Manual contentsDigging DeeperBrowse 136 chapters
Manual 19 min read

Recommendations

You record interactions with searchable documents and read recommendations through Recommend. The starter engine uses SQL events, search facets, timestamps, scopes, and optional search vectors. You need no embedding model to generate collaborative, attribute-affinity, popularity, and recent results. The Recommender trait lets you replace the engine without changing callers.

The purpose of the starter engine is to build the data the graph recommender needs: immutable events, materialized recommendations, explanations, and run records under a versioned storage contract. Two engines ship: starter, the default, and graph, which you select with RECOMMEND_ENGINE=graph once your events carry enough shared engagement (see Rank with the graph engine).

Record events

Install the migrations and provision the target's search index first. In an authenticated request, this function records a view of an existing searchable note and returns its provenance:

use suprnova::{
    EventProvenance, EventType, FrameworkError, Recommend,
    RecommendationEvent, RecommendationTarget,
};

async fn record_note_view(key: &str, dedupe: &str) -> Result<EventProvenance, FrameworkError> {
    let target = RecommendationTarget { index: "notes".into(), key: key.into() };
    let event = RecommendationEvent::new(EventType::View, target, None, "notes", dedupe).await?;
    Recommend::record(event).await
}

RecommendationEvent::new(event_type, target, actor, surface, dedupe_key) loads the registered target's document. It captures its source version, the index's guest access filter as canonical JSON, and the current UTC time. A missing or ineligible target is an error. The access-scope snapshot describes the fact's record-time policy; it does not replace the current viewer's scope on reads. Recording requires a target, a non-null scope equal to the descriptor's guest filter, and a source version. The event also stores its type, weight, actor, surface, dedupe key, and scoring snapshot.

With actor=None, Auth supplies the authenticated user. For guests, resolve Actor::current(Some(server_session_id)).await? or Actor::guest(server_session_id)? and pass that actor to the constructor. Guest actors store a lowercase SHA256 session digest, never the raw session. Trusted background code can use Actor::User(user_id). Never take an actor from a request body: Actor and RecommendationEvent have no Deserialize implementation. Resolve identities on the server.

A dedupe key is unique per actor. Repeating an actor/key pair returns the original event id and digest and leaves that first event unchanged, even if the retry changes its type or weight. Separate actors can use the same key. Use a stable interaction identity for retries. Surface and dedupe key allow 1-128 bytes without control characters; target index allows 128 bytes and target key 255.

Recommend::record validates the actor and dedupe key before resolving a retry. An existing fact returns its original provenance even after its target is deleted or its access scope changes. A first write still requires a live target and a matching access-scope snapshot.

Event types and scoring

Every event's score is its base plus signed integer weight / 20. Weight is in basis points from -10000 to 10000, defaults to zero, and division truncates toward zero. Class determines how the event affects recommendations; changing weight does not turn an impression into positive affinity.

Event type Rust variant Class Base score
view EventType::View Positive 1000
consume EventType::Consume Positive 1000
complete EventType::Complete Positive 1200
skip EventType::Skip Negative -1000
hide EventType::Hide Negative -1000
report EventType::Report Negative -1000
like EventType::Like Positive 1400
favorite EventType::Favorite Positive 1600
share EventType::Share Positive 1500
comment EventType::Comment Positive 1200
follow EventType::Follow Positive 800
impression EventType::Impression Seen 0
click EventType::Click Positive 300
purchase EventType::Purchase Positive 2000
Application name EventType::Custom(String) Positive 500

EventType::from_name recognizes the shipped names even when supplied through Custom. Declare application classes and base scores through RecommendConfig.event_types or RECOMMEND_EVENT_TYPES:

{"playlist_add":{"class":"positive","score":1600},"dismiss":{"class":"negative","score":-1200}}

Classes are positive, negative, and seen. Override scores must be between -1000000 and 1000000. Overrides also accept shipped event names. Existing events keep their stored class and score when configuration changes.

Generate with the starter engine

