Skip to content

rank

Per-user ranking: profile cosine + click-trained classifier + feature-preference term, blended by rank.

EmbedderUnavailable

Bases: RuntimeError

The embedder is down and there is no cached vector to fall back on.

Its own type so the web server can tell this expected, cold-start condition apart from a bug: the profile-vector cache is in-process memory, so a freshly started attest serve is cold for every reader and this is the FIRST thing a new user hits when Ollama is not running yet.

RankedItem

Bases: BaseModel

One feed item as ranked for a reader -- score is a rank WITHIN the candidate set that produced it (see the comment on profile_similarity below), never comparable across two different calls.

to_row(*, summary=False)

The compact wire shape feed.list, feed.search and feed.digest all return -- the one projection from RankedItem to the response an agent actually reads, so a caller of this type can see the shape it will eventually take without cross-referencing a shaper in the MCP layer.

Deliberately small: a ten-item response used to run past 3,000 characters, and gemma4:e2b could not reproduce one -- it truncated, apologised, re-rendered as raw JSON, truncated again, and never recovered. score is never included: it is a blended RANK within a candidate set, not a value comparable across calls, and order already carries the ranking. Tags are capped at MAX_TAGS_SHOWN with n_tags reporting the true count, because silent truncation is how an agent is told an item has three topics when it has six.

apply_relevance_floor(sims)

Keep hits within RELEVANCE_FLOOR of the RELEVANCE_ANCHOR-averaged best similarity in sims. The policy half of a semantic search, over a plain rowid -> similarity dict so the three rounds of live tuning documented above can be regression-tested with no database and no model.

autocreate_user(conn, name)

Create a reader on first sight, seeded from what the corpus covers.

Refusing an unknown name and listing the valid ones taught agents to call persona_create with whatever string they had: the live database grew a duplicate persona with zero clicks that way, days after that reader had been merged away. The refusal did not prevent the duplicate, it caused it.

avg_ranks(scores)

Rank 0 = highest score; tied scores share their mean rank (no tie-break noise).

blend_weight(n_clicks)

How much a persona's click-classifier rank counts against its embedding-profile rank, 0 at no clicks rising asymptotically toward 1 -- so a brand-new reader is ranked purely by profile similarity, and only a click history shifts weight toward the learned classifier.

bootstrap_persona(conn, embedder, user_name, k=30)

Pseudo-clicks for a synthetic persona: top-k/2 by profile similarity -> useful, bottom-k/2 -> not useful. Optional demo garnish; persona switch works without it.

DEMO FIXTURE ONLY -- NOT GROUND TRUTH. The useful/not-useful label is a deterministic linear threshold on the exact same embedding X that classifier_probs() trains on (argsort(X @ embed_query(interests)), top half vs. bottom half). A linear classifier fit on X to predict a linear threshold of X recovers it essentially perfectly, so any AUC computed over these rows is a tautology, not a measurement of ranking quality. evaluate_user() excludes source='bootstrap' clicks for exactly this reason -- never remove that filter to "get more eval data".

classifier_probs(click_rows, X)

P(useful) for each row of X from a persona's own click history (click_rows: dicts with useful and a float32 embedding), or None when there is not yet a two-class history to learn from -- callers must fall back to embedding-only order on None rather than treat it as all-zero. Takes rows, not a connection, so the blend can be exercised on literal vectors.

create_user(conn, name, interests)

Insert a persona. Ranking starts from the interests embedding alone.

The INSERT is authoritative, not the preceding read. Check-then-insert has no transaction around the pair, so concurrent first sight of one reader had 15 of 16 callers raise -- and that escapes /list as a 500 on the FIRST page load for a new reader, which is what autocreate exists to serve. The database was never wrong (UNIQUE held, one row); only the losers were told something false.

Losing the race is not an error: the persona the caller asked for exists. A caller that genuinely needs "did I create this" can compare the returned id, and feed.persona_create still refuses a name that already existed when it looked -- that refusal is a UX decision, made above this line.

evaluate_user(conn, user_id, n_holdout=5)

Stratified-holdout AUC over real (non-bootstrap) clicks only.

Honest noise at small n -- never present as evidence.

source='bootstrap' clicks are excluded: their useful/not-useful label is a deterministic linear threshold on the same embedding the classifier trains on (see bootstrap_persona docstring), so any AUC computed over them is a tautology rather than a measurement.

