fix(6251): the rules warning sends its reader to the rule-arm review

surfaced_never_pulled told a rules reader there was no judged sample for the
rule arms. There is one now; the guard tying the text to REVIEWABLE caught it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 19:59:02 -04:00
co-authored by Claude Opus 5.5
parent 7c1d98dd2a
commit 95ef4af746
2 changed files with 51 additions and 25 deletions
+5 -2
View File
@@ -599,8 +599,11 @@ async def retrieval_telemetry(
corpus. Not a verdict on them: a line carries its matched passage, so corpus. Not a verdict on them: a line carries its matched passage, so
an unopened record may have been unrelated, enough as shown, or already an unopened record may have been unrelated, enough as shown, or already
in context. Judge a sample (`menus_to_review`) before acting on it. in context. Judge a sample (`menus_to_review`) before acting on it.
For RULES there is no judged sample: a rule set aside again and again For RULES, judge a sample of the rule arm that showed them
is a trigger to fix (`update_rule(when_to_apply=...)`), not a floor. (`menus_to_review(source="prompt_rule")`, or `pre_tool_rule`,
`write_path_rule`); a rule set aside again and again where it does
not apply is a trigger to fix (`update_rule(when_to_apply=...)`), not
a floor.
- `read_and_unacted` — distinct rules OPENED in the window that recorded - `read_and_unacted` — distinct rules OPENED in the window that recorded
no outcome, against the ones that did. The worse sibling of `surfaced_never_pulled` above: a no outcome, against the ones that did. The worse sibling of `surfaced_never_pulled` above: a
rule nobody opens is cheap, while a rule read and silently unchanged is rule nobody opens is cheap, while a rule read and silently unchanged is
+46 -23
View File
@@ -41,6 +41,7 @@ from grimoire.models.system_usage import SURFACED as SYSTEM_SURFACED
from grimoire.models.system_usage import SystemUsageEvent from grimoire.models.system_usage import SystemUsageEvent
from grimoire.services.rule_usage import is_ambient from grimoire.services.rule_usage import is_ambient
from grimoire.models.retrieval_log import RetrievalLog from grimoire.models.retrieval_log import RetrievalLog
from grimoire.models.rulebook import Rule
from grimoire.services.retrieval_review import judged_block from grimoire.services.retrieval_review import judged_block
from grimoire.services.retrieval_registry import ( from grimoire.services.retrieval_registry import (
POINTS, UNBIDDEN, get_point, is_registered, sources_expected_to_emit, POINTS, UNBIDDEN, get_point, is_registered, sources_expected_to_emit,
@@ -489,9 +490,10 @@ def _num(raw: str, fallback):
# to look next. ONE SENTENCE PER CORPUS because the two lines are built # to look next. ONE SENTENCE PER CORPUS because the two lines are built
# differently: a note line carries its matched passage, so an unopened # differently: a note line carries its matched passage, so an unopened
# note may have done its job, and the judged sample is the instrument that can # note may have done its job, and the judged sample is the instrument that can
# tell. A rule line carries only its trigger and asks to be opened, and no # tell. A rule line carries only its trigger and asks to be opened, so a rule
# judged sample covers the rule arms (`retrieval_review.REVIEWABLE`), so # set aside again and again is usually a trigger to fix; the judged sample
# sending that reader to `menus_to_review` would send them to a refusal. # (`retrieval_review.REVIEWABLE` names the rule arms it covers) says whether
# it was set aside rightly.
_NEVER_PULLED_READING = { _NEVER_PULLED_READING = {
"notes": ( "notes": (
"That is not a verdict on them: a line carries its matched passage, " "That is not a verdict on them: a line carries its matched passage, "
@@ -504,11 +506,13 @@ _NEVER_PULLED_READING = {
"Not a verdict either, and read differently from notes: the count " "Not a verdict either, and read differently from notes: the count "
"includes rules that only arrived in a listing (the project " "includes rules that only arrived in a listing (the project "
"handshake, a planning read), and a rule line shows its trigger, so a " "handshake, a planning read), and a rule line shows its trigger, so a "
"session can rightly set one aside on the trigger alone. There is no " "session can rightly set one aside on the trigger alone. To tell a "
"judged sample for the rule arms. A rule set aside again and again " "rule set aside rightly from one missed, judge a sample of a rule "
"where it does not apply is a trigger to fix, not a floor to move — " "arm with `menus_to_review(source=\"prompt_rule\")` (or "
"`update_rule(when_to_apply=...)`, then `what_might_apply` with the " "`pre_tool_rule`, `write_path_rule`). A rule set aside again and "
"moment's own words to see where it ranks." "again where it does not apply is a trigger to fix, not a floor to "
"move — `update_rule(when_to_apply=...)`, then `what_might_apply` "
"with the moment's own words to see where it ranks."
), ),
} }
@@ -936,7 +940,9 @@ async def moment_usage(user_id: int | None, since) -> dict:
- `rules`: how many distinct rules did; - `rules`: how many distinct rules did;
- `opened`: how many of those an AGENT then opened (`mcp_*`), at or after - `opened`: how many of those an AGENT then opened (`mcp_*`), at or after
their first delivery there in the window; their first delivery there in the window;
- `last_delivered_at`. - `last_delivered_at`;
- `by_rule`: each rule delivered there, most delivered first, with its
`title`, `delivered` and whether it was `opened`.
`opened` is an UPPER BOUND, for by_source's reason: a pull records the `opened` is an UPPER BOUND, for by_source's reason: a pull records the
door, not the line that prompted it, so a rule delivered at two moments door, not the line that prompted it, so a rule delivered at two moments
@@ -983,15 +989,21 @@ async def moment_usage(user_id: int | None, since) -> dict:
pull.source.startswith("mcp_", autoescape=True), pull.source.startswith("mcp_", autoescape=True),
pull.created_at >= per_rule.c.first_at, pull.created_at >= per_rule.c.first_at,
) )
# One row per (moment, rule); the moment's totals are summed from
# these below, so `by_rule` and the totals cannot disagree.
rows = ( rows = (
await session.execute( await session.execute(
select( select(
per_rule.c.moment, per_rule.c.moment,
func.sum(per_rule.c.n).label("delivered"), per_rule.c.rule_id,
func.count().label("rules"), Rule.title,
func.count(case((opened, 1))).label("opened"), per_rule.c.n,
func.max(per_rule.c.last_at).label("last_at"), opened.label("opened"),
).group_by(per_rule.c.moment) per_rule.c.last_at,
)
.select_from(per_rule)
.outerjoin(Rule, Rule.id == per_rule.c.rule_id)
.order_by(per_rule.c.moment, per_rule.c.n.desc(), per_rule.c.rule_id)
) )
).all() ).all()
complete = await _complete_from(session, RuleUsageEvent, user_id) complete = await _complete_from(session, RuleUsageEvent, user_id)
@@ -1000,15 +1012,26 @@ async def moment_usage(user_id: int | None, since) -> dict:
block["moment_usage_failed"] = True block["moment_usage_failed"] = True
return block return block
block["by_moment"] = { by_moment: dict[str, dict] = {}
moment: { for moment, rule_id, title, n, was_opened, last_at in rows:
"delivered": int(delivered or 0), slot = by_moment.setdefault(moment, {
"rules": int(rules or 0), "delivered": 0, "rules": 0, "opened": 0,
"opened": int(n_opened or 0), "last_delivered_at": None, "by_rule": [],
"last_delivered_at": iso(last_at), })
} slot["delivered"] += int(n or 0)
for moment, delivered, rules, n_opened, last_at in rows slot["rules"] += 1
} slot["opened"] += 1 if was_opened else 0
stamp = iso(last_at)
if stamp and (slot["last_delivered_at"] or "") < stamp:
slot["last_delivered_at"] = stamp
# Which rule a shared moment delivered, and whether that rule was
# opened: the per-moment counts cannot say once two rules are
# mounted there. `title` is null for a rule deleted since.
slot["by_rule"].append({
"rule_id": int(rule_id), "title": title,
"delivered": int(n or 0), "opened": bool(was_opened),
})
block["by_moment"] = by_moment
block.update(_coverage(complete.get("*"), since)) block.update(_coverage(complete.get("*"), since))
return block return block