Compare commits
2
Commits
cdb49b2f9f
...
7e9d33d9d9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e9d33d9d9 | ||
|
|
5d48e23285 |
@@ -21,11 +21,9 @@ _PARSED_FROM_BODY = frozenset({"title", "body", "tags"})
|
||||
def _mirror_recomposers() -> dict[str, Callable[[Note], dict]]:
|
||||
"""note_type -> the function that rebuilds that kind's derived `data`.
|
||||
|
||||
A TABLE rather than a chain of `if note_type == ...`, because the previous
|
||||
shape tested one constant and the next kind with a derived mirror was
|
||||
silently not covered — which is exactly what happened: the snippet guard
|
||||
(#3128) was hard-coded, and lessons arrived in milestone 385 with the same
|
||||
body-is-authority/`data`-is-mirror design and none of the protection.
|
||||
A TABLE rather than a chain of `if note_type == ...`, because a chain
|
||||
tests the constants someone remembered, and the next kind with a derived
|
||||
mirror (body is authority, `data` is mirror) is silently not covered.
|
||||
|
||||
The failure is invisible from here. Nothing raises, nothing logs; the row
|
||||
simply keeps answering queries from a mirror that no longer matches its
|
||||
@@ -49,7 +47,7 @@ def _mirror_recomposers() -> dict[str, Callable[[Note], dict]]:
|
||||
}
|
||||
|
||||
|
||||
# Text fields where EMPTY MEANS NULL (milestone 317). The sweep's whole signal
|
||||
# Text fields where EMPTY MEANS NULL. The sweep's whole signal
|
||||
# is `verify_with IS NULL` = "this is a decision, there is nothing to go and
|
||||
# check". An empty string that is not NULL makes a norm look like a constraint
|
||||
# nobody has verified, forever — and it would sit at the top of the sweep,
|
||||
@@ -64,7 +62,7 @@ def guard_check_fields(status: str | None, note_type: str | None) -> None:
|
||||
which fields a caller passed, so it also catches the sideways route: a
|
||||
checked note being turned into a task, which no per-field gate would see.
|
||||
|
||||
Raises rather than dropping silently, for minted_kind's reason (#3129) — a
|
||||
Raises rather than dropping silently, for minted_kind's reason — a
|
||||
silently-corrected write is the defect that reasoning exists to end, and a
|
||||
caller putting a check on the wrong record has an idea an error corrects
|
||||
and a default hides. Lives at the service, not either door, so REST and MCP
|
||||
@@ -92,12 +90,11 @@ def embed_note(note) -> None:
|
||||
"""Refresh a note's embedding, fire-and-forget.
|
||||
|
||||
Lives HERE — at the service, not the route — so every caller gets it by
|
||||
construction. Previously each REST route made this call itself and the MCP
|
||||
tools did not, so a record created through MCP stayed out of semantic search
|
||||
and auto-inject until the next restart's backfill ran (#2056). That is
|
||||
invisible on an instance that redeploys constantly and permanent on one that
|
||||
doesn't, which is the worst shape a bug can have: it only appears where
|
||||
nobody is looking.
|
||||
construction. A door that forgot the call would leave its records out of
|
||||
semantic search and auto-inject until the next restart's backfill ran —
|
||||
invisible on an instance that redeploys constantly and permanent on one
|
||||
that doesn't, which is the worst shape a bug can have: it only appears
|
||||
where nobody is looking.
|
||||
|
||||
Uses `note.user_id` — the OWNER — rather than the caller. Embeddings belong
|
||||
to the record, and a collaborator editing a shared note must refresh the
|
||||
@@ -110,16 +107,16 @@ def embed_note(note) -> None:
|
||||
|
||||
Detaching also means this task races anything that deletes the note out
|
||||
from under it. That is not handled here: `upsert_note_embedding` claims
|
||||
the note's row before touching its vectors, and loses if it can't (#3262).
|
||||
the note's row before touching its vectors, and loses if it can't.
|
||||
"""
|
||||
try:
|
||||
import asyncio
|
||||
|
||||
from scribe.services.embeddings import document_title, upsert_note_embedding
|
||||
# Chunking and the empty-record gate live inside upsert_note_embedding —
|
||||
# one path for every writer (#280). The title is the EMBEDDED one: a
|
||||
# one path for every writer. The title is the EMBEDDED one: a
|
||||
# snippet's or lesson's trigger joins its name here, not in the stored
|
||||
# title (milestone 427).
|
||||
# title.
|
||||
asyncio.create_task(
|
||||
upsert_note_embedding(
|
||||
note.id, note.user_id,
|
||||
@@ -305,7 +302,7 @@ def build_note(
|
||||
expires_when=expires_when,
|
||||
)
|
||||
# A create that names a status is the same transition an update to it is
|
||||
# (#3683) — otherwise `create_task(status="in_progress")` writes a row no
|
||||
# — otherwise `create_task(status="in_progress")` writes a row no
|
||||
# update could produce: started, with no `started_at`.
|
||||
if status is not None:
|
||||
apply_status_transition(note, user_id)
|
||||
@@ -318,11 +315,11 @@ def apply_status_transition(note: Note, user_id: int | None = None) -> None:
|
||||
Called by the update path whenever `status` is written and by `build_note`
|
||||
whenever a create names one, so a task created at a status is
|
||||
indistinguishable from one that reached it by update. Two copies of "what
|
||||
a status implies" are where the two drift, and #3683 was that drift.
|
||||
a status implies" are where the two drift.
|
||||
|
||||
`user_id` is whoever is making the change: reaching `in_progress` is the
|
||||
moment a session takes the work on, so it stamps that user's claim, and a
|
||||
status that ends or un-starts the work releases it (milestone 381).
|
||||
status that ends or un-starts the work releases it.
|
||||
"""
|
||||
from scribe.services.task_claims import release_claim, stamp_claim
|
||||
|
||||
@@ -382,15 +379,14 @@ async def list_notes(
|
||||
) -> tuple[list[Note], int]:
|
||||
"""Lifecycle-shaped listing. Two contracts worth knowing:
|
||||
|
||||
VISIBILITY is the shared browse clause (#47, #2462): the caller's own
|
||||
records plus anything in a project they can reach — the same reach
|
||||
query_knowledge has always had. Before this, list_notes was silently
|
||||
owner-only, so a task in a shared project was invisible in list_tasks,
|
||||
VISIBILITY is the shared browse clause: the caller's own records plus
|
||||
anything in a project they can reach — the same reach query_knowledge has.
|
||||
Owner-only here would hide a task in a shared project from list_tasks,
|
||||
enter_project's open-task list and the web UI while the same project's
|
||||
notes appeared. Browse, not read, per decision #2094: an ambient list must
|
||||
never surface a record someone shared one-to-one — those stay search-only.
|
||||
notes appeared. Browse, not read: an ambient list must never surface a
|
||||
record someone shared one-to-one — those stay search-only.
|
||||
|
||||
`q` is SEMANTIC (operator decision, 2026-08-06: "make it match") — the same
|
||||
`q` is SEMANTIC — the same
|
||||
meaning-based match as Browse search, at the interactive floor. When `q` is
|
||||
present, relevance ordering wins and `sort` is ignored; a query is a
|
||||
relevance claim and sorting its results by date would shuffle the answer.
|
||||
@@ -430,9 +426,9 @@ async def list_notes(
|
||||
if query_vec is not None:
|
||||
from scribe.models.embedding import NoteEmbedding
|
||||
from scribe.services.embeddings import INTERACTIVE_SEARCH_THRESHOLD
|
||||
# Best-chunk-per-note as a correlated MIN, not a join (#280):
|
||||
# a note stores one embedding row PER CHUNK, so the plain join
|
||||
# this used to be would repeat a long note once per matching
|
||||
# Best-chunk-per-note as a correlated MIN, not a join:
|
||||
# a note stores one embedding row PER CHUNK, so a plain join
|
||||
# would repeat a long note once per matching
|
||||
# chunk — duplicated list rows and a total that counts chunks.
|
||||
# This query is filter-heavy and paginated, never HNSW-bound,
|
||||
# so the scalar subquery costs what the join did.
|
||||
@@ -528,7 +524,7 @@ async def list_notes(
|
||||
elif sort == "touched":
|
||||
# Last touched: the later of the task's own edit and its newest
|
||||
# work-log. Logging doesn't bump updated_at, so a task worked
|
||||
# through its logs would otherwise sort as untouched (#4045).
|
||||
# through its logs would otherwise sort as untouched.
|
||||
# GREATEST skips a NULL, so a task with no logs sorts by its edit.
|
||||
from scribe.models.task_log import TaskLog
|
||||
last_log = (
|
||||
@@ -589,8 +585,8 @@ def minted_kind(kind: str) -> str:
|
||||
list is how the two doors would come to disagree.
|
||||
|
||||
Raises rather than falling back to 'work'. A silently-corrected kind is
|
||||
the defect this exists to end (#3129: the editor's Kind select reported
|
||||
success and changed nothing), and a caller naming a kind we do not know
|
||||
the defect this exists to end (an editor that reports success and changes
|
||||
nothing), and a caller naming a kind we do not know
|
||||
has a wrong idea that an error corrects and a default hides.
|
||||
"""
|
||||
if kind in MINTABLE_KINDS:
|
||||
@@ -616,7 +612,7 @@ async def update_note(
|
||||
the one form that cannot happen by accident. The REST door, where a
|
||||
cleared form input arrives as "", reaches the same place through the
|
||||
NULLABLE_NOTE_TEXT normalisation below — two idioms, one outcome.
|
||||
(Same shape as rulebooks.update_rule, milestone 312 step 2.)
|
||||
(Same shape as rulebooks.update_rule.)
|
||||
"""
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
@@ -650,9 +646,7 @@ async def update_note(
|
||||
# Same shape as status/priority above, and for the same
|
||||
# reason: a kind the column will refuse should fail here with
|
||||
# a readable message, not as a CheckViolationError from the
|
||||
# database. Before this, `task_kind` reached setattr through
|
||||
# the hasattr guard with no validation at all — but no door
|
||||
# ever offered it, so a task's kind was write-once (#3129).
|
||||
# database.
|
||||
try:
|
||||
value = TaskKind(value).value
|
||||
except ValueError:
|
||||
@@ -684,12 +678,12 @@ async def update_note(
|
||||
if note.verify_with != check_before:
|
||||
note.verified_at = None
|
||||
# Some kinds derive `data` from their body — so a write that moves the
|
||||
# body through this generic door must move the mirror with it (#3128).
|
||||
# Without this, PATCH /api/notes/<id> {body} left the mirror behind,
|
||||
# and every read PREFERS the mirror: a snippet went on reporting its
|
||||
# body through this generic door must move the mirror with it.
|
||||
# Without this, PATCH /api/notes/<id> {body} would leave the mirror
|
||||
# behind, and every read PREFERS the mirror: a snippet would report its
|
||||
# old repo/path/symbol to prior-art recall while showing its new body,
|
||||
# and a lesson would go on being retrieved for the situation it used to
|
||||
# name. The kind's own updater composes the mirror itself and passes it
|
||||
# and a lesson would be retrieved for the situation it used to name.
|
||||
# The kind's own updater composes the mirror itself and passes it
|
||||
# explicitly, so an explicit `data` always wins — the caller that knows
|
||||
# the field set beats the one that can only re-read the body.
|
||||
if "data" not in fields and not _PARSED_FROM_BODY.isdisjoint(fields):
|
||||
@@ -715,22 +709,21 @@ async def update_note(
|
||||
return note
|
||||
|
||||
|
||||
# A hard `delete_note(user_id, note_id)` lived here with ZERO callers, and was
|
||||
# removed with #278 step 1. It is recorded rather than silently dropped because
|
||||
# the danger was never that it ran — it is that it was findable by name. Someone
|
||||
# wanting to delete a note greps `delete_note`, finds a function in the notes
|
||||
# service with exactly the right signature, and permanently destroys a record
|
||||
# every path downstream expects to be recoverable.
|
||||
# There is deliberately no hard `delete_note(user_id, note_id)` here. The
|
||||
# danger of one is not that it runs — it is that it is findable by name.
|
||||
# Someone wanting to delete a note greps `delete_note`, finds a function in the
|
||||
# notes service with exactly the right signature, and permanently destroys a
|
||||
# record every path downstream expects to be recoverable.
|
||||
#
|
||||
# The delete path is `trash_svc.delete`, which soft-deletes an entity AND its
|
||||
# descendants under one batch_id so `restore(batch)` works. `purge_trash` owns
|
||||
# permanent deletion. Both are reachable; neither is spelled `delete_note`.
|
||||
|
||||
|
||||
# ── The sweep (milestone 317 step 3) ─────────────────────────────────────────
|
||||
# ── The sweep ────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# A SIBLING of rulebooks.rules_due_for_verification, not a shared
|
||||
# implementation, and deliberately so (note 3163). The row could have been
|
||||
# implementation, and deliberately so. The row could have been
|
||||
# shared; the QUERY cannot. That sweep scopes by rulebook ownership XOR project
|
||||
# ownership because rules have no sharing ACL at all — no rule_shares, no
|
||||
# can_read_rule. A note scopes by the note ACL, which is a different question
|
||||
@@ -760,12 +753,12 @@ async def notes_due_for_verification(
|
||||
decisions, there is nothing to go and check, and listing them would dilute
|
||||
the result until nobody reads it.
|
||||
|
||||
Scoped with `browsable_notes_clause`, NOT the read scope (decision note
|
||||
2094): a sweep is a passive surface, and a record shared one-to-one must
|
||||
Scoped with `browsable_notes_clause`, NOT the read scope: a sweep is a
|
||||
passive surface, and a record shared one-to-one must
|
||||
not arrive in one unasked.
|
||||
|
||||
Deliberately NOT filtered to non-task, non-snippet records even though the
|
||||
write path (step 2) permits a check on nothing else. A row in that state
|
||||
write path permits a check on nothing else. A row in that state
|
||||
would be a row in an ILLEGAL state, and this is the one surface that could
|
||||
tell somebody about it. Hiding it here to match the invariant would make
|
||||
the sweep agree with a database it had stopped describing.
|
||||
@@ -942,10 +935,10 @@ async def resolve_process(user_id: int, name_or_id) -> tuple[Note | None, list[d
|
||||
"""Resolve a stored process by id or name.
|
||||
|
||||
note_type='process', non-trashed, scoped to what this user may READ — owned
|
||||
plus shared (rule #78). Naming one is an explicit act, so a Process shared
|
||||
with the caller resolves here even though it is deliberately absent from the
|
||||
passive process list and the skill manifest (decision note 2094); without
|
||||
this, a Process a search surfaced could not then be run — see #2093.
|
||||
plus shared (services/access.py). Naming one is an explicit act, so a
|
||||
Process shared with the caller resolves here even though it is
|
||||
deliberately absent from the passive process list and the skill manifest;
|
||||
without this, a Process a search surfaced could not then be run.
|
||||
|
||||
Precedence: numeric id → exact case-insensitive title → substring. Returns
|
||||
(note, other_candidates); on a substring tie with no exact hit, `note` is the
|
||||
@@ -1118,16 +1111,15 @@ async def get_note_for_user(
|
||||
return (note, perm) if note else None
|
||||
|
||||
|
||||
# What a LISTING row carries (#4061): what the record is and where it sits,
|
||||
# never what it says. list_tasks once returned every row's to_dict(), body
|
||||
# included, and a project's todo list came to 93-165k characters, past what an
|
||||
# MCP client accepts inline, so the list arrived as a file to page through. The
|
||||
# same failure #4045 fixed for enter_project, one call over. get_task / get_note
|
||||
# read a record in full; a list is for choosing which one to open.
|
||||
# What a LISTING row carries: what the record is and where it sits, never what
|
||||
# it says. Every row's to_dict(), body included, can bring a project's todo
|
||||
# list past what an MCP client accepts inline, so the list arrives as a file to
|
||||
# page through. get_task / get_note read a record in full; a list is for
|
||||
# choosing which one to open.
|
||||
#
|
||||
# A field most rows leave empty (a one-line description, a parent, a due date)
|
||||
# is attached only when set: a hundred rows of `null` are a hundred chances to
|
||||
# learn to skip the key (#2483), and the bytes are the thing being cut.
|
||||
# learn to skip the key, and the bytes are the thing being cut.
|
||||
def brief_row(
|
||||
note: Note,
|
||||
milestone_titles: dict[int, str] | None = None,
|
||||
|
||||
@@ -1,81 +1,60 @@
|
||||
"""Rule usage telemetry — did a surfaced rule ever get read?
|
||||
|
||||
The sibling of `note_usage`, for the one retrieval surface in Scribe that
|
||||
could not be measured at all.
|
||||
The sibling of `note_usage`, for rules.
|
||||
|
||||
Two event streams, deliberately independent:
|
||||
Event streams, deliberately independent:
|
||||
|
||||
- SURFACED: the write-path standing-rule arm put this rule in front of the
|
||||
agent, unbidden, during a write.
|
||||
- SURFACED: a surface put this rule in front of the agent — a ranked arm,
|
||||
unbidden, or a bulk delivery (see AMBIENT VS RANKED below).
|
||||
- PULLED: someone then opened it in full (`get_rule`, or the REST detail
|
||||
route).
|
||||
|
||||
WHY THIS ARM AND NOT ANOTHER. Every other surface declines most of the time —
|
||||
`write_path` returns nothing on 78% of calls, `reuse_slot` on 79%, auto-inject
|
||||
on 39%. The rule arm APPEARED never to have returned nothing (#3311), and this
|
||||
docstring used to put that forward as the puzzle worth measuring: "either a
|
||||
perfectly tuned surface or a bar it cannot fail to clear".
|
||||
|
||||
It was neither, and the correction belongs here rather than being quietly
|
||||
deleted. The arm wrote its `retrieval_logs` row only on calls that FOUND
|
||||
something (#3497), so `zero_result_calls` sat at 0 and `cleared_threshold` at
|
||||
`calls` because of the shape of the code — at any threshold whatsoever. A
|
||||
statistic that could not vary was read as a finding about the corpus. It is the
|
||||
#2663 failure mode one level up: there the broken readout was a zero, here it
|
||||
was a hundred percent, which is far better camouflage.
|
||||
|
||||
The reason to measure this arm survives the correction, and is stronger for it.
|
||||
`retrieval_logs` records what the ranker scored, never whether the hint was any
|
||||
use, so even an honest clear-rate would not settle the question. The ratio these
|
||||
two streams produce is the missing half, and without it any threshold change is
|
||||
a number picked off a histogram.
|
||||
WHY RULES NEED THEIR OWN STREAM. `retrieval_logs` records what the ranker
|
||||
scored, never whether the hint was any use, so even an honest clear-rate cannot
|
||||
settle whether a rule arm earns its lines. The ratio these two streams produce
|
||||
is the missing half, and without it any threshold change is a number picked
|
||||
off a histogram. (A clear-rate can also mislead outright: an arm that logs
|
||||
only the calls that FOUND something reports a decline count of zero at any
|
||||
threshold — a statistic that cannot vary, read as a finding about the corpus.)
|
||||
|
||||
Design notes, mirroring `note_usage`:
|
||||
- Writes are fire-and-forget through `background.spawn`, so telemetry never
|
||||
adds latency to — or can break — the surface it observes. This module does
|
||||
NOT carry its own copy of the strong-reference dance; `background` is the
|
||||
one place that gets it right, and a fourth copy is how one of them drifts.
|
||||
one place that gets it right, and another copy is how one of them drifts.
|
||||
- Failures degrade, but never SILENTLY. `report_telemetry_failure` logs at
|
||||
WARNING and drops one AppLog row per process per site. #2663 is the record
|
||||
of this exact subsystem class running at zero for weeks — indistinguishable
|
||||
from "nobody uses this" — because every failure went to `logger.debug`.
|
||||
WARNING and drops one AppLog row per process per site. A telemetry
|
||||
subsystem whose failures go to `logger.debug` can run at zero for weeks,
|
||||
indistinguishable from "nobody uses this".
|
||||
- Reads (`usage_for_rules`) are awaited and aggregated in one round-trip for
|
||||
a whole page, never per row.
|
||||
|
||||
AMBIENT VS RANKED. The note twin splits ranked surfacings from ambient ones
|
||||
because `enter_project` and the skill sync put records in front of the agent
|
||||
without choosing them, and counting those as surfacings makes recency read as
|
||||
popularity (#2477). Rules have exactly that shape: the SessionStart preload,
|
||||
and every `rules_payload` surface hand over the whole
|
||||
applicable set at once, chosen by nobody.
|
||||
popularity. Rules have exactly that shape: the SessionStart preload, and every
|
||||
`rules_payload` surface hand over the whole applicable set at once, chosen by
|
||||
nobody.
|
||||
|
||||
Until 2026-09-03 those bulk surfaces emitted nothing, and this module said so —
|
||||
"an empty `AMBIENT_SOURCES` would be machinery pretending to a distinction the
|
||||
data does not yet contain". True as far as it went, but it had a consequence
|
||||
worth naming, because it is the reason the bucket exists now: the always-on
|
||||
set's token cost was certain and its usefulness was UNFALSIFIABLE, permanently
|
||||
and by construction. The one surface whose value was actually in question was
|
||||
the one surface exempt from the scoreboard that judges every other.
|
||||
|
||||
They emit now. The split is the readout-level change the old note promised — a
|
||||
`case()`, no migration, because `event` and `source` are plain Text with no
|
||||
CHECK constraint. `source` stays granular so a reader can still tell the
|
||||
preload from `enter_project` from the ranked arm.
|
||||
Those bulk surfaces emit too, as ambient. Without them the always-on set's
|
||||
token cost would be certain and its usefulness UNFALSIFIABLE — the one surface
|
||||
whose value is actually in question would be the one exempt from the
|
||||
scoreboard that judges every other. The split is a readout-level `case()`, no
|
||||
migration, because `event` and `source` are plain Text with no CHECK
|
||||
constraint. `source` stays granular so a reader can still tell the preload
|
||||
from `enter_project` from a ranked arm.
|
||||
|
||||
WHY THIS NAMES THE RANKED SOURCES AND THE TWIN NAMES THE AMBIENT ONES. A
|
||||
deliberate divergence, on the failure mode rather than on symmetry. Both shapes
|
||||
fail silently when someone adds a surface and forgets the list, so the question
|
||||
is which list changes more often — and here it is emphatically the ambient one:
|
||||
there are TWO ranked rule sources (the write-path arm and the pre-tool arm)
|
||||
against the seven bulk ones the preload alone contributes. Ranked sources are
|
||||
added when somebody builds a ranker, which is rare and deliberate; bulk ones
|
||||
appear whenever a surface hands rules over, which is most of them. Naming the
|
||||
rare, slow-moving half means a newly-added bulk surface defaults to
|
||||
is which list changes more often — and here it is the ambient one. Ranked
|
||||
sources are added when somebody builds a ranker, which is rare and deliberate;
|
||||
bulk ones appear whenever a surface hands rules over, which is most of them.
|
||||
Naming the rare, slow-moving half means a newly-added bulk surface defaults to
|
||||
`ambient`, which merely under-counts it, instead of defaulting to `ranked`,
|
||||
which would quietly pad the pull-through denominator with surfacings nobody
|
||||
chose and make the arm look imprecise. Same argument #3191 and #3430 make
|
||||
against hand-kept lists: keep the list that must be remembered as short and as
|
||||
slow-moving as possible.
|
||||
chose and make the arm look imprecise. Keep the list that must be remembered
|
||||
as short and as slow-moving as possible.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -99,15 +78,15 @@ logger = logging.getLogger(__name__)
|
||||
# Membership is the whole definition of the pull-through denominator: a ranked
|
||||
# surfacing is a claim ("this rule may apply to what you are doing") that a pull
|
||||
# can confirm or refute, while an ambient one is a delivery nobody decided on.
|
||||
# The ranked half is read off the retrieval pipeline's specs (milestone 456
|
||||
# step 5): every rule arm — the reply backstop included, which records only
|
||||
# The ranked half is read off the retrieval pipeline's specs: every rule arm
|
||||
# — the reply backstop included, which records only
|
||||
# the rule that HELD the reply — the preference slot, which is a ranker's
|
||||
# choice twice over, and the rule reached through a confirmed lesson link,
|
||||
# whose own source lets its pull-through read apart from a direct match
|
||||
# (#4636). An arm added there is counted here by construction.
|
||||
# whose own source lets its pull-through read apart from a direct match.
|
||||
# An arm added there is counted here by construction.
|
||||
RANKED_SOURCES = (
|
||||
*RULE_RANKED_SOURCES,
|
||||
# A rule mounted on a moment (milestone 458). Not a ranker's pick, but
|
||||
# A rule mounted on a moment. Not a ranker's pick, but
|
||||
# not bulk either: somebody decided this rule applies at this moment, and
|
||||
# that is exactly the claim a pull can confirm or refute. Its
|
||||
# pull-through is the evidence for whether a mount earns its line.
|
||||
@@ -119,11 +98,10 @@ def is_ambient(source: str) -> bool:
|
||||
"""Was this surfacing a bulk delivery rather than a ranked choice?
|
||||
|
||||
One definition, read by both the per-rule badge readout and the aggregate
|
||||
in `retrieval_telemetry` — the two used to be able to disagree about what
|
||||
"surfaced" counted, which is the class of drift #3246 found across the
|
||||
rules system.
|
||||
in `retrieval_telemetry`, so the two cannot disagree about what
|
||||
"surfaced" counts.
|
||||
|
||||
Sync and pure, per the service canon (#2860), but deliberately PUBLIC where
|
||||
Sync and pure, per the service canon, but deliberately PUBLIC where
|
||||
that canon says such helpers stay `_private`. The departure is the point:
|
||||
a module-private copy in each caller is exactly the second definition this
|
||||
exists to prevent.
|
||||
@@ -174,7 +152,7 @@ def record_rule_surfaced(
|
||||
care which kind it is recording.
|
||||
|
||||
`detail` maps a rule id to what to record beside its row — the moment a
|
||||
mounted rule arrived at (milestone 458), so a mount's pull-through can be
|
||||
mounted rule arrived at, so a mount's pull-through can be
|
||||
read per moment rather than only per arm.
|
||||
"""
|
||||
try:
|
||||
@@ -225,14 +203,13 @@ def record_rule_outcome(
|
||||
source: str,
|
||||
detail: str = "",
|
||||
) -> None:
|
||||
"""Fire-and-forget: record what a rule ACTUALLY CHANGED (#4212).
|
||||
"""Fire-and-forget: record what a rule ACTUALLY CHANGED.
|
||||
|
||||
The third stream, and the one milestone 419 exists for. `surfaced` says
|
||||
the system offered a rule; `pulled` says somebody opened it. Neither says
|
||||
whether it made any difference, so a rule that fires constantly and is
|
||||
always obeyed and a rule that fires constantly and is never obeyed have,
|
||||
until now, produced identical telemetry. The second is far the more
|
||||
urgent and is precisely the one the readout could not name.
|
||||
The third stream. `surfaced` says the system offered a rule; `pulled`
|
||||
says somebody opened it. Neither says whether it made any difference, so
|
||||
without this a rule that fires constantly and is always obeyed and a rule
|
||||
that fires constantly and is never obeyed produce identical telemetry.
|
||||
The second is far the more urgent.
|
||||
|
||||
Two outcomes, because there are only two a judge can honestly report:
|
||||
|
||||
@@ -245,14 +222,14 @@ def record_rule_outcome(
|
||||
states this exists to separate.
|
||||
|
||||
THERE IS NO THIRD CALL, and the absence is the design. Read-and-silently-
|
||||
unchanged is real — it is the failure this milestone was opened on — but
|
||||
unchanged is real — it is the failure this stream exists to expose — but
|
||||
it cannot be reported, because an agent that knew it was ignoring a rule
|
||||
would not be ignoring it. It is derived: a rule pulled, with no outcome
|
||||
behind it. See `outcome_state`.
|
||||
|
||||
Guarded rather than trusting: a bad outcome or a reasonless departure is
|
||||
dropped and REPORTED, never written. Telemetry that lies is worse than
|
||||
telemetry that is missing (#2663), and a `departed` row with an empty
|
||||
telemetry that is missing, and a `departed` row with an empty
|
||||
reason is a lie the readout cannot detect.
|
||||
"""
|
||||
if outcome not in OUTCOMES:
|
||||
@@ -280,9 +257,8 @@ def record_rule_outcome(
|
||||
|
||||
|
||||
# What a rule's usage says happened to it, in one word. The four states are
|
||||
# ordered by how much the system actually knows, and only the last two are
|
||||
# new — the point of the milestone is that UNACTED used to be invisible
|
||||
# inside APPLIED.
|
||||
# ordered by how much the system actually knows; recorded outcomes are what
|
||||
# keep UNACTED from hiding inside FOLLOWED.
|
||||
UNREAD = "unread" # surfaced, never opened
|
||||
UNACTED = "unacted" # opened, and nothing recorded after — the blind spot
|
||||
FOLLOWED = "followed" # opened and applied
|
||||
@@ -290,13 +266,12 @@ DEPARTED_FROM = "departed" # opened and deliberately not followed, with a why
|
||||
|
||||
|
||||
def outcome_state(usage: dict) -> str:
|
||||
"""The three states milestone 419 asked to be able to tell apart, plus
|
||||
the one that already existed.
|
||||
"""Which of the four states a rule's usage is in.
|
||||
|
||||
Pure, and reading only the aggregate `usage_for_rules` already returns —
|
||||
so the readout, the badge and any later session summary all answer this
|
||||
question the same way. Two callers computing "was this followed" from raw
|
||||
counts is the drift #3246 found across the rules system, arriving again.
|
||||
counts would drift.
|
||||
|
||||
PRECEDENCE, and it is deliberate: a departure outranks an application.
|
||||
A rule both applied and departed from in the same window is a rule
|
||||
@@ -306,8 +281,8 @@ def outcome_state(usage: dict) -> str:
|
||||
UNACTED is the derived state and the reason this function exists. It is
|
||||
not "no data"; it is a rule that was surfaced, deliberately OPENED, and
|
||||
then left no trace of having mattered. That is a much stronger signal
|
||||
than never having been opened at all, and it is the signal that was
|
||||
previously indistinguishable from compliance.
|
||||
than never having been opened at all, and without recorded outcomes it
|
||||
is indistinguishable from compliance.
|
||||
"""
|
||||
if int(usage.get("departed_count") or 0):
|
||||
return DEPARTED_FROM
|
||||
@@ -338,9 +313,9 @@ def empty_rule_usage() -> dict:
|
||||
"surfaced_count": 0,
|
||||
"ambient_count": 0,
|
||||
"pull_count": 0,
|
||||
# The outcome half (#4212). Zero here means "nothing recorded", which
|
||||
# for a rule that was also never pulled is simply silence — and for
|
||||
# one that WAS pulled is the blind spot this milestone is named for.
|
||||
# The outcome half. Zero here means "nothing recorded", which for a
|
||||
# rule that was also never pulled is simply silence — and for one
|
||||
# that WAS pulled is the blind spot (UNACTED).
|
||||
# `outcome_state` is what tells those apart; no caller should be
|
||||
# reading these counts raw to decide it.
|
||||
#
|
||||
@@ -349,7 +324,7 @@ def empty_rule_usage() -> dict:
|
||||
# DISTINCT ON alongside it, and a key that the aggregate could never
|
||||
# fill would read as "no reason given" on every rule that has one —
|
||||
# a permanently-null field that lies. The readout that needs the
|
||||
# prose reads the rows (#4213).
|
||||
# prose reads the rows.
|
||||
"applied_count": 0,
|
||||
"departed_count": 0,
|
||||
"last_outcome_at": None,
|
||||
@@ -377,9 +352,8 @@ async def usage_for_rules(rule_ids: list[int]) -> dict[int, dict]:
|
||||
# GROUP BY — a second `case()` instance there renders its own expanding-IN
|
||||
# bind names under asyncpg, so the database sees two DIFFERENT expressions
|
||||
# and rejects the query with a GroupingError. The note twin carries the
|
||||
# same warning for the same reason, and #2663 is what it cost: the
|
||||
# rejection was swallowed and every counter read zero in production while
|
||||
# the writes were landing fine.
|
||||
# same warning for the same reason: the rejection is swallowed and every
|
||||
# counter reads zero while the writes are landing fine.
|
||||
ambient = case(
|
||||
(RuleUsageEvent.source.notin_(RANKED_SOURCES), True),
|
||||
else_=False,
|
||||
@@ -406,7 +380,7 @@ async def usage_for_rules(rule_ids: list[int]) -> dict[int, dict]:
|
||||
except Exception:
|
||||
# A telemetry readout must not be able to break the list it decorates —
|
||||
# but it must say it failed, or a broken readout is indistinguishable
|
||||
# from a corpus nobody uses (#2663).
|
||||
# from a corpus nobody uses.
|
||||
await _report_failure("readout")
|
||||
return out
|
||||
|
||||
|
||||
Reference in New Issue
Block a user