The holdout fold is drawn with a fixed-seed stratified split rather than "last n_holdout rows in clicked_at order": clicked_at defaults to second-resolution datetime('now'), so rows inserted in one batch (as bootstrap_persona and any naturally label-sorted rating session do) tie and fall back to insertion order, making a trailing slice single-class by construction -- always returning None instead of an honest score.

forget_profile_vector(conn, user_id)

Drop this user's cached profile vector. Call after changing or deleting the persona.

Necessary because of how the two cache paths interact. The hash check in _profile_vector normally makes eviction unnecessary -- changed interests text hashes differently and misses. But the embedder-down fallback deliberately returns a cached vector WITHOUT comparing hashes, since a stale vector beats a dead feed. Together: change the interests, lose the embedder, and the fallback serves a vector computed from text the user already replaced, silently.

Evicting here means that case raises the honest cold-cache error instead.

Exposed as a function so callers do not reconstruct the key by hand -- mcp_server used to do exactly that, which is why update_persona was missed.

get_user(conn, name)

A persona by name, matched without regard to case.

Case-sensitive lookup turned one shift key into a second account: feed.list(user="Matt") missed matt, autocreate made a fresh persona, and 70 clicks were discarded while the reader was greeted as new. That is the duplicate-persona failure CLAUDE.md records -- autocreate removed the refusal that caused it and left the key's case scope untouched.

create_user checks through here, so creation inherits the same folding and a second spelling is refused rather than shadowing the first. The stored spelling is whatever was passed: preserved on write, folded on read.

literal_candidates(conn, query, limit)

Item ids whose title or summary contains the query as a substring.

Search blends a literal match with a semantic one, and a literal hit is not always a semantic hit -- "CRISPR" in a title the embedding places elsewhere is still the item the reader asked for. The old path got these free by ranking every row; restricting to sqlite-vec's top-k would have silently dropped them, which is a behaviour change rather than an optimisation.

rank_items(conn, embedder, user_id, since_days=14, *, exclude_clicked=True, only_ids=None)

The candidate items for user_id, blending profile similarity with the click classifier via blend_weight.

since_days=None with exclude_clicked=False is search_feed's semantics -- an older or already-rated item is a legitimate hit there. A cold embedder degrades to a cached profile vector (see _profile_vector) rather than failing the whole call; ranking never waits on explain.explain, which runs separately and can return None.

rank_rows(rows, profile_vec, click_rows, pref, n_clicks)

The blend, on rows: profile cosine, the click classifier (silent on a single-class history) and the tie-averaged preference term, mixed by blend_weight(n_clicks). Pure: no connection, no embedder -- the reader rank_items supplies rows with float32 embeddings and optional tags/ content_type, and examples/ranking/ calls this on literal vectors.

ranking_quality(conn, user_id)

How much to trust the ordering, stated up front.

A digest built from an untrained ranker looks exactly like one built from a good one. rank.classifier_probs returns None when a user's clicks are all one class (rank.py's single-class guard), so the click-CLASSIFIER term never fires -- but rank_items blends in a second, independent term (avg_ranks over pref_scores_for_items) whenever n_clicks > 0, regardless of the guard. So a single-class history with at least one click is NOT pure embedding similarity: the feature-preference term still contributes, only the classifier is silent. Naming which terms are actually contributing matters more than a blanket "profile-embedding only" claim, which is wrong in exactly the case this caveat exists to describe.

ranks(scores)

Rank 0 = highest score.

record_click(conn, user_id, item_id, useful, source='ui')

The single click write path. source records provenance (see CLICK_SOURCES).

SQLite cannot express a CHECK constraint added via ALTER TABLE, so the enum is enforced here rather than in the schema.

rowid -> similarity, via a sqlite-vec index (item_vectors or reference_vectors). The query half of a semantic search, split from apply_relevance_floor's policy so each can be read and tested on its own.

Indexed with DOC_PROMPT and searched with QUERY_PROMPT: embed.py's prompts are asymmetric because the model was trained that way, and mixing them measurably degrades retrieval.

Vectors are L2-normalised by truncate_normalize, so sqlite-vec's L2 distance d relates to cosine similarity as cos = 1 - d^2/2.