Files
FabledScribe/tests/test_rule_usage_wiring.py
T
bvandeusenandClaude Opus 5 238510080e
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 31s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 35s
feat(retrieval): the standing-rule arm gets its own bar, and asks for one rule not two (#3318)
Milestone 333 step 4 — the split #2223 made one surface down, now made for the
third corpus. The arm inherited WRITEPATH_DEFAULT_THRESHOLD = 0.68, a number
measured against code-vs-note-PROSE and never re-derived for code-vs-RULE-TEXT.

THE DEFAULT IS ARGUED STRUCTURALLY, NOT READ OFF A HISTOGRAM (rule 115). Two
facts hold on any install, including one with six rules and no telemetry:

- The eligible corpus is tiny — conditional rules only, a handful to a few
  dozen against thousands of notes. A top-k over forty candidates always
  returns something, so "the best match cleared the bar" stops meaning "a good
  match exists". A bar calibrated for best-of-thousands is cleared by
  best-of-forty as arithmetic, not relevance.
- Rules are short imperative technical English, far more homogeneous than note
  prose. #2223 put the code-vs-prose floor at 0.55-0.63 and set 0.68 above it;
  a more homogeneous corpus has a HIGHER floor, so 0.68 is not merely
  inherited, it sits below where this corpus's noise lives.

0.72 errs deliberately toward silence on an asymmetry that is also structural:
this hint fires on EVERY write. A missed rule is recoverable — it is still in
Scribe and the agent can search it. A hint that cries wolf is not: it teaches
the reader to skip the whole block, and the true positives go with it. The
arm's own comment already said "noise on a hint that fires on every write is
how a hint gets ignored".

Pinned as an INEQUALITY, not a value: test_the_rule_bar_defaults_above_the_code_bar
asserts RULEHINT > WRITEPATH, so tuning the number stays free while inverting
the relationship — which would silently reinstate #3311 — does not.

RULEHINT_LIMIT = 1, and deliberately not a knob. With a corpus this small, k=2
means the second line is almost always the second-best noise wearing the same
confident framing as the first; halving k halves that regardless of the bar.
It stays a constant because it is a decision about how loud one hint may be,
not a per-install tuning question — and a knob nobody turns only adds a way to
misconfigure the surface.

Reachable from Settings, no restart (rule 25), with copy that says which way to
move it and points at retrieval_telemetry's rule pull-through — which step 3
made readable — to tell "arriving unread" from "never arrived".

Every config stand-in in the suite gained the key, not just the one that
noticed. The arm reads `rule_threshold` while BUILDING its search arguments, so
a missing key raises inside its fail-open except and turns the arm into a
silent no-op — indistinguishable from it running and finding nothing. That is
the same vacuous-pass shape that bit step 2, one layer down (rule 33).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcCs1CcQ1ormdnzSshKqvN
2026-09-02 18:05:00 -04:00

277 lines
12 KiB
Python

"""Both ends of the rule-usage loop are actually wired (milestone 333 step 2).
Step 1 built the table and the service. A counter nobody calls reads zero and
looks exactly like a surface nobody uses — which is #2663's shape and the whole
reason this milestone exists. So this file is about the CALL SITES, not the
storage.
Cross-cutting on purpose: the surfaced end lives in `plugin_context`, the pull
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.
"""
from contextlib import ExitStack
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from tests.helpers import fake_note, fake_rule
# The MCP tool layer reads its caller from a ContextVar the HTTP transport sets
# per request; a unit test has no request, so it binds the caller itself. The
# arm tests do not need it — build_write_path_hint takes user_id directly — but
# the module-level mark is how every tool-layer test file in this repo opts in.
pytestmark = pytest.mark.usefixtures("_bind_user")
# ── The surfaced end ───────────────────────────────────────────────────
#
# conftest's autouse `_no_rule_arm` stubs `semantic_search_rules` so unrelated
# plugin-context tests don't pull a real embedding model through this arm. Its
# docstring says a test that wants the arm live can re-patch it — that is what
# each of these does.
# The write-path hint returns early when a write matched nothing at all — no
# staleness, no synced record, no prior-art menu, no shape signal. The rule arm
# sits deliberately on the FAR side of that guard, because it runs a semantic
# search and moving it above would mean an embedding query on every write in
# the session (#3311's closing note, and the reason its gating is a separate
# question from precision).
#
# So a fixture that stubs every other arm to empty never reaches the rule arm
# at all — which is what the first run of this file did. The note hit below is
# not decoration: it is the condition the arm requires in order to fire.
_PRIOR_ART = [(0.72, fake_note(id=9, title="debounce helper", user_id=1,
note_type="snippet"))]
def _arm_patches(pc, hits, recorder, prior_art=None, cfg=None, rule_search=None):
"""The minimum stubbing that lets the rule arm run and nothing else.
`cfg` and `rule_search` are overridable so a caller can inspect what the
arm ASKED for rather than only what it did with the answer — patching them
a second time on top would work, but reads as an accident.
"""
return (
patch.object(pc, "get_writepath_config",
AsyncMock(return_value=cfg or {
"enabled": True, "threshold": 0.6,
"top_k": 3, "rule_threshold": 0.6,
})),
patch.object(pc.snippets_svc, "list_snippets", AsyncMock(return_value=([], 0))),
patch.object(pc, "semantic_search_notes",
AsyncMock(return_value=_PRIOR_ART if prior_art is None
else prior_art)),
patch.object(pc, "semantic_search_rules",
rule_search or AsyncMock(return_value=hits)),
patch.object(pc, "record_retrieval", MagicMock()),
patch.object(pc, "record_surfaced", MagicMock()),
patch.object(pc, "record_rule_surfaced", recorder),
patch.object(pc, "owner_names_for", AsyncMock(return_value={})),
patch.object(pc, "concept_query", MagicMock(return_value="a deadline on a fetch")),
)
async def _run_arm(hits, recorder, prior_art=None, **kwargs):
from scribe.services import plugin_context as pc
with ExitStack() as stack:
for ctx in _arm_patches(pc, hits, recorder, prior_art):
stack.enter_context(ctx)
return await pc.build_write_path_hint(
1, "frontend/src/api/client.ts", code="x" * 400, **kwargs
)
@pytest.mark.asyncio
async def test_the_arm_records_what_it_showed():
"""The claim being measured. Without this call the arm keeps producing
scores in retrieval_logs and no evidence that any hint was ever read."""
rec = MagicMock()
hits = [(0.71, fake_rule(id=156, title="A wait with no deadline is a bug"))]
await _run_arm(hits, rec)
assert rec.call_count == 1
kw = rec.call_args.kwargs
assert kw["rule_ids"] == [156]
assert kw["source"] == "write_path_rule"
@pytest.mark.asyncio
async def test_a_rule_the_session_already_holds_is_not_counted_as_surfaced():
"""`exclude_rule_ids` drops what the session already has, and the recorded
set must be what was SHOWN, not what was considered.
Counting the excluded ones would inflate the denominator with claims the
agent never saw — the ratio would fall for a reason that has nothing to do
with whether the hints landed, which is precisely the misreading this
milestone exists to prevent.
"""
rec = MagicMock()
hits = [
(0.71, fake_rule(id=156, title="A wait with no deadline is a bug")),
(0.70, fake_rule(id=157, title="A loop re-arms in a finally")),
]
await _run_arm(hits, rec, exclude_rule_ids=[157])
assert rec.call_args.kwargs["rule_ids"] == [156]
@pytest.mark.asyncio
async def test_nothing_is_recorded_when_every_hit_was_already_held():
"""No surfacing happened, so no surfacing is recorded. A zero-row batch
would still be a call, and a call that says "we showed nothing" pollutes
the count of times the arm spoke."""
rec = MagicMock()
hits = [(0.71, fake_rule(id=156, title="A wait with no deadline is a bug"))]
await _run_arm(hits, rec, exclude_rule_ids=[156])
assert rec.call_count == 0
@pytest.mark.asyncio
async def test_the_arm_searches_on_its_OWN_bar_not_the_code_one():
"""The consuming half of step 4. `get_writepath_config` assembling a
separate `rule_threshold` means nothing if the arm still passes
`cfg["threshold"]` to its search — the split would exist in the config and
not in the behaviour, and #3311 would be exactly where it was.
The two values are deliberately different here so the assertion can tell
them apart.
"""
from scribe.services import plugin_context as pc
search = AsyncMock(return_value=[])
with ExitStack() as stack:
for ctx in _arm_patches(
pc, [], MagicMock(), rule_search=search,
cfg={"enabled": True, "threshold": 0.60,
"top_k": 3, "rule_threshold": 0.81},
):
stack.enter_context(ctx)
await pc.build_write_path_hint(
1, "frontend/src/api/client.ts", code="x" * 400,
)
kw = search.await_args.kwargs
assert kw["threshold"] == 0.81, "the arm is still using the code threshold"
assert kw["limit"] == pc.RULEHINT_LIMIT
assert kw["tier"] == "conditional"
@pytest.mark.asyncio
async def test_the_arm_does_not_fire_on_a_write_that_matched_nothing():
"""The gate, pinned — because the fixture above now depends on it and a
silent change would make every other test here pass vacuously.
A write matching no prior art returns before the rule arm runs. That is
deliberate: the arm is a semantic search, and ungating it means an
embedding query on every write in the session. #3311 is explicit that the
gate stays until the arm's precision is fixed, so this failing is a signal
to go read that issue rather than to update the assertion.
"""
rec = MagicMock()
hits = [(0.71, fake_rule(id=156, title="A wait with no deadline is a bug"))]
await _run_arm(hits, rec, prior_art=[])
assert rec.call_count == 0
@pytest.mark.asyncio
async def test_a_failing_recorder_does_not_break_the_write():
"""Telemetry must never take down the surface it observes. The arm is
already wrapped in a fail-open try/except; this pins that the new call is
INSIDE it rather than after."""
rec = MagicMock(side_effect=RuntimeError("telemetry is down"))
hits = [(0.71, fake_rule(id=156, title="A wait with no deadline is a bug"))]
out = await _run_arm(hits, rec)
assert "context" in out
# ── The pull end ───────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_mcp_get_rule_records_an_agent_pull():
"""THE pull that matters: the arm's own message ends "Read it with
get_rule(N)", so this is the exact action a landed hint produces."""
rec = MagicMock()
rule = fake_rule(id=156, title="A wait with no deadline is a bug")
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule",
AsyncMock(return_value=rule)), \
patch("scribe.mcp.tools.rulebooks.rulebooks_svc.rule_detail",
AsyncMock(return_value={"id": 156})), \
patch("scribe.mcp.tools.rulebooks.record_rule_pulled", rec):
from scribe.mcp.tools.rulebooks import get_rule
await get_rule(rule_id=156)
assert rec.call_args.kwargs["rule_id"] == 156
assert rec.call_args.kwargs["source"] == "mcp_get_rule"
@pytest.mark.asyncio
async def test_a_rule_that_cannot_be_read_is_not_a_pull():
"""Recorded after the access check. A refused read is not a pull, and
counting it would credit the arm for a hint nobody could open."""
rec = MagicMock()
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule",
AsyncMock(return_value=None)), \
patch("scribe.mcp.tools.rulebooks.record_rule_pulled", rec):
from scribe.mcp.tools.rulebooks import get_rule
with pytest.raises(ValueError):
await get_rule(rule_id=156)
assert rec.call_count == 0
# ── Completeness: every door, and only the doors ───────────────────────
def _source_of(module_path: str) -> str:
return (Path(__file__).resolve().parents[1] / module_path).read_text()
def test_every_rule_detail_door_records_a_pull():
"""The task's own warning, made mechanical: miss a door and the ratio
reads low for a reason that is not about the rules.
Source inspection rather than behaviour, because the REST door has no
live-HTTP harness in the unit lane (see test_routes_rulebooks.py's own
note). What it can still prove is that the handler names the recorder —
which is the thing that gets forgotten when a door is added.
"""
rest = _source_of("src/scribe/routes/rulebooks.py")
mcp = _source_of("src/scribe/mcp/tools/rulebooks.py")
assert 'source="rest_rule"' in rest, (
"the REST rule-detail route does not record a pull"
)
assert 'source="mcp_get_rule"' in mcp, (
"the MCP get_rule tool does not record a pull"
)
def test_the_bulk_loaders_are_not_counted_as_pulls():
"""`list_always_on_rules` and `enter_project` hand over every applicable
rule at once. That is delivery, not somebody choosing to open one record,
and counting it would swamp the signal with exactly the ambient surfacing
the ratio exists to distinguish from.
Stated as a test because it is the tempting addition: both put rules in
front of an agent, so "surely those are pulls too" is the reading someone
arrives at without the argument.
"""
for path in ("src/scribe/mcp/tools/rulebooks.py",
"src/scribe/mcp/tools/projects.py"):
src = _source_of(path)
for door in ("list_always_on_rules", "enter_project"):
if f"async def {door}" not in src:
continue
body = src.split(f"async def {door}", 1)[1].split("\nasync def ", 1)[0]
assert "record_rule_pulled" not in body, (
f"{door} records a pull. It is a bulk resident load — every "
"applicable rule at once — so counting it would drown the "
"surfaced:pulled ratio in ambient delivery."
)