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 ;
async
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:
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:
- Collaborative candidates from shared positive targets, with search semantic neighbours folded in when vectors exist.
- Attribute affinity over declared facet values of authorized positive seeds.
- Popularity from positive activity in
RECOMMEND_POPULARITY_WINDOW, default 30 UTC days. - Recent eligible documents, using
published_at,created_at, orupdated_atwhen 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()
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 ;
async
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_engagededge when at leastRECOMMEND_MIN_PEERSactors hold both among their most recentRECOMMEND_GRAPH_FANOUTpositives. Positive events count, and only the aggregate is stored, so no edge describes one person. - Public engagement. For the event types
RECOMMEND_PUBLIC_EVENT_TYPESlists, each signed-in actor gets anengagededge 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>andrelation:<name>:inverse.
use ;
// Each track points at its artist, at 80 percent confidence.
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.memgraphkeeps it in a Memgraph server over the Bolt protocol. Enable thegraph-memgraphfeature and setRECOMMEND_GRAPH_URLto abolt://orbolt+s://address without credentials;RECOMMEND_GRAPH_USERNAMEandRECOMMEND_GRAPH_PASSWORDare read only when the store connects. Memgraph holds the graph in memory, so after its data is lost, runrecommendations:graph-rebuildto 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
unsearchableends, 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_DEPTHhops 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_atorupdated_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 ;
async
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:
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
- Search - declare targets, scopes, facets, and semantic providers.
- Task Scheduling - register generation and retention tasks.
- Cache - configure the generation lock backend.
- Migrations - register the versioned data contract.
- Environment Variables - configure bounds and event scores.
