feat(rules): a repeat is referenced, not withheld (#3750, #3752)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 40s
CI & Build / Python tests (push) Successful in 1m9s
CI & Build / Build & push image (push) Successful in 37s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 40s
CI & Build / Python tests (push) Successful in 1m9s
CI & Build / Build & push image (push) Successful in 37s
Both arms used to drop a hit already on the session's exclusion ledger and emit nothing. That is correct only while the session still HOLDS what it was told, and a compaction breaks exactly that: the earlier injection is summarized away while the id stays on the ledger, leaving the rule absent from context AND unreachable for the rest of the session. #3749 closed the compaction half by clearing the ledger; this closes the ordinary half, where a session simply stops holding a line it read an hour ago. Only ONE CLAUSE of the existing line is false on a repeat — the claim that the rule is not in the session's loaded set — so only that clause changes. The fresh line is byte-identical to what it was. Both tails now come from one `_rule_hint_line`. The arms phrase their heads differently on purpose; everything after must not differ, and #3497's history is that the pre-tool arm inherited a defect by being modelled on its sibling rather than sharing with it. THE BUDGET DECISION, recorded at RULEHINT_LIMIT. A repeat competes for the single slot on rank alone: nothing is fetched behind it, and it never rides alongside as a second line. Promoting a fresh rule past a better-ranked repeat would reinstate the withholding one rank deeper, and a second line is the one thing the limit exists to forbid. The consequence is deliberate — a rule that keeps ranking first for a recurring situation keeps being referenced, and its decay belongs to exclusion ageing (#3751), not to a first-place rule being demoted for having won before. THE TELEMETRY, decided before shipping rather than after a number moved (#3752): nothing changes. A reference is a RENDERING decision, not a retrieval outcome. `results` stays `fresh`, `suppressed` stays len(hits) - len(fresh), and `record_rule_surfaced` still counts only what the arm freshly chose. This matters more than it reads: the naive implementation drops the `fresh` filter and takes suppressed_count to zero everywhere — and #3739's near-miss fix identifies repeat-caused zeros by `suppressed_count > 0`, so the contamination corrected on 2026-09-08 would return by a different route, in the same field, with the fix still in the code and no longer working. A test asserts the counters as unmoved, because "nothing changed" is only worth something if it is checkable. Also corrects two comments that outlived #3702 — both arms still claimed CONDITIONAL ONLY while the module-level note above RULEHINT_LIMIT said the opposite, in the exact code this change edits. GUARDS - an already-held hit produces a rule line at all (the regression), asserted on `get_rule(<id>)` rather than a truthy context: the write-path arm fills its context from four other sources, so truthiness passes under the OLD behaviour and pins nothing there. - the two tails are distinguishable, each excludes the other, and neither injects the rule statement — the budget claim, both branches. - the counters are unmoved, per #3752. - test_a_rule_the_session_already_holds_is_not_re_offered asserted the old contract (`"161" not in context`). Repinned rather than deleted: the telemetry half of what it protected still holds. Repinned the #3497 log-placement guard on structure (rule 167). It read `body.index("if not fresh:")` — a local variable NAME, not the property. This change renames that guard to `if not hits:`, so the old assertion would have raised ValueError and reported #3497 as back while the arm was entirely correct. Now walks the AST for the first early return after the search and asserts the call row is written before it. Falsified both ways: it fails on the #3497 mutation, and it refuses to pass when no early return exists at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011cPyzNnegXHr5iRMzzy5KJ
This commit is contained in:
@@ -10,6 +10,7 @@ end in two different doors, and the property under test is that they meet. Split
|
||||
across three module-shaped files, "both ends are wired" is a thing no single
|
||||
test asserts.
|
||||
"""
|
||||
import ast
|
||||
from contextlib import ExitStack
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
@@ -472,14 +473,40 @@ async def test_the_tool_arm_is_a_ranked_source():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_rule_the_session_already_holds_is_not_re_offered():
|
||||
async def test_a_rule_the_session_already_holds_is_referenced_not_re_offered():
|
||||
"""WAS `..._is_not_re_offered`, asserting `"161" not in out["context"]`.
|
||||
|
||||
That assertion was the old contract and #3750 deliberately reverses half of
|
||||
it: a rule already on the ledger now gets a line with a different tail
|
||||
instead of being dropped in silence. Withholding was only ever right while
|
||||
the session still HELD what it was told, and a compaction breaks exactly
|
||||
that while leaving the id excluded.
|
||||
|
||||
The half that survives is the half that was always about telemetry rather
|
||||
than rendering: an already-held rule stays out of `rule_ids`, so it reaches
|
||||
neither the exclusion ledger (where it already is) nor the surfacing count
|
||||
(which a reference must not inflate).
|
||||
"""
|
||||
rec = MagicMock()
|
||||
hits = [(0.71, fake_rule(id=161, title="Reach the forge through its MCP tools")),
|
||||
(0.70, fake_rule(id=12, title="Don't run a local stack unless asked"))]
|
||||
out = await _run_tool_arm(hits, rec, exclude_rule_ids=[161])
|
||||
|
||||
assert out["rule_ids"] == [12]
|
||||
assert "161" not in out["context"]
|
||||
assert out["rule_ids"] == [12], (
|
||||
"a referenced rule was counted as surfaced; only the fresh one was "
|
||||
"actually chosen by this arm"
|
||||
)
|
||||
assert "get_rule(161)" in out["context"], (
|
||||
"the held rule vanished instead of being referenced (#3750)"
|
||||
)
|
||||
assert "get_rule(12)" in out["context"], (
|
||||
"the fresh rule was lost while adding the reference — both belong in "
|
||||
"the hint, and the reference must not displace the surfacing"
|
||||
)
|
||||
assert _SEEN_TAIL in out["context"] and _FRESH_TAIL in out["context"], (
|
||||
"one call rendered two hits in different states and gave them the same "
|
||||
"tail; the tails are the entire difference a reader can act on"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -705,10 +732,46 @@ def test_neither_rule_arm_logs_its_call_behind_a_results_guard():
|
||||
)
|
||||
|
||||
# Pre-tool arm: the call log comes BEFORE the early return.
|
||||
body = pc_src.split("async def build_tool_rule_hint")[1]
|
||||
assert body.index('source="pre_tool_rule"') < body.index("if not fresh:"), (
|
||||
"the pre-tool arm returns before logging its call — a surface with no "
|
||||
"rows at all cannot be told apart from a hook that never fired"
|
||||
#
|
||||
# WALKED, NOT SUBSTRING-MATCHED (rule 167). This assertion used to read
|
||||
# `body.index("if not fresh:")`, which pinned the name of a local variable
|
||||
# rather than the property. #3750 changed that guard to `if not hits:` —
|
||||
# the arm still logs before returning, so the property held perfectly, and
|
||||
# a name-matching assertion would have raised ValueError and reported the
|
||||
# #3497 defect as back. A guard that cries regression when the thing it
|
||||
# protects is intact is the failure mode rule 167 names.
|
||||
#
|
||||
# The property is positional: between the search and the first guard that
|
||||
# can return early, the call row has already been written.
|
||||
fn = next(
|
||||
n for n in ast.walk(ast.parse(pc_src))
|
||||
if isinstance(n, ast.AsyncFunctionDef) and n.name == "build_tool_rule_hint"
|
||||
)
|
||||
search_at = min(
|
||||
n.lineno for n in ast.walk(fn)
|
||||
if isinstance(n, ast.Call)
|
||||
and getattr(n.func, "id", None) == "semantic_search_rules"
|
||||
)
|
||||
logged_at = min(
|
||||
n.lineno for n in ast.walk(fn)
|
||||
if isinstance(n, ast.Call)
|
||||
and getattr(n.func, "id", None) == "record_retrieval"
|
||||
)
|
||||
# Every `if <cond>: return ...` after the search — whatever it tests.
|
||||
bailouts = [
|
||||
n.lineno for n in ast.walk(fn)
|
||||
if isinstance(n, ast.If) and n.lineno > search_at
|
||||
and any(isinstance(b, ast.Return) for b in n.body)
|
||||
]
|
||||
assert bailouts, (
|
||||
"no early return found after the search in build_tool_rule_hint — the "
|
||||
"guard has nothing left to protect, which means this test is now "
|
||||
"passing vacuously rather than the arm being correct"
|
||||
)
|
||||
assert logged_at < min(bailouts), (
|
||||
f"the pre-tool arm returns at line {min(bailouts)} before logging its "
|
||||
f"call at line {logged_at} — a surface with no rows at all cannot be "
|
||||
f"told apart from a hook that never fired (#3497)"
|
||||
)
|
||||
|
||||
|
||||
@@ -866,3 +929,161 @@ async def test_both_recorders_report_the_same_rules_for_one_call(
|
||||
"the fixture stopped exercising what it claims to; check the exclusion "
|
||||
"filter still runs before both recorders"
|
||||
)
|
||||
|
||||
|
||||
# ── A repeat is REFERENCED, not withheld (#3750) ──────────────────────────
|
||||
#
|
||||
# Both arms used to drop a hit already on the session's exclusion ledger and
|
||||
# emit nothing at all. That is right only while the session still HOLDS what it
|
||||
# was told — and it stops being right the moment a compaction summarizes the
|
||||
# earlier injection away while the id stays on the ledger, which leaves the
|
||||
# rule absent from context AND unreachable for the rest of the session.
|
||||
#
|
||||
# The tests below pin the emitted LINE, not the prose describing it, and both
|
||||
# arms are parametrized through one body so the two tails cannot drift apart —
|
||||
# #3497's history is that the pre-tool arm inherited a defect from its sibling
|
||||
# by being modelled on it rather than sharing with it.
|
||||
|
||||
_HELD = fake_rule(
|
||||
id=156,
|
||||
title="A wait with no deadline is a bug",
|
||||
statement="Every wait on something that can fail to answer carries a deadline.",
|
||||
when_to_apply="writing any call that crosses a process boundary",
|
||||
)
|
||||
_FRESH_TAIL = "not in this session's loaded set"
|
||||
_SEEN_TAIL = "You saw it earlier this session"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("source", "run"), _ARMS, ids=["write_path", "pre_tool"])
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_rule_already_on_the_ledger_still_produces_a_line(source, run):
|
||||
"""THE REGRESSION, stated as the thing that used to be absent.
|
||||
|
||||
Falsified against the old behaviour: before #3750 both arms filtered to
|
||||
`fresh` before rendering, so an all-excluded call returned an empty
|
||||
context and this assertion fails on `context == ""`.
|
||||
"""
|
||||
out = await run([(0.81, _HELD)], MagicMock(), exclude_rule_ids=[156])
|
||||
|
||||
# ON `get_rule(156)` RATHER THAN A TRUTHY CONTEXT. The write-path arm's
|
||||
# context also carries the prior-art menu, the staleness line and the shape
|
||||
# signals, so `assert out["context"]` is TRUE under the old behaviour and
|
||||
# would pin nothing on that arm while looking identical to a real check on
|
||||
# the other. The rule line is the only part of the string this changes.
|
||||
assert "get_rule(156)" in out["context"], (
|
||||
f"{source} emitted no rule line for a rule the session had already "
|
||||
f"been shown. Silence is only correct while the session still holds "
|
||||
f"the line — after a compaction it does not, and the id is still on "
|
||||
f"the ledger, so the rule is unreachable for the rest of the session. "
|
||||
f"Context was: {out['context']!r}"
|
||||
)
|
||||
assert _HELD.title in out["context"], (
|
||||
"the reference names no rule; a pull pointer with nothing attached "
|
||||
"gives a reader no way to judge whether it is worth pulling"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("source", "run"), _ARMS, ids=["write_path", "pre_tool"])
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_two_tails_are_distinguishable_and_say_the_true_one(source, run):
|
||||
"""One clause differs, and it is the clause that would otherwise be false.
|
||||
|
||||
A repeat rendered with the FRESH tail would assert "it is not in this
|
||||
session's loaded set" about a rule this session was handed twenty minutes
|
||||
ago — a line that is wrong in the one way a reader cannot check.
|
||||
"""
|
||||
fresh_ctx = (await run([(0.81, _HELD)], MagicMock()))["context"]
|
||||
seen_ctx = (await run([(0.81, _HELD)], MagicMock(),
|
||||
exclude_rule_ids=[156]))["context"]
|
||||
|
||||
assert _FRESH_TAIL in fresh_ctx and _SEEN_TAIL not in fresh_ctx, (
|
||||
f"{source} rendered a first surfacing with the repeat tail"
|
||||
)
|
||||
assert _SEEN_TAIL in seen_ctx and _FRESH_TAIL not in seen_ctx, (
|
||||
f"{source} told the session a rule it has already been shown is not "
|
||||
f"in its loaded set"
|
||||
)
|
||||
assert fresh_ctx != seen_ctx, "the two tails collapsed into one"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("source", "run"), _ARMS, ids=["write_path", "pre_tool"])
|
||||
@pytest.mark.asyncio
|
||||
async def test_neither_tail_injects_the_rule_statement(source, run):
|
||||
"""The budget, pinned on both branches.
|
||||
|
||||
A reference costs the same ~40 tokens as a first surfacing precisely
|
||||
because neither carries the statement. If a repeat ever starts inlining the
|
||||
body "since we are re-showing it anyway", this arm stops being cheap enough
|
||||
to always emit — and always emitting is the whole mechanism.
|
||||
"""
|
||||
for excluded in ([], [156]):
|
||||
ctx = (await run([(0.81, _HELD)], MagicMock(),
|
||||
exclude_rule_ids=excluded))["context"]
|
||||
assert _HELD.statement not in ctx, (
|
||||
f"{source} inlined the rule statement (excluded={excluded!r}); the "
|
||||
f"line carries title, trigger and a pull pointer and nothing more"
|
||||
)
|
||||
assert _HELD.title in ctx and "process boundary" in ctx, (
|
||||
"the line dropped the title or the trigger — those are what let a "
|
||||
"reader decide whether to pull without pulling"
|
||||
)
|
||||
|
||||
|
||||
# ── What a reference IS in the telemetry: nothing new (#3752) ─────────────
|
||||
#
|
||||
# THE RELATION, STATED BEFORE IT SHIPS. #3750 changes what is RENDERED and
|
||||
# nothing about what is COUNTED:
|
||||
#
|
||||
# result_count counts fresh surfacings — unchanged
|
||||
# suppressed_count counts repeats — unchanged
|
||||
# rule_usage counts fresh surfacings — unchanged
|
||||
#
|
||||
# A reference is a rendering decision, not a retrieval outcome. That answer is
|
||||
# not free: the naive implementation renders repeats by dropping the `fresh`
|
||||
# filter, which takes `suppressed_count` to zero everywhere — and #3739's
|
||||
# near-miss fix identifies repeat-caused zeros by `suppressed_count > 0`, so
|
||||
# the contamination corrected on 2026-09-08 would return by a different route,
|
||||
# in the same field, with the fix still sitting in the code not working.
|
||||
#
|
||||
# #3712 gave a reader `complete_from` for a counter that started late. Nothing
|
||||
# tells a reader a counter's DEFINITION moved. This test is the cheap version
|
||||
# of that guarantee: the claim "nothing moved" is only worth anything if it is
|
||||
# checkable, so it is asserted rather than described.
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("source", "run"), _ARMS, ids=["write_path", "pre_tool"])
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_reference_is_rendered_but_not_counted(source, run):
|
||||
"""The counters must read exactly as they did before #3750."""
|
||||
log, rec = MagicMock(), MagicMock()
|
||||
out = await run([(0.81, _HELD)], rec, retrieval_log=log,
|
||||
exclude_rule_ids=[156])
|
||||
|
||||
row = next(c for c in log.call_args_list
|
||||
if c.kwargs.get("source") == source).kwargs
|
||||
assert row["results"] == [], (
|
||||
f"{source} counted a referenced rule as a result. `result_count` "
|
||||
f"drives zero_result_calls and the whole threshold picture; a repeat "
|
||||
f"is not evidence the bar is set correctly."
|
||||
)
|
||||
assert row["suppressed"] == 1, (
|
||||
f"{source} stopped reporting the repeat as suppressed. #3739's "
|
||||
f"near_misses predicate excludes declines with suppressed_count > 0 — "
|
||||
f"if this reads 0, every repeat-caused zero is re-counted as a genuine "
|
||||
f"ranker rejection and the near-miss contamination returns."
|
||||
)
|
||||
assert rec.call_count == 0, (
|
||||
f"{source} recorded a surfacing for a rule it only referenced, which "
|
||||
f"inflates pull_through's denominator with a choice the arm never made"
|
||||
)
|
||||
assert out["rule_ids"] == [], (
|
||||
"a referenced id went back to the hook for the exclusion ledger; it is "
|
||||
"already there by definition, and returning it conflates 'shown fresh' "
|
||||
"with 'mentioned again'"
|
||||
)
|
||||
assert "get_rule(156)" in out["context"], (
|
||||
"guard is passing vacuously — no rule line was rendered, so 'rendered "
|
||||
"but not counted' is not what this run demonstrated. Truthiness of the "
|
||||
"whole context will not do: the write-path arm fills it from four "
|
||||
"other sources."
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user