The batch reads at most RECOMMEND_HISTORY newest positive and negative events per actor combined, ordered by time and id; the default is 500. Seen impressions are aggregated separately in SQL and do not consume those slots. Shared positive targets identify peers, sampled to RECOMMEND_PEERS (200) by their most recent shared activity. Candidate scores sum sampled peers' positive scores. A collaborative candidate needs at least RECOMMEND_MIN_PEERS (3) distinct peers: with fewer, it can still arrive through a later tier, which names no peers. Otherwise a suggestion whose peer count is 1 would point at that one person, and the suggestion would be one of their own views or purchases. Recommend::similar_to counts co-occurrence under the same rule. Negative and previously seen/interacted targets are excluded. Materialized reads also enforce the current peer minimum, so older rows cannot reveal a lone peer while waiting for the next generation. That generation refreshes all actors when ranking settings change, replacing stale scores and explanations even when no history changed. Scores sort descending with (index, key) as a deterministic tie-break.

When the higher tier is short, results fill in this order:

  1. Collaborative candidates from shared positive targets, with search semantic neighbours folded in when vectors exist.
  2. Attribute affinity over declared facet values of authorized positive seeds.
  3. Popularity from positive activity in RECOMMEND_POPULARITY_WINDOW, default 30 UTC days.
  4. Recent eligible documents, using published_at, created_at, or updated_at when declared, otherwise the search source version.

Declare facet and timestamp attributes on your searchable models to supply those signals. Semantic retrieval reuses the target index's provider, document profile, generation, and vector store. It creates no second vector store. Every target passes the viewer's search scope inside candidate retrieval and again against its live document before display. Removed or inaccessible documents cannot supply displayed evidence.

Run cargo run --bin console -- recommendations:generate on demand. Add this task to your application's schedule:

schedule.add(
    schedule.command("recommendations:generate").hourly().without_overlapping()
);

schedule.command("recommendations:generate").hourly().without_overlapping() uses the same generation path as an on-demand command. The starter also holds a renewable cache lock named recommendations:generate, with RECOMMEND_LOCK_TTL_SECS defaulting to 3600 seconds. Configure a shared lock backend when several processes generate recommendations. Contention returns GenerationOutcome::Locked immediately; a lost lease aborts the run.

Runs and explanations

Runs have running, complete, or failed states. A run stages rows under its own id and publishes by marking itself complete in one statement. Reads select only the latest completed run and retrieve ranked rows and evidence together in bounded pages pinned to that run. Previous rows are removed only after publication. A failed run removes its staged rows and explanations, keeps an error record, and leaves the previous completed run current.

Generation tracks a completed high-water mark, new events, capped history snapshots, a search catalog digest, and a ranking settings digest covering the history cap, peer cap, peer minimum, and popularity window. It regenerates affected actor/surface pairs and copies unaffected rows into the next snapshot. History and catalog checks catch late commits and changed content. A filtered --user or --surface run preserves other rows and does not consume their pending global delta or a pending ranking settings refresh.

recommendation_runs records start time, duration in milliseconds, events read, new events, users covered, items scored, coverage, fallback share, high-water mark, catalog digest, ranking digest, history snapshots, state, and error. recommendations:status prints the ten latest attempts. The committed million-event fixture has a 300-second generation budget; run its ignored budget test separately from ordinary application tests.

Every materialized row carries its run id and explanation id. The public explanation contains peer and shared-target counts, authorized affinity attribute values, a tier (collaborative, semantic, affinity, popularity, or recent), and shared-target titles only where the viewer can see them. It exposes no peer identity, peer interaction, or private evidence id. Stored evidence retains event provenance for derivation without exposing it in the display projection.

New starter explanations retain every contributing positive event, including both actors' events on each shared target and repeated events whose scores were summed. Verify each stored id/digest pair with Recommend::verify_provenance; a deleted contributor returns Removed. Regenerate older recommendations to refresh their stored evidence.

Read recommendations

Select the subject and surface, and let the request resolve the viewer:

