fix(telemetry): both rule arms logged only their hits, so the clear-rate could only read 100% (#3497)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 22s
CI & Build / integration (push) Successful in 33s
CI & Build / Python tests (push) Failing after 48s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 22s
CI & Build / integration (push) Successful in 33s
CI & Build / Python tests (push) Failing after 48s
CI & Build / Build & push image (push) Skipped
`write_path_rule` reported `zero_result_calls: 0` and `cleared_threshold: 133/133` — a perfect record no other surface comes near (`write_path` 421 zeroes of 613, `reuse_slot` 124/199, `auto_inject` 114/326). #3311 read that as a measurement and milestone 333 was scoped on it. It was an artifact. Both arms called `record_retrieval` inside a guard on having results — the write-path arm behind `if fresh:`, the pre-tool arm below `if not fresh: return out` — so a call that found nothing wrote no row. The statistic was a fact about the shape of the code, true at any threshold whatsoever. The call log moves out of the guard in both arms. The surfacing log stays in it: nothing was shown, so no surfacing occurred. `results=fresh` is kept deliberately — the note arms pass exclusions into `semantic_search_notes`, so what they log is already post-exclusion, and logging `hits` here would make this row mean something other than every other row in the same readout. The defect bites hardest on the pre-tool arm, which fires on every Bash call: with no rows at all, a ranker that declined is indistinguishable from a hook that never fired — the silent failure the arm exists to stop. Tests cover both arms behaviourally (found nothing; found only what the session already held; searched nothing at all, which must stay silent) plus a structural guard, because this was one level of indentation and it appeared independently in two places. #3311 and the `rule_usage` docstring corrected rather than quietly rewritten. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011cPyzNnegXHr5iRMzzy5KJ
This commit is contained in:
+136
-11
@@ -47,12 +47,14 @@ _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):
|
||||
def _arm_patches(pc, hits, recorder, prior_art=None, cfg=None, rule_search=None,
|
||||
retrieval_log=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.
|
||||
`cfg`, `rule_search` and `retrieval_log` are overridable so a caller can
|
||||
inspect what the arm ASKED for, and what it told the CALL log, 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",
|
||||
@@ -66,7 +68,7 @@ def _arm_patches(pc, hits, recorder, prior_art=None, cfg=None, rule_search=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_retrieval", retrieval_log or MagicMock()),
|
||||
patch.object(pc, "record_surfaced", MagicMock()),
|
||||
patch.object(pc, "record_rule_surfaced", recorder),
|
||||
patch.object(pc, "owner_names_for", AsyncMock(return_value={})),
|
||||
@@ -74,10 +76,11 @@ def _arm_patches(pc, hits, recorder, prior_art=None, cfg=None, rule_search=None)
|
||||
)
|
||||
|
||||
|
||||
async def _run_arm(hits, recorder, prior_art=None, **kwargs):
|
||||
async def _run_arm(hits, recorder, prior_art=None, retrieval_log=None, **kwargs):
|
||||
from scribe.services import plugin_context as pc
|
||||
with ExitStack() as stack:
|
||||
for ctx in _arm_patches(pc, hits, recorder, prior_art):
|
||||
for ctx in _arm_patches(pc, hits, recorder, prior_art,
|
||||
retrieval_log=retrieval_log):
|
||||
stack.enter_context(ctx)
|
||||
return await pc.build_write_path_hint(
|
||||
1, "frontend/src/api/client.ts", code="x" * 400, **kwargs
|
||||
@@ -410,7 +413,7 @@ def test_the_marker_paths_stay_silent():
|
||||
# is why they all had to be resident. These cover the surface that changes it.
|
||||
|
||||
|
||||
def _tool_patches(pc, hits, recorder, cfg=None):
|
||||
def _tool_patches(pc, hits, recorder, cfg=None, retrieval_log=None):
|
||||
return (
|
||||
patch.object(pc, "get_writepath_config",
|
||||
AsyncMock(return_value=cfg or {
|
||||
@@ -418,16 +421,16 @@ def _tool_patches(pc, hits, recorder, cfg=None):
|
||||
"top_k": 3, "rule_threshold": 0.6,
|
||||
})),
|
||||
patch.object(pc, "semantic_search_rules", AsyncMock(return_value=hits)),
|
||||
patch.object(pc, "record_retrieval", MagicMock()),
|
||||
patch.object(pc, "record_retrieval", retrieval_log or MagicMock()),
|
||||
patch.object(pc, "record_rule_surfaced", recorder),
|
||||
)
|
||||
|
||||
|
||||
async def _run_tool_arm(hits, recorder, command="curl -s https://git.example/api/v1/runs",
|
||||
tool="Bash", **kwargs):
|
||||
tool="Bash", retrieval_log=None, **kwargs):
|
||||
from scribe.services import plugin_context as pc
|
||||
with ExitStack() as stack:
|
||||
for ctx in _tool_patches(pc, hits, recorder):
|
||||
for ctx in _tool_patches(pc, hits, recorder, retrieval_log=retrieval_log):
|
||||
stack.enter_context(ctx)
|
||||
return await pc.build_tool_rule_hint(1, tool, command, **kwargs)
|
||||
|
||||
@@ -577,3 +580,125 @@ def test_the_hook_and_the_route_agree_on_every_parameter_name():
|
||||
assert f'request.args.get("{arg}")' in handler, (
|
||||
f"the hook sends {arg!r} and the route never reads it"
|
||||
)
|
||||
|
||||
|
||||
# ── The CALL log is unconditional; the SURFACING log is not (#3497) ────
|
||||
#
|
||||
# Both arms used to write their retrieval_logs row inside a guard on having
|
||||
# results, so `zero_result_calls` was pinned at 0 and `cleared_threshold` at
|
||||
# `calls` by the shape of the code — at any threshold whatsoever. #3311 read
|
||||
# that as a measurement of the corpus and a milestone was scoped on it.
|
||||
#
|
||||
# The distinction these tests hold: a CALL happened whether or not it found
|
||||
# anything, and the calls that found nothing are the only evidence a threshold
|
||||
# is set too high. A SURFACING did not happen when nothing was shown.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_write_path_arm_logs_the_call_that_found_nothing():
|
||||
log, rec = MagicMock(), MagicMock()
|
||||
await _run_arm([], rec, retrieval_log=log)
|
||||
|
||||
rule_calls = [c for c in log.call_args_list
|
||||
if c.kwargs.get("source") == "write_path_rule"]
|
||||
assert len(rule_calls) == 1, (
|
||||
"a rule call that found nothing wrote no row — `zero_result_calls` can "
|
||||
"then only ever read 0, however badly the threshold is tuned"
|
||||
)
|
||||
assert rule_calls[0].kwargs["results"] == []
|
||||
rec.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_write_path_arm_logs_a_call_whose_only_hit_was_already_shown():
|
||||
"""The subtler half. The ranker DID find something; the session had already
|
||||
been told. That is a decline from the reader's side and must be logged as
|
||||
one — the note arms get this for free by passing exclusions into the search,
|
||||
so their zero-result rows already include this case."""
|
||||
log, rec = MagicMock(), MagicMock()
|
||||
hits = [(0.71, fake_rule(id=156, title="A wait with no deadline is a bug"))]
|
||||
await _run_arm(hits, rec, retrieval_log=log, exclude_rule_ids=[156])
|
||||
|
||||
rule_calls = [c for c in log.call_args_list
|
||||
if c.kwargs.get("source") == "write_path_rule"]
|
||||
assert len(rule_calls) == 1
|
||||
assert rule_calls[0].kwargs["results"] == [], (
|
||||
"the row must record what the arm could SHOW, so this row is comparable "
|
||||
"with an auto_inject row, whose exclusions are applied by the search"
|
||||
)
|
||||
rec.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_tool_arm_logs_the_call_that_found_nothing():
|
||||
"""It matters more here than on the sibling. This arm fires on every Bash
|
||||
call, so an empty `sources` row is the normal outcome — and with no row at
|
||||
all, "the ranker declined" is indistinguishable from "the hook never fired",
|
||||
which is exactly the silent failure the arm was built to stop (#3476)."""
|
||||
log, rec = MagicMock(), MagicMock()
|
||||
out = await _run_tool_arm([], rec, retrieval_log=log)
|
||||
|
||||
assert out == {"context": "", "rule_ids": []}
|
||||
assert log.call_count == 1
|
||||
assert log.call_args.kwargs["source"] == "pre_tool_rule"
|
||||
assert log.call_args.kwargs["results"] == []
|
||||
assert log.call_args.kwargs["query"] == "curl -s https://git.example/api/v1/runs", (
|
||||
"the query is the point of the row: it is what a threshold is tuned against"
|
||||
)
|
||||
rec.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_tool_arm_logs_a_call_whose_only_hit_was_already_shown():
|
||||
log, rec = MagicMock(), MagicMock()
|
||||
hits = [(0.71, fake_rule(id=161, title="Reach the forge through its MCP tools"))]
|
||||
out = await _run_tool_arm(hits, rec, retrieval_log=log, exclude_rule_ids=[161])
|
||||
|
||||
assert out["rule_ids"] == []
|
||||
assert log.call_count == 1
|
||||
assert log.call_args.kwargs["results"] == []
|
||||
rec.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_command_the_arm_never_searched_writes_no_row_at_all():
|
||||
"""The one case that must stay silent, and the boundary of the rule above.
|
||||
|
||||
A blank command costs no embedding query, so there was no retrieval to log.
|
||||
A row here would report a call that never happened and drag the clear-rate
|
||||
down with phantom declines — the mirror of the defect, from the other side.
|
||||
"""
|
||||
from scribe.services import plugin_context as pc
|
||||
|
||||
log = MagicMock()
|
||||
with ExitStack() as stack:
|
||||
for ctx in _tool_patches(pc, [], MagicMock(), retrieval_log=log):
|
||||
stack.enter_context(ctx)
|
||||
await pc.build_tool_rule_hint(1, "Bash", " ")
|
||||
|
||||
log.assert_not_called()
|
||||
|
||||
|
||||
def test_neither_rule_arm_logs_its_call_behind_a_results_guard():
|
||||
"""Structural, on top of the behavioural pair above, because the defect was
|
||||
one level of indentation and it appeared INDEPENDENTLY in two places — the
|
||||
pre-tool arm inherited it by being modelled on its sibling. The third arm
|
||||
modelled on either of them is the one this catches.
|
||||
"""
|
||||
pc_src = Path("src/scribe/services/plugin_context.py").read_text()
|
||||
|
||||
# Write-path arm: what remains inside `if fresh:` is the SURFACING log only.
|
||||
guarded = pc_src.split('source="write_path_rule", query=code or path')[1]
|
||||
guarded = guarded.split("if fresh:")[1].split("except Exception:")[0]
|
||||
assert "record_rule_surfaced" in guarded, "the surfacing log must stay guarded"
|
||||
assert "record_retrieval" not in guarded, (
|
||||
"the call log is back inside the results guard — a call that found "
|
||||
"nothing is the only evidence a threshold is set too high"
|
||||
)
|
||||
|
||||
# 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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user