docs(6205): embeddings comments state the reason, not the instance that taught it
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m10s
CI & Build / Python tests (push) Successful in 1m54s
CI & Build / Build & push image (push) Successful in 23s

Record ids, milestone tags, dated measurements and "used to" stories are
removed; the chunker version list now says what each stamped version
contains. Corrected two comments that said a failed search leaves
report["searched"] absent (it is stamped False first), and pointed the
rule-search docstring at _RULEHINT_BAND in retrieval_pipeline instead of a
tier note in plugin_context that no longer exists. plugin_context's own
dangling "note on tiers" pointer is reworded to match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 20:04:59 -04:00
co-authored by Claude Opus 5.5
parent 4ebd8e88fb
commit 0c24fa392c
2 changed files with 198 additions and 215 deletions
+195 -212
View File
@@ -45,10 +45,8 @@ _SIMILARITY_THRESHOLD = 0.45
# The floor for INTERACTIVE, human-facing feeds — REST /api/search, Browse # The floor for INTERACTIVE, human-facing feeds — REST /api/search, Browse
# search, and the list views' semantic `q`. Deliberately looser than the agent # search, and the list views' semantic `q`. Deliberately looser than the agent
# default above: a human scanning a result list gets value from loosely-related # default above: a human scanning a result list gets value from loosely-related
# hits an agent would be misled by. One constant, because this number was # hits an agent would be misled by. One constant, so a tuner cannot move the
# written as `0.3` in two files with the reasoning attached to only one of # number in one file and not another.
# them — the exact shape where a later tuner moves one and not the other
# (#2463 finding 3).
INTERACTIVE_SEARCH_THRESHOLD = 0.3 INTERACTIVE_SEARCH_THRESHOLD = 0.3
# Public alias so callers (and telemetry) can record the effective default # Public alias so callers (and telemetry) can record the effective default
@@ -80,18 +78,18 @@ async def _get_model():
return _model return _model
# ONE EMBEDDING PER QUERY PER REQUEST (milestone 456 step 1). # ONE EMBEDDING PER QUERY PER REQUEST.
# #
# A single plugin request runs several ranked arms over the SAME string: the # A single plugin request runs several ranked arms over the SAME string: the
# operator's message is searched by auto_inject, its reuse and lesson slots, # operator's message is searched by auto_inject, its reuse and lesson slots,
# prompt_rule, the preference slot and rule_via_lesson — each of which called # prompt_rule, the preference slot and rule_via_lesson — each of which calls
# `get_embedding` on its own, so one prompt cost up to six model calls for one # `get_embedding` on its own. Without a memo one prompt costs up to six model
# vector. The arms are not wrong to search separately; they were wrong to pay # calls for one vector. The arms search separately; they need not pay for the
# for the vector separately. # vector separately.
# #
# A memo, not a cache: it lives for one request and is opened explicitly by # A memo, not a cache: it lives for one request and is opened explicitly by
# `query_embedding_memo()`. Outside a scope the default is None and nothing is # `query_embedding_memo()`. Outside a scope the default is None and nothing is
# remembered, so every other caller behaves exactly as before. Keyed on the # remembered, so every other caller embeds on every call. Keyed on the
# exact text — a query that differs by a character is a different query. # exact text — a query that differs by a character is a different query.
# #
# Only `get_embedding` (a QUERY) consults it. `get_embeddings` embeds a # Only `get_embedding` (a QUERY) consults it. `get_embeddings` embeds a
@@ -170,19 +168,19 @@ def _cosine_similarity(a: list[float], b: list[float]) -> float:
return dot / (mag_a * mag_b) return dot / (mag_a * mag_b)
# How much a superseded record is pushed down the ranking (#278). # How much a superseded record is pushed down the ranking.
# #
# Chosen against a measurement, not by feel. On 2026-08-07 dev-log #2420 sat at # Chosen against a measurement, not by feel. A dev-log queried with its own
# 0.6120 on a query made of its own title phrase, 8th, behind #1759 at 0.6506 — # title phrase can sit 8th, ~0.04 below the top hit and ~0.014 from its
# a deficit of 0.039 to the top and ~0.014 to its nearest neighbours. A penalty # nearest neighbours. A penalty of 0.05 clears that whole band, so demoting a
# of 0.05 clears that whole band, so demoting a cluster's stale members actually # cluster's stale members actually reorders it rather than shuffling within a
# reorders it rather than shuffling within a tie. # tie.
# #
# It is deliberately NOT large. Supersession is a claim about SOME of a record's # It is deliberately NOT large. Supersession is a claim about SOME of a record's
# content, so a superseded note that strongly answers a question nothing else # content, so a superseded note that strongly answers a question nothing else
# answers should still surface — just behind anything comparable that is # answers should still surface — just behind anything comparable that is
# current. A penalty big enough to bury it outright would be hiding by another # current. A penalty big enough to bury it outright would be hiding by another
# name, which is the thing the operator ruled out. # name, and a superseded record is demoted, never hidden.
_SUPERSESSION_PENALTY = 0.05 _SUPERSESSION_PENALTY = 0.05
# Candidates fetched per requested result when a re-rank follows. Three ranks of # Candidates fetched per requested result when a re-rank follows. Three ranks of
@@ -190,7 +188,7 @@ _SUPERSESSION_PENALTY = 0.05
# whose neighbours sit ~0.01-0.02 apart. # whose neighbours sit ~0.01-0.02 apart.
_SUPERSESSION_OVERFETCH = 3 _SUPERSESSION_OVERFETCH = 3
# Chunk rows fetched per requested result (#280). The HNSW top-k runs at CHUNK # Chunk rows fetched per requested result. The HNSW top-k runs at CHUNK
# grain — several chunks of one strong note can occupy consecutive ranks, and # grain — several chunks of one strong note can occupy consecutive ranks, and
# each collapses into a single result. Four ranks of headroom per result keeps # each collapses into a single result. Four ranks of headroom per result keeps
# the top-k indexed while making it effectively impossible for collapsing to # the top-k indexed while making it effectively impossible for collapsing to
@@ -244,15 +242,15 @@ async def _apply_supersession_penalty(
def embedding_text(title: str | None, body: str | None) -> str: def embedding_text(title: str | None, body: str | None) -> str:
"""`title\\n{body}` — the atomic join every embedded document is built from. """`title\\n{body}` — the atomic join every embedded document is built from.
One definition, deliberately. This was written out three times — the write One definition, deliberately. Three paths embed — the write path
path (`notes.embed_note`), the recurring-task spawn, and the startup (`notes.embed_note`), the recurring-task spawn, and the startup backfill —
backfill — and identical copies of a formatting rule are three chances to and identical copies of a formatting rule are three chances to change one
change one and not the others. The spawn path is the dangerous one: a and not the others. The spawn path is the dangerous one: a recurring task
recurring task embedded to a different shape than everything else would be embedded to a different shape than everything else would be ranked
ranked against a corpus it doesn't match, and nothing would report it. against a corpus it doesn't match, and nothing would report it.
Since the chunking build (#280) this is a BUILDING BLOCK, not the whole This is a BUILDING BLOCK, not the whole story: the document shape a
story: the document shape a record is embedded as is `chunk_document` record is embedded as is `chunk_document`
below, which calls this once per chunk. Callers that want "the text this below, which calls this once per chunk. Callers that want "the text this
note is embedded as" want `chunk_document`; this stays public because the note is embedded as" want `chunk_document`; this stays public because the
two functions are one contract and the guard in test_embedding_text pins two functions are one contract and the guard in test_embedding_text pins
@@ -272,16 +270,14 @@ TRIGGER_SEP = " — "
def trigger_title(subject: str | None, trigger: str | None) -> str: def trigger_title(subject: str | None, trigger: str | None) -> str:
"""`{subject} — {trigger}` — the title half of a situation-keyed document. """`{subject} — {trigger}` — the title half of a situation-keyed document.
ONE definition, because this join had three — rules, snippets, and a ONE definition for rules, snippets and lessons, so a fix or an
fourth for lessons (milestone 385) — the shape #3207 records, where a fix improvement is never hunted for in N places by someone who does not know
or an improvement then has to be found in N places by someone who does not N. It builds EMBEDDED titles only: `rule_document` for rules and
know N. Since milestone 427 it builds EMBEDDED titles only: `rule_document` `document_title` for snippets and lessons. No stored title carries it.
for rules and `document_title` for snippets and lessons. No stored title
carries it.
WHY THE JOIN MATTERS AT ALL, measured in note #2485: the snippet was the WHY THE JOIN MATTERS AT ALL, measured: snippets are the sharp records in
only sharp record in the corpus — a 0.153 top-to-second gap against the corpus — a ~0.15 top-to-second gap against 0.010–0.023 for
0.010–0.023 for everything else — and the cause was this title plus the everything else — and the cause is this title plus the
same trigger repeated in the body, so purpose appears twice in a short same trigger repeated in the body, so purpose appears twice in a short
document and dominates the vector. Every kind that must be findable by WHEN document and dominates the vector. Every kind that must be findable by WHEN
IT APPLIES rather than what it is about is built on this line. IT APPLIES rather than what it is about is built on this line.
@@ -301,11 +297,10 @@ def trigger_title(subject: str | None, trigger: str | None) -> str:
def untrigger_title(title: str | None, trigger: str | None) -> str: def untrigger_title(title: str | None, trigger: str | None) -> str:
"""The subject back out of a `trigger_title` — the inverse of the join. """The subject back out of a `trigger_title` — the inverse of the join.
Kept HERE, beside the join, for the reason the join itself was Kept HERE, beside the join, for the reason the join is one function: a
consolidated: a separator spelled in two files is a separator that will one separator spelled in two files is a separator that will one day be
day be changed in one of them. #3207 records the shape — derive it before changed in one of them, and an inverse written in a caller is another
the third copy — and an inverse written in a caller is that third copy copy wearing a different name.
wearing a different name.
Needs the trigger passed in rather than guessing at the separator, because Needs the trigger passed in rather than guessing at the separator, because
a subject may legitimately contain an em dash. Given the trigger, the a subject may legitimately contain an em dash. Given the trigger, the
@@ -337,17 +332,15 @@ def document_title(
) -> str | None: ) -> str | None:
"""The title a note is EMBEDDED under — its stored title, plus its trigger. """The title a note is EMBEDDED under — its stored title, plus its trigger.
Milestone 427. A snippet's or lesson's STORED title is its subject alone; A snippet's or lesson's STORED title is its subject alone; the trigger
the trigger lives in `data` (decision #4157). It still has to reach the lives in `data`. It still has to reach the vector — the `subject —
vector — the `subject — trigger` join is what makes these kinds rank on trigger` join is what makes these kinds rank on the situation they apply
the situation they apply to (#2485) — so it is joined HERE, at embed time, to — so it is joined HERE, at embed time, rather than being carried in a
rather than being carried in a title every listing then has to show. title every listing then has to show.
IDEMPOTENT, and that is what makes the migration safe: a title that is IDEMPOTENT: a title that is already composed (an old backup restored) is
already composed (a row not yet migrated, an old backup restored) is untriggered first, so it comes out the same and never doubled, and no
untriggered first, so it comes out the same and never doubled. The text is vector moves.
byte-identical to what these kinds were embedded as before, so no vector
moves and the floors tuned against them stay calibrated.
`body` is the fallback when the mirror is missing, read by the kind's own `body` is the fallback when the mirror is missing, read by the kind's own
parser — the same degrade-to-the-body each kind's reader already has. parser — the same degrade-to-the-body each kind's reader already has.
@@ -370,40 +363,37 @@ def document_title(
return trigger_title(untrigger_title(title, trigger), trigger) return trigger_title(untrigger_title(title, trigger), trigger)
# --- chunking (#280): the document shape ------------------------------------ # --- chunking: the document shape -------------------------------------------
# #
# bge-small reads at most 512 tokens and fastembed silently truncates the rest, # bge-small reads at most 512 tokens and fastembed silently truncates the rest,
# so a single whole-document vector loses everything past ~400 words — for a # so a single whole-document vector loses everything past ~400 words — for a
# long dev-log, three quarters of the record was PERMANENTLY invisible to # long dev-log, three quarters of the record would be invisible to search. So
# search. The fix is the document shape: one vector per meaningful chunk, and a # a record is embedded as one vector per meaningful chunk, and is as findable
# record is as findable as its best-matching section. # as its best-matching section.
# Bumped whenever THE DOCUMENT A RECORD IS EMBEDDED AS can change for a record # Bumped whenever THE DOCUMENT A RECORD IS EMBEDDED AS can change for a record
# that itself has not changed. Stored on every note_embeddings row so the # that itself has not changed. Stored on every note_embeddings row so the
# startup backfill re-embeds exactly the stale notes — a version comparison # startup backfill re-embeds exactly the stale notes — a version comparison
# instead of the table wipe migrations 0067/0077 had to do. # instead of a table wipe.
# #
# Stated that way rather than as "chunk_document's output for the same input", # Stated that way rather than as "chunk_document's output for the same input":
# which is what it used to say: `chunk_document` is only the last step, and # `chunk_document` is only the last step. A task's document carries its work
# version 2 moves without touching it. A task's document now carries its work # logs, so a new log changes what the task embeds as while its title, body and
# logs (#4251), so every task that has one embeds differently than it did while # the chunker are all untouched.
# its title, body and the chunker are all untouched — exactly the case the
# narrower wording would have read as "nothing to re-embed".
# #
# 1 → 2: work logs joined the task document. # 2: a task's document includes its work logs.
# 2 → 3: no chunk is a heading alone (#4784). A content-free chunk embeds # 3: no chunk is a heading alone. A content-free chunk embeds near the
# near the centre of the space and outranked real records on any # centre of the space and outranks real records on any vague query.
# vague query — "## Work log — 2026-08-21" took rank 1.
CHUNKER_VERSION = 3 CHUNKER_VERSION = 3
# The public name of the space every score lives in, and the two facts that # The public name of the space every score lives in, and the two facts that
# can invalidate a tuned number (#4104). # can invalidate a tuned number.
# #
# EMBEDDING_MODEL is `_MODEL_NAME` under a name other modules may read. It was # EMBEDDING_MODEL is `_MODEL_NAME` under a name other modules may read, so
# private until this step, which is precisely why nothing outside this file # anything outside this file can state what space a threshold was measured in
# could state what space a threshold was measured in — a floor is a distance in # — a floor is a distance in THIS model's geometry and means nothing in
# THIS model's geometry and means nothing in another's. # another's.
# #
# The pair is what a stamp is made of, and the pairing is the point: a score # The pair is what a stamp is made of, and the pairing is the point: a score
# changes when the model changes (different geometry) OR when the document # changes when the model changes (different geometry) OR when the document
@@ -430,12 +420,12 @@ def calibration_stamp() -> dict:
def is_current_stamp(table): def is_current_stamp(table):
"""The rows written by THIS chunker in THIS model's space (#4132). """The rows written by THIS chunker in THIS model's space.
Every embedding table stores both halves of `calibration_stamp()` per row, Every embedding table stores both halves of `calibration_stamp()` per row,
and a row is current only when both match. One predicate for all four and a row is current only when both match. One predicate for all four
tables, because a backfill that checked the version alone is exactly how a tables, because a backfill that checked the version alone would miss a
same-width model swap would have gone unnoticed. same-width model swap.
""" """
return and_( return and_(
table.chunker_version == CHUNKER_VERSION, table.chunker_version == CHUNKER_VERSION,
@@ -459,13 +449,13 @@ async def rows_off_the_live_model(table) -> int:
# Character budget approximating the model window. Tokens-per-char varies by # Character budget approximating the model window. Tokens-per-char varies by
# content — ~4 chars/token for prose, closer to 3 for code and tables — so 1400 # content — ~4 chars/token for prose, closer to 3 for code and tables — so 1400
# chars sits at roughly 350-470 tokens, leaving headroom for the title prefixed # chars sits at roughly 350-470 tokens, leaving headroom for the title prefixed
# to every chunk. Deliberately conservative: our own measurement (#2485) says # to every chunk. Deliberately conservative: measurement says
# shorter, single-topic documents embed SHARPER, so the cost of over-splitting # shorter, single-topic documents embed SHARPER, so the cost of over-splitting
# is a few extra cheap vectors while the cost of under-splitting is truncation — # is a few extra cheap vectors while the cost of under-splitting is truncation —
# the exact data loss this exists to end. # the exact data loss chunking exists to prevent.
_CHUNK_CHAR_BUDGET = 1400 _CHUNK_CHAR_BUDGET = 1400
# The least a chunk may say beyond its headings (#4784). Below this a chunk is # The least a chunk may say beyond its headings. Below this a chunk is
# a heading, a date or a one-line lead-in, and it embeds as text about nothing # a heading, a date or a one-line lead-in, and it embeds as text about nothing
# in particular — which is close to everything, so it outranks real records on # in particular — which is close to everything, so it outranks real records on
# any vague query. Such a piece is joined to its neighbour instead of being # any vague query. Such a piece is joined to its neighbour instead of being
@@ -531,14 +521,14 @@ def _split_paragraphs(section: str, budget: int) -> list[str]:
budget (a monster table or code block — split at line boundaries so no budget (a monster table or code block — split at line boundaries so no
content is dropped, which is the entire point of this module). content is dropped, which is the entire point of this module).
No piece is thin (#4784). A heading, or a lead-in line, that would be left No piece is thin. A heading, or a lead-in line, that would be left
on its own is carried into the paragraph after it; a thin tail joins the on its own is carried into the paragraph after it; a thin tail joins the
piece before it.""" piece before it."""
pieces: list[str] = [] pieces: list[str] = []
current = "" current = ""
# A hard cut never lands in the first quarter of the budget: the newline # A hard cut never lands in the first quarter of the budget: the newline
# it would find there is the one closing a heading or a lead-in, and # it would find there is the one closing a heading or a lead-in, and
# cutting at it is how a heading became a chunk of its own. # cutting at it would make a heading a chunk of its own.
lo = budget // 4 lo = budget // 4
for para in section.split("\n\n"): for para in section.split("\n\n"):
if current and _is_thin(current): if current and _is_thin(current):
@@ -589,15 +579,15 @@ def chunk_document(title: str | None, body: str | None) -> list[str]:
The contract every retrieval surface builds on: The contract every retrieval surface builds on:
- A record that fits the model window yields EXACTLY ONE chunk, identical - A record that fits the model window yields EXACTLY ONE chunk, identical
to the historical `title\\nbody` shape — snippets and reference notes, to the single-vector `title\\nbody` shape — snippets and reference notes,
the corpus's sharpest records, are byte-for-byte unaffected. the corpus's sharpest records, are byte-for-byte unaffected.
- A longer record is split at markdown heading boundaries (fence-aware), - A longer record is split at markdown heading boundaries (fence-aware),
small neighbouring sections merged, oversize sections split at paragraph small neighbouring sections merged, oversize sections split at paragraph
boundaries, so every chunk fits the window. NOTHING is dropped: every boundaries, so every chunk fits the window. NOTHING is dropped: every
line of the body lands in some chunk. line of the body lands in some chunk.
- Every chunk is prefixed with the record's title — each vector carries - Every chunk is prefixed with the record's title — each vector carries
its own topical anchor, the property that makes snippets discriminative its own topical anchor, the property that makes snippets
(#2485). Pieces sub-split from one section also repeat that section's discriminative. Pieces sub-split from one section also repeat that section's
heading line, so "which part of which topic" survives the split. heading line, so "which part of which topic" survives the split.
- An empty record yields [] (callers gate on falsiness to skip embedding). - An empty record yields [] (callers gate on falsiness to skip embedding).
@@ -615,7 +605,7 @@ def chunk_document(title: str | None, body: str | None) -> list[str]:
# Merge small adjacent sections upward so tiny sections don't each spend a # Merge small adjacent sections upward so tiny sections don't each spend a
# vector, then split anything still over budget at paragraph boundaries. # vector, then split anything still over budget at paragraph boundaries.
# A THIN section takes the next one in whatever its size (#4784): a heading # A THIN section takes the next one in whatever its size: a heading
# with nothing under it ("## Findings" above its "### …" parts) or a lone # with nothing under it ("## Findings" above its "### …" parts) or a lone
# lead-in line would otherwise stand as a chunk, and the split below keeps # lead-in line would otherwise stand as a chunk, and the split below keeps
# it attached to the first paragraph that follows. # it attached to the first paragraph that follows.
@@ -640,7 +630,7 @@ def chunk_document(title: str | None, body: str | None) -> list[str]:
for i, piece in enumerate(pieces): for i, piece in enumerate(pieces):
# Repeat the heading a continuation piece sits under so each vector # Repeat the heading a continuation piece sits under so each vector
# still knows what topic it is part of. The LAST heading so far, # still knows what topic it is part of. The LAST heading so far,
# not the section's first: a thin heading merged forward (#4784) # not the section's first: a thin heading merged forward
# means one section can hold several. # means one section can hold several.
if i > 0 and heading and not piece.startswith(heading): if i > 0 and heading and not piece.startswith(heading):
piece = f"{heading}\n{piece}" piece = f"{heading}\n{piece}"
@@ -650,7 +640,7 @@ def chunk_document(title: str | None, body: str | None) -> list[str]:
async def _claim_parent_row(session, id_column, row_id: int, label: str) -> bool: async def _claim_parent_row(session, id_column, row_id: int, label: str) -> bool:
"""Lock the record a vector belongs to BEFORE rewriting that vector (#3262). """Lock the record a vector belongs to BEFORE rewriting that vector.
An embedding write and a cascading delete of the same record take the same An embedding write and a cascading delete of the same record take the same
two row locks in OPPOSITE orders. The embedder deletes the old chunk rows two row locks in OPPOSITE orders. The embedder deletes the old chunk rows
@@ -696,7 +686,7 @@ async def _claim_parent_row(session, id_column, row_id: int, label: str) -> bool
async def _work_log_sections(note_id: int) -> list[tuple[object, str | None]]: async def _work_log_sections(note_id: int) -> list[tuple[object, str | None]]:
"""A task's work logs, oldest first, for its embedded document (#4251). """A task's work logs, oldest first, for its embedded document.
Reads the table directly rather than through `task_logs.logs_for_task`, Reads the table directly rather than through `task_logs.logs_for_task`,
because that function asks a PERMISSION question — may this user read this because that function asks a PERMISSION question — may this user read this
@@ -707,8 +697,8 @@ async def _work_log_sections(note_id: int) -> list[tuple[object, str | None]]:
That also settles what happens on a shared task: a collaborator's log is That also settles what happens on a shared task: a collaborator's log is
part of the task's document, so it becomes findable by everyone who can part of the task's document, so it becomes findable by everyone who can
read the task and by nobody else. The same answer `logs_for_task` gives a read the task and by nobody else. The same answer `logs_for_task` gives a
reader (#4241), which is the point — a log that can be read and not found reader, which is the point — a log that can be read but not found is half
is the half-surface that issue was about. a surface.
Asked for every note, not only tasks. `upsert_note_embedding` is handed a Asked for every note, not only tasks. `upsert_note_embedding` is handed a
note_id and no kind — and the three writers that call it would each have to note_id and no kind — and the three writers that call it would each have to
@@ -797,14 +787,13 @@ async def upsert_note_embedding(
# Kinds that belong to no single project, and are therefore reachable from a # Kinds that belong to no single project, and are therefore reachable from a
# project-scoped search of a DIFFERENT project when a caller asks for them # project-scoped search of a DIFFERENT project when a caller asks for them.
# (milestone 385 step 3).
# #
# A lesson is the whole reason this exists. "A better way to think about this # A lesson is the whole reason this exists. "A better way to think about this
# problem" is not true only where it was learned, and a lesson confined to its # problem" is not true only where it was learned, and a lesson confined to its
# origin project would be unreachable exactly where it is most useful — on the # origin project would be unreachable exactly where it is most useful — on the
# next project, which is the case the kind was created for (#3727). # next project, which is the case the kind exists for.
# `semantic_search_rules` has always had this property: it scopes by OWNERSHIP # `semantic_search_rules` has the same property: it scopes by OWNERSHIP
# rather than by what binds a given project, because "is there a rule about # rather than by what binds a given project, because "is there a rule about
# this" is a question asked across a whole rulebook. A lesson asks the same # this" is a question asked across a whole rulebook. A lesson asks the same
# kind of question. # kind of question.
@@ -816,11 +805,11 @@ GLOBAL_NOTE_TYPES: tuple[str, ...] = ("lesson",)
# Both searches rank WITHOUT the threshold and apply it in Python, so the best # Both searches rank WITHOUT the threshold and apply it in Python, so the best
# rejected score stays observable (#3670). The qualifying set is provably # rejected score stays observable. The qualifying set is the same as a
# unchanged: rows arrive ordered by distance ascending, so every above-bar row # filter inside the query would give: rows arrive ordered by distance
# sorts ahead of every below-bar one, and an over-fetch that used to return N # ascending, so every above-bar row sorts ahead of every below-bar one, and an
# above-bar rows returns the same N plus some losers. What changes is only that # over-fetch returns the same N above-bar rows plus some losers. The losers
# the losers are now visible instead of discarded inside the query. # are visible instead of discarded inside the query.
# #
# That visibility is the entire point. A bar can only be judged from the calls # That visibility is the entire point. A bar can only be judged from the calls
# it TURNED AWAY — a 0.72 bar rejecting a stream of 0.71s is set too high by a # it TURNED AWAY — a 0.72 bar rejecting a stream of 0.71s is set too high by a
@@ -836,15 +825,15 @@ def record_best_chunk(report: dict | None, chunks: dict[int, dict]) -> None:
"""Publish the winning chunk per record into `report["best_chunk"]`. """Publish the winning chunk per record into `report["best_chunk"]`.
Every semantic search here collapses several chunk rows to the best one per Every semantic search here collapses several chunk rows to the best one per
record, which means each of them KNOWS which passage earned the hit — and record, which means each of them KNOWS which passage earned the hit.
each of them used to drop it, leaving every caller to preview the head of Dropping it would leave every caller to preview the head of the document
the document instead. The head is a different span, one the search has instead — a different span, one the search has already scored lower, with
already scored lower, and nothing in the result said so (#4243). nothing in the result saying so.
It rides in `report` rather than in the return value because all three It rides in `report` rather than in the return value because all three
searches return `list[tuple[float, Record]]` and roughly thirty sites searches return `list[tuple[float, Record]]` and roughly thirty sites
unpack that pair; widening it would be an interface change to every one of unpack that pair; widening it would be an interface change to every one of
them with nothing to catch a miss (lesson #4207). `report` is already the them with nothing to catch a miss. `report` is already the
side-channel these functions use for `searched` and `best_available_score`, side-channel these functions use for `searched` and `best_available_score`,
so this adds a key to a channel callers already open. so this adds a key to a channel callers already open.
@@ -856,7 +845,7 @@ def record_best_chunk(report: dict | None, chunks: dict[int, dict]) -> None:
report["best_chunk"] = chunks report["best_chunk"] = chunks
# --- scope first, then rank (#4958, #4961) ----------------------------------- # --- scope first, then rank -------------------------------------------------
# #
# Every semantic search here asks the same question of a shared table: the # Every semantic search here asks the same question of a shared table: the
# nearest chunks AMONG THE ONES THIS CALLER MAY SEE. Ordered straight off a # nearest chunks AMONG THE ONES THIS CALLER MAY SEE. Ordered straight off a
@@ -867,8 +856,8 @@ def record_best_chunk(report: dict | None, chunks: dict[int, dict]) -> None:
# users' records are what fill those 40. The searches fail open, so nothing # users' records are what fill those 40. The searches fail open, so nothing
# reports it: the result is just shorter. # reports it: the result is just shorter.
# #
# The fix is one shape, shared by every search so it cannot be fixed in one # The remedy is one shape, shared by every search so it cannot be fixed in one
# and missed in the next (which is how #4958 left three behind). The scope is # and missed in the next. The scope is
# a MATERIALIZED CTE with the distance computed inside it; the index cannot # a MATERIALIZED CTE with the distance computed inside it; the index cannot
# order a materialized CTE, so the ranking over it is exact. # order a materialized CTE, so the ranking over it is exact.
# #
@@ -876,7 +865,7 @@ def record_best_chunk(report: dict | None, chunks: dict[int, dict]) -> None:
# Systems are hundreds of chunks. Notes are the big corpus, but the hook arms # Systems are hundreds of chunks. Notes are the big corpus, but the hook arms
# that run on every prompt are project-scoped, so the pass is over one # that run on every prompt are project-scoped, so the pass is over one
# project's chunks rather than the whole table; `retrieval_logs.duration_ms` # project's chunks rather than the whole table; `retrieval_logs.duration_ms`
# on those arms is where that claim is checked after the deploy. If it ever # on those arms is where that claim is checked. If it ever
# stops holding, pgvector 0.8's `hnsw.iterative_scan` keeps the index and is # stops holding, pgvector 0.8's `hnsw.iterative_scan` keeps the index and is
# the next step — gated on the extension version, because an unknown `hnsw.*` # the next step — gated on the extension version, because an unknown `hnsw.*`
# setting errors and these searches would read that error as "nothing found". # setting errors and these searches would read that error as "nothing found".
@@ -938,14 +927,14 @@ async def semantic_search_notes(
the function sets `report["best_available_score"]` to the highest score the function sets `report["best_available_score"]` to the highest score
anything reached, or None when the corpus offered nothing at all. It is anything reached, or None when the corpus offered nothing at all. It is
the only figure that survives a call returning nothing, and therefore the the only figure that survives a call returning nothing, and therefore the
only one a bar can be judged from (#3670). only one a bar can be judged from.
It also sets `report["searched"]`: False before anything can return, True It also sets `report["searched"]`: False before anything can return, True
only where a real result set exists. So an empty query, an unavailable only where a real result set exists. So an empty query, an unavailable
embedder and a failed database query all leave it FALSE, and a caller can embedder and a failed database query all leave it FALSE, and a caller can
tell a search that found nothing from one that never ran. A caller logging tell a search that found nothing from one that never ran. A caller logging
telemetry must check it — recording a failed search as a zero-result call telemetry must check it — recording a failed search as a zero-result call
reports a decline the ranker never made (#3765). ABSENT means no search reports a decline the ranker never made. ABSENT means no search
touched the dict at all, which is a stand-in in a test, not a real call. touched the dict at all, which is a stand-in in a test, not a real call.
`note_type` narrows to a record kind, or several (e.g. "snippet", or `note_type` narrows to a record kind, or several (e.g. "snippet", or
@@ -981,15 +970,15 @@ async def semantic_search_notes(
Ranking and the top-k cut happen in Postgres via pgvector's cosine-distance Ranking and the top-k cut happen in Postgres via pgvector's cosine-distance
operator (`<=>`, exposed as ``Vector.cosine_distance``), EXACTLY and over operator (`<=>`, exposed as ``Vector.cosine_distance``), EXACTLY and over
the in-scope chunks only (#4961, see `_rank_scoped`). It used to be an the in-scope chunks only (see `_rank_scoped`). An indexed
indexed ``ORDER BY ... LIMIT k`` straight off the HNSW index from ``ORDER BY ... LIMIT k`` straight off the HNSW index would rank before it
migration 0067, which ranks before it filters and so could drop an filters, and so could drop an in-scope record that sat behind ~40 nearer
in-scope record that sat behind ~40 nearer ones the caller cannot see. ones the caller cannot see.
Cosine distance is ``1 - cosine_similarity``, so a similarity floor of Cosine distance is ``1 - cosine_similarity``, so a similarity floor of
*threshold* is a distance ceiling of ``1 - threshold`` and similarity is *threshold* is a distance ceiling of ``1 - threshold`` and similarity is
recovered as ``1 - distance``. recovered as ``1 - distance``.
`demote_superseded` applies the supersession penalty (#278): a record a `demote_superseded` applies the supersession penalty: a record a
later note claims to have overtaken ranks below its equals. Callers asking later note claims to have overtaken ranks below its equals. Callers asking
"what is the current answer" want it; the near-duplicate gate does NOT, and "what is the current answer" want it; the near-duplicate gate does NOT, and
passes False — a superseded record is still a duplicate of what you are passes False — a superseded record is still a duplicate of what you are
@@ -999,7 +988,7 @@ async def semantic_search_notes(
Returns an empty list if the embedder is unavailable or on any error. Returns an empty list if the embedder is unavailable or on any error.
""" """
# Stamped FALSE before anything can return, flipped True only where a real # Stamped FALSE before anything can return, flipped True only where a real
# result set exists (#3765). Every early return below leaves it false, so a # result set exists. Every early return below leaves it false, so a
# caller can tell a search that found nothing from one that never ran. It # caller can tell a search that found nothing from one that never ran. It
# has to be the first thing done to `report`: a return added above this # has to be the first thing done to `report`: a return added above this
# line would leave the key ABSENT, which reads as "no caller asked". # line would leave the key ABSENT, which reads as "no caller asked".
@@ -1025,13 +1014,13 @@ async def semantic_search_notes(
# and leave shared records unreachable by meaning. # and leave shared records unreachable by meaning.
# #
# Every filter below is the SCOPE, built into the chunks the # Every filter below is the SCOPE, built into the chunks the
# ranking draws from rather than applied to what it returns # ranking draws from rather than applied to what it returns —
# (#4961) — see `_rank_scoped`. # see `_rank_scoped`.
stmt = ( stmt = (
# chunk_index/chunk_text ride along so the collapse below can # chunk_index/chunk_text ride along so the collapse below can
# say WHICH passage matched. Without them the caller is left # say WHICH passage matched. Without them the caller is left
# previewing the head of the body — a span this query has # previewing the head of the body — a span this query has
# already determined is not why the record ranked (#4243). # already determined is not why the record ranked.
_scoped_chunks(NoteEmbedding, NoteEmbedding.note_id, distance) _scoped_chunks(NoteEmbedding, NoteEmbedding.note_id, distance)
.join(Note, NoteEmbedding.note_id == Note.id) .join(Note, NoteEmbedding.note_id == Note.id)
.where( .where(
@@ -1047,7 +1036,7 @@ async def semantic_search_notes(
in_project = or_( in_project = or_(
in_project, Note.note_type.in_(GLOBAL_NOTE_TYPES) in_project, Note.note_type.in_(GLOBAL_NOTE_TYPES)
) )
# Family canon (milestone 463 step 6) crosses the project # Family canon crosses the project
# line the same way a lesson does, but only to a project # line the same way a lesson does, but only to a project
# on one of the idea's platforms: the canon ideas reaching # on one of the idea's platforms: the canon ideas reaching
# it, and their references in its languages. Membership # it, and their references in its languages. Membership
@@ -1125,7 +1114,7 @@ async def semantic_search_notes(
logger.warning("Failed to query note embeddings", exc_info=True) logger.warning("Failed to query note embeddings", exc_info=True)
return [] return []
# Collapse chunk rows to BEST-CHUNK-PER-NOTE (#280): rows arrive ordered by # Collapse chunk rows to BEST-CHUNK-PER-NOTE: rows arrive ordered by
# distance, so the first appearance of a note is its best chunk and later # distance, so the first appearance of a note is its best chunk and later
# appearances are the same note matched less well. A note's relevance IS # appearances are the same note matched less well. A note's relevance IS
# its best section's relevance — a query about one topic of a long record # its best section's relevance — a query about one topic of a long record
@@ -1137,9 +1126,9 @@ async def semantic_search_notes(
# one place that knows which passage earned the hit. Kept beside the score # one place that knows which passage earned the hit. Kept beside the score
# rather than returned with it: the return type is list[tuple[float, Note]] # rather than returned with it: the return type is list[tuple[float, Note]]
# and ten callers unpack it at ~18 sites, so widening the tuple would be an # and ten callers unpack it at ~18 sites, so widening the tuple would be an
# interface change to every one of them with nothing to catch the misses # interface change to every one of them with nothing to catch the misses.
# (lesson #4207). `report` is the side-channel this function already uses # `report` is the side-channel this function already uses for
# for best_available_score. # best_available_score.
best_chunk: dict[int, dict] = {} best_chunk: dict[int, dict] = {}
for note, dist, chunk_index, chunk_text in rows: for note, dist, chunk_index, chunk_text in rows:
if int(note.id) in seen: if int(note.id) in seen:
@@ -1153,16 +1142,16 @@ async def semantic_search_notes(
# The best score anything reached, bar or no bar. Recorded BEFORE the # The best score anything reached, bar or no bar. Recorded BEFORE the
# filter because a call that returns nothing is exactly when it matters. # filter because a call that returns nothing is exactly when it matters.
if report is not None: if report is not None:
# `searched` is what stops a null score meaning four things (#3765). # `searched` is what stops a null score meaning four things. Every
# Every early return above — empty query, embedder down, and the broad # early return above — empty query, embedder down, and the broad
# `except` around the query itself — leaves this key ABSENT, so a # `except` around the query itself — leaves it False, so a caller can
# caller can tell "I looked and there was nothing" from "I never # tell "I looked and there was nothing" from "I never looked" and from
# looked" and from "the query failed". Set here, at the one point past # "the query failed". Set here, at the one point past which a real
# which a real result set exists. # result set exists.
report["searched"] = True report["searched"] = True
# ONE unpack, so the score and the id cannot describe different records # ONE unpack, so the score and the id cannot describe different
# (#3807). Splitting these into two expressions is how a later edit # records. Split into two expressions, a later edit can pair a score
# pairs a score with its neighbour's id. # with its neighbour's id.
best = scored[0] if scored else None best = scored[0] if scored else None
report["best_available_score"] = best[0] if best else None report["best_available_score"] = best[0] if best else None
report["best_available_id"] = int(best[1].id) if best else None report["best_available_id"] = int(best[1].id) if best else None
@@ -1181,18 +1170,18 @@ async def semantic_search_notes(
return final return final
# --- backfill correctness: scan ids, read text at embed time (#4262) -------- # --- backfill correctness: scan ids, read text at embed time ---------------
# #
# A backfill used to SELECT the text alongside the id and then iterate that # A backfill that SELECTs the text alongside the id and then iterates that
# snapshot, sleeping between records. On a corpus of any size the run takes # snapshot, sleeping between records, runs for minutes on a corpus of any
# minutes, and anything edited inside that window was re-embedded by its own # size. Anything edited inside that window is re-embedded by its own write
# write path and then OVERWRITTEN with the pre-edit text the scan had captured # path and then OVERWRITTEN with the pre-edit text the scan captured —
# — stamped at the current version, so the next boot considered it current and # stamped at the current version, so the next boot considers it current and
# never repaired it. A record left findable by what it used to say, silently, # never repairs it. The record stays findable by what it used to say,
# until someone happened to edit it again. # silently, until someone edits it again.
# #
# The two functions below are the fix, and they are separate on purpose: one # The two functions below are separate on purpose: one stops that happening,
# stops it happening, the other repairs what already happened. # the other repairs rows it already damaged.
async def _current_row(columns, id_column, row_id: int): async def _current_row(columns, id_column, row_id: int):
@@ -1234,7 +1223,7 @@ async def _vectors_older_than_their_record(
only signal that separates them, and it costs one grouped read at startup. only signal that separates them, and it costs one grouped read at startup.
A guard that refuses to write a bad value does not undo the bad values A guard that refuses to write a bad value does not undo the bad values
already stored (#4202) — the rows are the thing that has to change, and already stored — the rows are the thing that has to change, and
this is what changes them. this is what changes them.
""" """
newest = ( newest = (
@@ -1257,16 +1246,15 @@ async def _tasks_logged_since_embedding(session) -> set[int]:
"""Task ids whose newest work log is newer than their newest vector. """Task ids whose newest work log is newer than their newest vector.
The note corpus needs this on top of the plain timestamp comparison, and The note corpus needs this on top of the plain timestamp comparison, and
the reason is a consequence of #4251 that is easy to miss: a task's the reason is easy to miss: a task's
embedded document carries its work logs, but a log lives in its own table, embedded document carries its work logs, but a log lives in its own table,
so writing one does NOT move `notes.updated_at`. A task re-embedded from so writing one does NOT move `notes.updated_at`. A task re-embedded from
stale text during a backfill therefore looks current by every other stale text during a backfill therefore looks current by every other
signal — the version is right and the note's own timestamp is older than signal — the version is right and the note's own timestamp is older than
the vectors — while the document those vectors encode is out of date. the vectors — while the document those vectors encode is out of date.
These are the exact records the backfill race was most likely to hit, These are the records a backfill race is most likely to hit, because a
because a session logging work is what "forward work during a long session logging work is what "forward work during a long backfill" MEANS.
backfill" MEANS.
""" """
newest_vector = ( newest_vector = (
select( select(
@@ -1299,13 +1287,13 @@ async def backfill_note_embeddings() -> None:
1. no vectors at all; 1. no vectors at all;
2. vectors from an older chunker — version-awareness is what makes a 2. vectors from an older chunker — version-awareness is what makes a
document-shape change deployable. Migration 0077 cleared the table document-shape change deployable: a CHUNKER_VERSION bump re-embeds
once; every later CHUNKER_VERSION bump re-embeds here instead; here rather than wiping the table;
3. vectors OLDER THAN THE TEXT THEY CLAIM TO ENCODE. A record embedded by 3. vectors OLDER THAN THE TEXT THEY CLAIM TO ENCODE. A record embedded by
the current chunker from text that has since changed is current by the current chunker from text that has since changed is current by
every other signal and answers to what it used to say (#4262). every other signal and answers to what it used to say.
Runs as a background task at startup, after the serving flag (#4181), with Runs as a background task at startup, after the serving flag, with
a small sleep between notes so a large backfill doesn't peg CPU. It is a small sleep between notes so a large backfill doesn't peg CPU. It is
resumable by construction: progress IS the version stamp on each record's resumable by construction: progress IS the version stamp on each record's
rows, so a restart re-queries and skips what finished — there is no rows, so a restart re-queries and skips what finished — there is no
@@ -1360,7 +1348,7 @@ async def backfill_note_embeddings() -> None:
if row is None: if row is None:
continue # deleted between the scan and here continue # deleted between the scan and here
user_id, title, body, note_type, data = row user_id, title, body, note_type, data = row
# The EMBEDDED title, as the write path builds it (milestone 427). # The EMBEDDED title, as the write path builds it.
title = document_title(title, note_type, data, body) title = document_title(title, note_type, data, body)
if not chunk_document(title, body): if not chunk_document(title, body):
continue continue
@@ -1371,7 +1359,7 @@ async def backfill_note_embeddings() -> None:
logger.info("Embedding backfill complete: %d/%d notes embedded", success, len(notes_to_embed)) logger.info("Embedding backfill complete: %d/%d notes embedded", success, len(notes_to_embed))
# ── Rules (milestone 307, note 3026) ──────────────────────────────────── # ── Task and rule documents ──────────────────────────────────────────────
# The heading a work log gets inside its task's embedded document. A CONSTANT # The heading a work log gets inside its task's embedded document. A CONSTANT
# because it is load-bearing twice over: `_split_sections` splits on it, so it # because it is load-bearing twice over: `_split_sections` splits on it, so it
@@ -1392,23 +1380,23 @@ def task_document(
A synthesised embed-time shape, like `rule_document` and unlike a lesson: A synthesised embed-time shape, like `rule_document` and unlike a lesson:
the stored record is the task row, and the logs live in their own table, so the stored record is the task row, and the logs live in their own table, so
the document that should be searchable exists nowhere until it is built the document that should be searchable exists nowhere until it is built
here (#4251). here.
WHY THE LOGS BELONG IN THE TASK'S DOCUMENT rather than in rows of their WHY THE LOGS BELONG IN THE TASK'S DOCUMENT rather than in rows of their
own. "Has anyone tried this before?" is answered by a log and asked of a own. "Has anyone tried this before?" is answered by a log and asked of a
task — a hit on a bare log would have to be resolved back to its task to be task — a hit on a bare log would have to be resolved back to its task to be
worth anything, so the useful result is the task either way. The objection worth anything, so the useful result is the task either way. The objection
to folding them in is that a long log drowns a short title, and that was to folding them in is that a long log drowns a short title: one vector
true before #280: one vector per record meant a 2,000-word log averaged the per record would let a 2,000-word log average the task's own subject
task's own subject away, and everything past ~400 words was truncated away, and truncate everything past ~400 words unread. Chunking removes
unread. Chunking removed both. Each log becomes its own section, each both. Each log becomes its own section, each
section its own title-anchored vector, each scored separately — so a task section its own title-anchored vector, each scored separately — so a task
is as findable as its best-matching log, and the task's own prose keeps the is as findable as its best-matching log, and the task's own prose keeps the
chunk it always had. chunk it always had.
That the result is LEGIBLE is the other half, and it is this session's That the result is LEGIBLE is the other half: a search hands back the
other build: a search now hands back the chunk that won (#4243), so a hit chunk that won, so a hit earned by a log shows that log's passage under
earned by a log shows that log's passage under the task's title. Without it the task's title. Without it
the reader would get `body[:240]` of the task — the opening of a record the reader would get `body[:240]` of the task — the opening of a record
whose relevance lives three hundred lines further down. whose relevance lives three hundred lines further down.
@@ -1443,17 +1431,17 @@ def rule_document(
) -> tuple[str | None, str | None]: ) -> tuple[str | None, str | None]:
"""The (title, body) a rule is EMBEDDED as — trigger first, `why` never. """The (title, body) a rule is EMBEDDED as — trigger first, `why` never.
Both halves of this are measured, not guessed (note 2485). That pass found Both halves of this are measured, not guessed. Snippets are the sharp
the snippet was the only sharp record in the corpus — a 0.153 top-to-second records in the corpus — a ~0.15 top-to-second gap against 0.010–0.023
gap against 0.010–0.023 for everything else — and that the cause was its for everything else — and the cause is their
SHAPE: `{name} — {when_to_use}` as the title and `**When to use:** …` SHAPE: `{name} — {when_to_use}` as the title and `**When to use:** …`
repeated in the body, so purpose appears twice in a short document and repeated in the body, so purpose appears twice in a short document and
dominates the vector. This mirrors that exactly. dominates the vector. This mirrors that exactly.
And it excludes `why` on the same evidence. `why` is dated incident And it excludes `why` on the same evidence. `why` is often incident
narrative — rule 46's runs to 4,300 characters of it — and long, narrative running to thousands of characters, and long, multi-topic prose
multi-topic prose is precisely what made sixteen dev-logs mutually is precisely what makes dev-logs mutually indistinguishable: the average
indistinguishable: the average lands on the centroid of "development", lands on the centroid of "development",
which every one of them shares. Adding `why` would not give the vector more which every one of them shares. Adding `why` would not give the vector more
to work with; it would give every rule the same thing to work with. to work with; it would give every rule the same thing to work with.
@@ -1545,17 +1533,17 @@ async def semantic_search_rules(
the function sets `report["best_available_score"]` to the highest score the function sets `report["best_available_score"]` to the highest score
anything reached, or None when the corpus offered nothing at all. It is anything reached, or None when the corpus offered nothing at all. It is
the only figure that survives a call returning nothing, and therefore the the only figure that survives a call returning nothing, and therefore the
only one a bar can be judged from (#3670). only one a bar can be judged from.
It also sets `report["searched"]`: False before anything can return, True It also sets `report["searched"]`: False before anything can return, True
only where a real result set exists. So an empty query, an unavailable only where a real result set exists. So an empty query, an unavailable
embedder and a failed database query all leave it FALSE, and a caller can embedder and a failed database query all leave it FALSE, and a caller can
tell a search that found nothing from one that never ran. A caller logging tell a search that found nothing from one that never ran. A caller logging
telemetry must check it — recording a failed search as a zero-result call telemetry must check it — recording a failed search as a zero-result call
reports a decline the ranker never made (#3765). ABSENT means no search reports a decline the ranker never made. ABSENT means no search
touched the dict at all, which is a stand-in in a test, not a real call. touched the dict at all, which is a stand-in in a test, not a real call.
SCOPED, and the scope is a rule's home (milestone 414). A rule lives in a SCOPED, and the scope is a rule's home. A rule lives in a
rulebook topic — GLOBAL, it applies wherever its owner works — or on one rulebook topic — GLOBAL, it applies wherever its owner works — or on one
project, where it applies to that project and nowhere else: project, where it applies to that project and nowhere else:
@@ -1569,20 +1557,15 @@ async def semantic_search_rules(
explicit whole-rulebook question — `search(content_type="rule")` with no explicit whole-rulebook question — `search(content_type="rule")` with no
project — where "is there a rule about this" is asked across everything. project — where "is there a rule about this" is asked across everything.
This used to be scoped by OWNERSHIP alone, on the argument that "is there Scoping by OWNERSHIP alone is right for the explicit ask and wrong for
a rule about this" wants the whole rulebook. That is still right for the the hooks, which inject unasked: every project's rules would surface in
explicit ask. It was wrong for the hooks, which inject unasked: every every other project's sessions — one repo's template conventions arriving
project's rules surfaced in every other project's sessions — one repo's while editing an unrelated one — and a project rule would mean nothing a
template conventions arriving while editing an unrelated one — and a session could feel.
project rule meant nothing a session could feel.
THERE IS NO TIER TO NARROW BY ANY MORE (milestone 394). This carried a Every rule in scope is eligible for every arm, and relevance is the
`tier` parameter, and the arms deliberately passed nothing: filtering on it threshold's job alone — see `_RULEHINT_BAND` in
made a whole class of rules permanently ineligible for the one mechanism services/retrieval_pipeline.py for what those scores are read against.
that surfaces a rule AT the moment it applies. The tier is now gone
entirely, so every rule is eligible for every arm and relevance is the
threshold's job alone — see the block above RULEHINT_LIMIT in
services/plugin_context.py for what those scores are read against.
`kind` narrows to `rule` or `preference`, and NONE is likewise the ordinary `kind` narrows to `rule` or `preference`, and NONE is likewise the ordinary
case: a caller asking "what governs this" wants both, because the reader case: a caller asking "what governs this" wants both, because the reader
@@ -1602,7 +1585,7 @@ async def semantic_search_rules(
from scribe.models.rulebook import Rule from scribe.models.rulebook import Rule
from scribe.services.rule_scope import joined_to_homes, rule_home from scribe.services.rule_scope import joined_to_homes, rule_home
# See the sibling search: stamped before anything can return (#3765). # See the sibling search: stamped before anything can return.
if report is not None: if report is not None:
report["searched"] = False report["searched"] = False
if not query or not query.strip(): if not query or not query.strip():
@@ -1621,11 +1604,11 @@ async def semantic_search_rules(
# function fails open. # function fails open.
home = await rule_home(user_id, project_id, everywhere=everywhere) home = await rule_home(user_id, project_id, everywhere=everywhere)
# SCOPE FIRST, THEN RANK — exactly (#4958; the shape is # SCOPE FIRST, THEN RANK — exactly (the shape is `_rank_scoped`'s).
# `_rank_scoped`'s). The home filter is built into the chunks the # The home filter is built into the chunks the ranking draws from:
# ranking draws from: applied to what an HNSW walk returned, it ran # applied to what an HNSW walk returned, it would run after ~40
# after ~40 nearest chunks from every owner and project had been # nearest chunks from every owner and project had been taken, and an
# taken, and an in-scope rule ranked past them was silently gone. # in-scope rule ranked past them would be silently gone.
scoped = ( scoped = (
joined_to_homes( joined_to_homes(
_scoped_chunks(RuleEmbedding, RuleEmbedding.rule_id, distance) _scoped_chunks(RuleEmbedding, RuleEmbedding.rule_id, distance)
@@ -1654,7 +1637,7 @@ async def semantic_search_rules(
best: dict[int, tuple[float, object]] = {} best: dict[int, tuple[float, object]] = {}
# Which chunk won, kept beside the score it won with — a rule's `why` and # Which chunk won, kept beside the score it won with — a rule's `why` and
# `how_to_apply` are long, and a caller shown only the head cannot see the # `how_to_apply` are long, and a caller shown only the head cannot see the
# clause that actually matched (#4243). # clause that actually matched.
won: dict[int, dict] = {} won: dict[int, dict] = {}
for rule, dist, chunk_index, chunk_text in rows: for rule, dist, chunk_index, chunk_text in rows:
score = 1.0 - float(dist) score = 1.0 - float(dist)
@@ -1669,9 +1652,9 @@ async def semantic_search_rules(
int(r.id): won[int(r.id)] for _s, r in kept if int(r.id) in won int(r.id): won[int(r.id)] for _s, r in kept if int(r.id) in won
}) })
if report is not None: if report is not None:
# See the sibling search: absent means the search never ran (#3765). # See the sibling search: False means the search never ran.
report["searched"] = True report["searched"] = True
# One unpack — see the sibling search (#3807). # One unpack — see the sibling search.
best = ranked[0] if ranked else None best = ranked[0] if ranked else None
report["best_available_score"] = best[0] if best else None report["best_available_score"] = best[0] if best else None
report["best_available_id"] = int(best[1].id) if best else None report["best_available_id"] = int(best[1].id) if best else None
@@ -1688,7 +1671,7 @@ async def backfill_rule_embeddings() -> None:
current = select(RuleEmbedding.rule_id).where( current = select(RuleEmbedding.rule_id).where(
is_current_stamp(RuleEmbedding) is_current_stamp(RuleEmbedding)
) )
# IDS ONLY — the text is re-read per rule below (#4262). # IDS ONLY — the text is re-read per rule below.
by_version = { by_version = {
row[0] for row in (await session.execute( row[0] for row in (await session.execute(
select(Rule.id) select(Rule.id)
@@ -1720,7 +1703,7 @@ async def backfill_rule_embeddings() -> None:
await upsert_rule_embedding(rule_id, *row) await upsert_rule_embedding(rule_id, *row)
# ── Milestones (milestone 415) ────────────────────────────────────────── # ── Milestones ───────────────────────────────────────────────────────────
def milestone_document( def milestone_document(
title: str | None, description: str | None, body: str | None, title: str | None, description: str | None, body: str | None,
@@ -1728,7 +1711,7 @@ def milestone_document(
"""The (title, body) a milestone is EMBEDDED as. """The (title, body) a milestone is EMBEDDED as.
Title and one-line description lead, the way a snippet's name and purpose Title and one-line description lead, the way a snippet's name and purpose
lead its document (note 2485): the question this search answers is "does lead its document: the question this search answers is "does
a plan for this already exist?", and a plan is recognised by what it is a plan for this already exist?", and a plan is recognised by what it is
FOR. The plan body follows, chunked, so a milestone whose description is FOR. The plan body follows, chunked, so a milestone whose description is
empty — most roadmap milestones written by hand — is still findable by the empty — most roadmap milestones written by hand — is still findable by the
@@ -1749,7 +1732,7 @@ async def upsert_milestone_embedding(
The rule twin's contract: one document definition shared by the write path The rule twin's contract: one document definition shared by the write path
and the backfill, and an atomic per-milestone replacement guarded by the and the backfill, and an atomic per-milestone replacement guarded by the
parent-row claim (#3262), so a milestone deleted mid-refresh wins. parent-row claim, so a milestone deleted mid-refresh wins.
""" """
from scribe.models.embedding import MilestoneEmbedding from scribe.models.embedding import MilestoneEmbedding
from scribe.models.milestone import Milestone from scribe.models.milestone import Milestone
@@ -1808,7 +1791,7 @@ async def semantic_search_milestones(
before start_planning, and the one the planning gate asks for it. before start_planning, and the one the planning gate asks for it.
SCOPE. With `project_id`, that project's milestones, provided the caller SCOPE. With `project_id`, that project's milestones, provided the caller
can read the project (access.can_read_project, rule 78) — a collaborator on can read the project (access.can_read_project) — a collaborator on
a shared project sees its plans. Without one, the milestones the caller a shared project sees its plans. Without one, the milestones the caller
owns across their projects. `status` narrows to "active" or "done". owns across their projects. `status` narrows to "active" or "done".
@@ -1835,7 +1818,7 @@ async def semantic_search_milestones(
scope = Milestone.project_id == project_id scope = Milestone.project_id == project_id
else: else:
scope = Milestone.user_id == user_id scope = Milestone.user_id == user_id
# Scope first, then rank (#4961): see `_rank_scoped`. # Scope first, then rank: see `_rank_scoped`.
scoped = ( scoped = (
_scoped_chunks(MilestoneEmbedding, MilestoneEmbedding.milestone_id, distance) _scoped_chunks(MilestoneEmbedding, MilestoneEmbedding.milestone_id, distance)
.join(Milestone, MilestoneEmbedding.milestone_id == Milestone.id) .join(Milestone, MilestoneEmbedding.milestone_id == Milestone.id)
@@ -1874,7 +1857,7 @@ async def semantic_search_milestones(
return kept return kept
# --- Systems: the charter as an ANSWER, not a filter (#4251) ----------------- # --- Systems: the charter as an ANSWER, not a filter ------------------------
def system_document( def system_document(
@@ -1909,7 +1892,7 @@ async def upsert_system_embedding(
The third sibling's contract exactly: one document definition shared by the The third sibling's contract exactly: one document definition shared by the
write path and the backfill, and an atomic per-System replacement guarded write path and the backfill, and an atomic per-System replacement guarded
by the parent-row claim (#3262), so a System deleted mid-refresh wins. by the parent-row claim, so a System deleted mid-refresh wins.
""" """
from scribe.models.embedding import SystemEmbedding from scribe.models.embedding import SystemEmbedding
from scribe.models.system import System from scribe.models.system import System
@@ -1964,26 +1947,26 @@ async def semantic_search_systems(
"""Return up to *limit* (score, system) pairs most like *query*. """Return up to *limit* (score, system) pairs most like *query*.
Answers "where does this belong?" — the question asked before filing a Answers "where does this belong?" — the question asked before filing a
record or opening a file, and the one that had no tool: `list_systems` record or opening a file. `list_systems` enumerates and `system_id`
enumerates and `system_id` filters, so a charter could narrow a search and filters, so without this a charter could narrow a search and never be the
could never be the answer to one. answer to one.
ITS OWN SEARCH rather than a note kind, for what note 3163 says about ITS OWN SEARCH rather than a note kind, for the reason milestones have
milestones: a charter competing with the whole note corpus for one top-k their own: a charter competing with the whole note corpus for one top-k
would be outranked by the records filed under it, and the right answer would be outranked by the records filed under it, and the right answer
would be crowded out by its own contents. The questions are different too — would be crowded out by its own contents. The questions are different too —
"where does this belong?" is not "what prior art is there?" — and a caller "where does this belong?" is not "what prior art is there?" — and a caller
asking one should not have to read past answers to the other. asking one should not have to read past answers to the other.
SCOPE. With `project_id`, that project's Systems, provided the caller can SCOPE. With `project_id`, that project's Systems, provided the caller can
read the project (access.can_read_project, rule 78) — a collaborator on a read the project (access.can_read_project) — a collaborator on a
shared project sees its areas, which is the point of a charter. Without shared project sees its areas, which is the point of a charter. Without
one, the Systems the caller owns across their projects. Archived Systems one, the Systems the caller owns across their projects. Archived Systems
are excluded: an archived area is one the operator has said is no longer are excluded: an archived area is one the operator has said is no longer
where things go, and that is exactly the question being asked. where things go, and that is exactly the question being asked.
Collapses to best-chunk-per-System and publishes the winning chunk, like Collapses to best-chunk-per-System and publishes the winning chunk, like
the sibling searches — from the start rather than retrofitted (#4243). the sibling searches.
Returns an empty list if the embedder is unavailable, the project is not Returns an empty list if the embedder is unavailable, the project is not
readable, or on any error: a recall aid must never break the call it readable, or on any error: a recall aid must never break the call it
serves. serves.
@@ -2007,7 +1990,7 @@ async def semantic_search_systems(
scope = System.project_id == project_id scope = System.project_id == project_id
else: else:
scope = System.user_id == user_id scope = System.user_id == user_id
# Scope first, then rank (#4961): see `_rank_scoped`. # Scope first, then rank: see `_rank_scoped`.
scoped = ( scoped = (
_scoped_chunks(SystemEmbedding, SystemEmbedding.system_id, distance) _scoped_chunks(SystemEmbedding, SystemEmbedding.system_id, distance)
.join(System, SystemEmbedding.system_id == System.id) .join(System, SystemEmbedding.system_id == System.id)
@@ -2030,7 +2013,7 @@ async def semantic_search_systems(
# A charter runs to several hundred words and a result shows its NAME — so # A charter runs to several hundred words and a result shows its NAME — so
# a match on the paragraph that actually decides where a record belongs # a match on the paragraph that actually decides where a record belongs
# would be previewed by two words that cannot. The winning chunk is what # would be previewed by two words that cannot. The winning chunk is what
# the caller should see (#4243). # the caller should see.
won: dict[int, dict] = {} won: dict[int, dict] = {}
for system, dist, chunk_index, chunk_text in rows: for system, dist, chunk_index, chunk_text in rows:
score = 1.0 - float(dist) score = 1.0 - float(dist)
@@ -2051,8 +2034,8 @@ async def backfill_system_embeddings() -> None:
"""Embed Systems that have no current vectors. Runs at startup beside the """Embed Systems that have no current vectors. Runs at startup beside the
note, rule and milestone backfills; a CHUNKER_VERSION bump re-embeds. note, rule and milestone backfills; a CHUNKER_VERSION bump re-embeds.
This is also the pass that makes every existing charter findable at all — On an install whose Systems predate their vectors, this is also the pass
Systems got vectors in #4251, so before it runs there are none.""" that makes every existing charter findable at all."""
from scribe.models.embedding import SystemEmbedding from scribe.models.embedding import SystemEmbedding
from scribe.models.system import System from scribe.models.system import System
@@ -2061,7 +2044,7 @@ async def backfill_system_embeddings() -> None:
current = select(SystemEmbedding.system_id).where( current = select(SystemEmbedding.system_id).where(
is_current_stamp(SystemEmbedding) is_current_stamp(SystemEmbedding)
) )
# IDS ONLY — the charter is re-read per System below (#4262). # IDS ONLY — the charter is re-read per System below.
by_version = { by_version = {
row[0] for row in (await session.execute( row[0] for row in (await session.execute(
select(System.id) select(System.id)
@@ -2104,7 +2087,7 @@ async def backfill_milestone_embeddings() -> None:
current = select(MilestoneEmbedding.milestone_id).where( current = select(MilestoneEmbedding.milestone_id).where(
is_current_stamp(MilestoneEmbedding) is_current_stamp(MilestoneEmbedding)
) )
# IDS ONLY — the plan is re-read per milestone below (#4262). # IDS ONLY — the plan is re-read per milestone below.
by_version = { by_version = {
row[0] for row in (await session.execute( row[0] for row in (await session.execute(
select(Milestone.id) select(Milestone.id)
+3 -3
View File
@@ -1665,9 +1665,9 @@ async def build_write_path_hint(
# resembles what is being written, noticed at the moment it is relevant # resembles what is being written, noticed at the moment it is relevant
# rather than by being resident in every session. # rather than by being resident in every session.
# #
# EVERY TIER — see the note on tiers near RULEHINT_LIMIT: a rule the # EVERY RULE IS ELIGIBLE, including one already named this session: a
# session was told about an hour ago is not a rule in front of the reader # rule the session was told about an hour ago is not a rule in front of
# now. # the reader now.
# #
# The arm itself is `retrieval_pipeline.run_rule_arm`: the band, the # The arm itself is `retrieval_pipeline.run_rule_arm`: the band, the
# fresh/repeat split, the unconditional call row and the fresh-only # fresh/repeat split, the unconditional call row and the fresh-only