use suprnova::{FrameworkError, Recommend, Recommendation, Viewer};

async fn home_recommendations(user_id: &str) -> Result<Vec<Recommendation>, FrameworkError> {
    let viewer = Viewer::current().await?;
    Recommend::for_user(user_id)
        .surface("home")
        .limit(10)
        .for_viewer(viewer)
        .get()
        .await
}

for_user takes a string-like user id, not an authorization decision. Your application authorizes which subject a caller may request. The viewer comes from Auth when get() executes or from explicit for_viewer. Without an identity, the guest scope applies. Recommend::for_actor(actor) supports a trusted guest actor too. The default surface is home, the limit is 10, zero returns no rows, and limits above 10000 fail. Before a completed run exists, materialized reads return no recommendations.

Materialized reads hydrate only a page's candidate targets and shared evidence targets. They refill after authorization filtering until the requested limit is met or the ranked rows run out. On-demand similarity loads bounded histories only for the selected peers and the viewer, preserving peer selection and score order. Custom search engines support SearchEngine::stored_keys for keyed starter reads, as they do for graph reads, so removed search documents stay excluded.

Recommend::similar_to(RecommendationTarget { index: "notes".into(), key: "1".into() }).for_viewer(viewer).limit(10).get().await? computes co-occurrence with optional semantic neighbours on demand. It refuses a missing or inaccessible seed before scoring. The viewer's negatives and seen targets are excluded. Its results have run_id=None; materialized results name the completed run. Both return a target, declared SearchDocument, score, one-based returned rank, and projected explanation.

Rank with the graph engine

The graph engine keeps a knowledge graph of your search documents and ranks from it live, at read time. Every node and edge it holds names its source, and every hop it takes stays inside the viewer's scope. When the graph supports a list too thinly, it answers with the starter engine's rows for the same viewer and says so, so turning it on early costs nothing.

Turn it on

Set RECOMMEND_ENGINE=graph. Recommend::generate then publishes the starter run as before, because those rows are the fallback, and pushes a GraphDeriveJob onto your queue, so the graph engine needs a configured queue. The job rebuilds the graph into a new generation and switches readers to it only when the rebuild completes; a failed rebuild leaves readers on the previous one. Run recommendations:graph-rebuild or Recommend::rebuild_graph() to rebuild at once.

What the graph holds

A rebuild reads the event stream, within each actor's RECOMMEND_HISTORY bound, and the relations you declare, over the documents still in their search index:

  • Shared engagement. Two documents get a co_engaged edge when at least RECOMMEND_MIN_PEERS actors hold both among their most recent RECOMMEND_GRAPH_FANOUT positives. Positive events count, and only the aggregate is stored, so no edge describes one person.
  • Public engagement. For the event types RECOMMEND_PUBLIC_EVENT_TYPES lists, each signed-in actor gets an engaged edge to the documents they engaged with. No type is public unless you list it; a type that is not public never becomes a per-actor fact.
  • Declared relations. A relation reads related documents from a document's search projection, so the graph follows it without loading models. Its edges run both ways, as relation:<name> and relation:<name>:inverse.
use suprnova::{AttributeValue, FrameworkError, GraphRelation, Recommend, RecommendationTarget};

// Each track points at its artist, at 80 percent confidence.
pub fn declare_relations() -> Result<(), FrameworkError> {
    Recommend::graph_relation(GraphRelation::new("artist", "tracks", 8_000, |document| {
        Ok(match document.attributes.get("artist_id") {
            Some(AttributeValue::Integer(id)) => vec![RecommendationTarget {
                index: "artists".into(),
                key: id.to_string(),
            }],
            _ => Vec::new(),
        })
    })?)
}

