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.
vector_search(conn, embedder, query, k, table='item_vectors')
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.