Every fact carries the scope of the document it describes, that document's source version, and its provenance: an event's id and content digest, or the relation and the document revision it was read from. A rebuild is deterministic, so rebuilding from the same stream and documents reproduces the same facts. Between rebuilds the graph follows its sources: a search write queues a GraphDocumentJob that removes what a deleted document held and replaces what a newer revision changed, and Recommend::prune removes the facts resting on the events it deletes. It deletes the oldest events first, in pages, and revokes each page's facts before the page goes, so a failed revocation leaves the events for a retry to find. A shared-engagement edge names its oldest event as provenance, so it goes as soon as one actor's event behind it does. A removed fact is never read, traversed or cited again.

A document revision replaces its node in place before removing stale declared relations, so its event-backed engagement edges keep counting. If the job fails after saving the node, retrying the same revision finishes the relation writes. Custom graph stores handle Revocation::DocumentRelationsBefore by removing older relation-derived edges and auxiliary nodes while preserving the document node itself. DocumentBefore retains its existing broader removal behavior.

Where the graph lives

The graph lives behind the GraphStore trait, which RECOMMEND_GRAPH_STORE selects:

  • database, the default, keeps the graph in your application's database (SQLite, Postgres, MySQL or MariaDB), in the tables the recommendation migrations create, so the graph engine needs no other service.
  • memgraph keeps it in a Memgraph server over the Bolt protocol. Enable the graph-memgraph feature and set RECOMMEND_GRAPH_URL to a bolt:// or bolt+s:// address without credentials; RECOMMEND_GRAPH_USERNAME and RECOMMEND_GRAPH_PASSWORD are read only when the store connects. Memgraph holds the graph in memory, so after its data is lost, run recommendations:graph-rebuild to restore it from the stream.

A store only persists facts and answers bounded hops; the framework applies the viewer's scope at every hop and computes every score, so both stores rank the same graph the same way. Bind your own GraphStore in the container to replace both.

How the graph ranks

A read authorizes before it scores. Seeds, negatives, every traversed edge and every candidate must pass the viewer's scope. The read also checks every fact it walks, scores or cites against its live source:

  • a document node counts only while its document is still in its index, which unsearchable ends, and the viewer may see the live document;
  • a fact resting on an event counts only while the event is still in the stream;
  • a relation fact read from an older revision of its source counts only while the relation still finds it in the live revision.

So a fact whose GraphDocumentJob is still queued, or one a rebuild read before its source changed, never counts, while a document saved again keeps counting. The blended score then weighs:

  • graph affinity, the strongest path of at most RECOMMEND_GRAPH_DEPTH hops from one of the viewer's positives, weighted by that positive;
  • semantic similarity in the search collection's own vectors (see below);
  • social proximity (see below);
  • freshness, from published_at, created_at or updated_at;
  • the viewer's negatives near the candidate, subtracted;
  • trust risk, the target's hide and report events weighed against its positive events, subtracted.

RECOMMEND_GRAPH_SURFACES sets per-surface weights, a confidence threshold and diversity caps in basis points and counts; a surface it does not name uses the defaults:

RECOMMEND_GRAPH_SURFACES='{"home":{"affinity":5000,"threshold":6000,"per_value":2}}'

The caps keep one list from collapsing: at most per_value results share one facet value, at most per_node are reached through one intermediate node, and at most per_band come from the most popular tenth of the catalog. The confidence is the share of the list that graph paths or social proximity support. Below the surface's threshold the engine returns the starter's rows, with explanation.engine set to starter and explanation.fallback_reason saying why.

A graph row's explanation has engine set to graph and evidence listing the stored facts it rests on: the viewer's own events, as id and digest, the edges of the path, and the seed documents, every one of which the viewer may see. It never names a peer or a followed actor. Serialized explanations omit engaged and co_engaged edge references because their keys can be recomputed from known actors or documents. The server-side explanation retains those references for verification against the graph store; public counts remain available. Keep the server-side explanation when verifying engagement evidence: serializing and loading it again does not restore those private references.

Social proximity

Social proximity counts followed actors who publicly engaged with a target. Set RECOMMEND_ACTOR_INDEX to the search index whose documents are your users, keyed by user id; a viewer's follow events on that index's documents name the actors they follow. Only engaged edges, which exist only for public event types, count, and a target gains social proximity only when at least RECOMMEND_SOCIAL_MIN_ACTORS followed actors agree on it, so a viewer's list never reveals what one followed account did. The explanation's peer_count reports how many agreed.

Similar items, vectors and discover

The graph engine reads the vectors your search collection already stores; it keeps no copy. Where the index's search engine is Qdrant, it uses Qdrant's own recommend and discover operations by point id, under the viewer's scope filter. On the database and collection engines, it folds the stored vectors into one query, the positives' mean pushed away from the negatives', and ranks the result. The positive and negative points are always the viewer's own events on documents the viewer may see. A graph read never steers by another person's events: Recommend::for_user(id) read by a viewer other than that user answers with the starter's rows for the user, with fallback_reason saying why, and so does Recommend::for_actor(actor) with a guest actor read by a signed-in viewer.

Recommend::similar_to(target) seeds the graph and the vectors with one document; the viewer's negatives push the vectors away and, like the targets the viewer has already seen, are left out of the list. Recommend::discover(target) searches near a document and ranks the neighbourhood by context pairs built from the viewer's own positive and negative events. Every negative joins a pair, repeating positives when there are fewer of them, or pairing with the seed when the viewer has none. It needs the graph engine and refuses a seed the viewer cannot see.

Bridge search events

Set both SEARCH_ANALYTICS=true and RECOMMEND_ENABLED=true to forward shown search impressions and validated clicks into the event stream. The bridge uses the authenticated actor or a server-session hash, surface search, and dedupe keys derived from impression id, event type, and shown position. Impressions count as seen; clicks contribute the click score.

Search verifies the impression's session, expiration, key, and zero-based position before forwarding a click. A forged key or position supplies no recommendation event. The POST /search/click endpoint also requires CSRF. See Search for analytics middleware and endpoint registration.

RECOMMEND_ENABLED controls automatic search forwarding. It does not disable explicit Recommend::record or generation. Turning search analytics off stops its event production; it does not erase recommendation history.

Verify provenance and control retention

Recommend::record returns EventProvenance { id, digest }. The digest is SHA256 over canonical event content, including its stored class and score. Keep the pair together when you derive evidence. Verification re-reads content and recomputes the digest:

use suprnova::{EventProvenance, FrameworkError, ProvenanceState, Recommend};

async fn evidence_is_intact(reference: &EventProvenance) -> Result<bool, FrameworkError> {
    Ok(Recommend::verify_provenance(reference).await? == ProvenanceState::Intact)
}

Intact means content matches, Altered means the id exists with changed or invalid content, and Removed means retention removed the event. A configuration change does not rewrite an earlier scoring snapshot.

Engines never prune or rewrite the append-only event stream. Your application owns retention. Run recommendations:prune --older-than 90 only when that is your retention policy; the argument is a positive number of days. There is no automatic event-expiration default. Pruning search analytics is separate.

Commands

Run these through your application's binary:

Command Arguments and flags Purpose
recommendations:generate [--surface name] [--user id] Publish a complete snapshot or report lock contention.
recommendations:status None Print the ten latest runs, including failures.
recommendations:prune --older-than days Apply your event retention policy.
recommendations:graph-rebuild None Rebuild the graph engine's facts into a new generation.

Value flags accept --name value or --name=value. Unknown, repeated, empty, and missing values fail. You can also call Recommend::generate(GenerateRun), Recommend::status(limit), and Recommend::prune(days); status limits are at most 1000. GenerateRun has optional user and surface strings.

Migrations and the data contract

Extend your application's migration list with suprnova::recommendations::migrations(). The kits already include this and suprnova::search::migrations(). The recommendation migrations create:

Table Stored contract
recommendation_events Immutable id, actor kind/id, target index/key, type, weight, surface, UTC microsecond time, access scope (the index scope's guest filter and the target's values for the index's scope_fields when the event was recorded), source version, dedupe key, class, and score.
recommendation_explanations Run id, event-id/digest provenance pairs, attribute values, peer count, shared-target count and references, and fallback tier.
starter_recommendations Run id, actor, surface, target index/key, score, rank, and explanation id.
recommendation_runs Publication state and batch statistics, watermarks, catalog digest, ranking digest, history snapshots, and error.
recommendation_schema recommendation_schema_version, 2 once the graph migration runs.
recommendation_graph_generations Graph generations and which one readers use.
recommendation_graph_nodes Nodes per generation: key, type, target, scope, source version, and provenance.
recommendation_graph_edges Edges per generation: key, endpoints, type, confidence, scope, source version, provenance, and times.

The data this engine builds is the graph engine's input under that versioned contract. Event identity plus content digest distinguishes intact evidence from facts removed by retention. These tables preserve the handoff without requiring a graph to serve starter recommendations; the graph engine reads them when you select it.

Variables

Variable Default Purpose
RECOMMEND_ENABLED false Forward validated search events automatically.
RECOMMEND_HISTORY 500 Newest positive and negative events per actor combined.
RECOMMEND_PEERS 200 Most recent shared peers to sample.
RECOMMEND_MIN_PEERS 3 Distinct peers a collaborative candidate needs.
RECOMMEND_POPULARITY_WINDOW 30 UTC days of positive activity for popularity.
RECOMMEND_LOCK_TTL_SECS 3600 Renewable generation lock lifetime.
RECOMMEND_EVENT_TYPES {} JSON class and base-score overrides.
RECOMMEND_ENGINE starter starter or graph.
RECOMMEND_GRAPH_STORE database database or memgraph.
RECOMMEND_GRAPH_URL unset The Memgraph Bolt address, without credentials.
RECOMMEND_GRAPH_USERNAME unset The Memgraph user, read when the store connects.
RECOMMEND_GRAPH_PASSWORD unset The Memgraph password, read when the store connects.
RECOMMEND_GRAPH_DEPTH 2 Most hops in a graph path, 1 to 4.
RECOMMEND_GRAPH_FANOUT 50 Most edges followed out of one node per hop.
RECOMMEND_GRAPH_MAX_NODES 2000 Most nodes one walk reaches.
RECOMMEND_PUBLIC_EVENT_TYPES unset Comma-separated event types other viewers may see.
RECOMMEND_SOCIAL_MIN_ACTORS 3 Followed actors who must agree for social proximity.
RECOMMEND_ACTOR_INDEX unset The search index whose documents are users.
RECOMMEND_GRAPH_SURFACES {} JSON per-surface weights, threshold, and caps.

RecommendConfig::current() uses registered configuration, environment configuration, or a task-local override from RecommendConfig::scope. Malformed values and zero numeric bounds fail with the variable name. See Environment Variables for types and limits.

Testing

Use a migrated test database, a scoped searchable test model, a fresh CollectionEngine, and SearchRegistry::isolated to exercise the real event and read paths without external services. Set SearchConfig.queue=false within SearchConfig::scope when your test needs inline indexing. Add a FakeEmbedder only when testing semantic neighbours, and keep provider qualification separate from ranking tests.

Record shared positives for the subject and three peers, an unseen candidate the peers share, and negative and seen facts for the subject. Generate a run and check the candidate, fallback order, authorized explanations, immutable dedupe result, and provenance after retention. Also check that a failed run leaves the previous completed run readable and that another viewer cannot read an inaccessible seed or target.

The repository's suite includes these behaviors and an ignored million-event budget fixture:

cargo nextest run -p suprnova --test recommendations
cargo nextest run -p suprnova --test recommendations --run-ignored only \
    -E 'test(million_events_complete_within_the_committed_budget)'

Why Suprnova diverges

Laravel has no first-party recommender. Suprnova supplies an immutable event stream, an explainable SQL starter, and a knowledge-graph engine behind a replaceable trait. You reuse search documents, scopes, and vectors, keep the graph in your own database or in Memgraph, and retain provenance from every recommendation back to its events instead of tying interaction history to a hosted recommendation service.

Next