Merge pull request 'fix(telemetry): both rule arms logged only their hits, so the clear-rate could only read 100% (#3497)' (#138) from dev into main
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 30s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m5s
CI & Build / Build & push image (push) Successful in 17s

This commit was merged in pull request #138.
This commit is contained in:
2026-09-03 07:19:08 -04:00
4 changed files with 216 additions and 43 deletions
+51 -23
View File
@@ -1218,24 +1218,43 @@ async def build_write_path_hint(
"does not apply; it is not in this session's loaded set." "does not apply; it is not in this session's loaded set."
) )
rule_ids.append(rule.id) rule_ids.append(rule.id)
# TWO tables, and the split is not arbitrary. retrieval_logs is one
# row per CALL, keyed on the score distribution a threshold is tuned
# from. rule_usage_events is one row per RULE per event, which is the
# grain "was this hint ever acted on" needs and the grain a JSONB
# result_ids array cannot be indexed at.
#
# This comment used to say rule ids had nowhere to go — that
# note_usage_events remaps ids on restore, so a rule id there would
# return attached to whatever note took that number. That is still
# true of the NOTE table, and it is exactly why rule_usage_events is
# its own (milestone 333 step 1). The gap it described is closed.
#
# THE CALL LOG IS UNCONDITIONAL; THE SURFACING LOG IS NOT, and the
# asymmetry is the correction #3497 exists to make. Both used to sit
# inside an `if fresh:`, which is how this arm came to report
# `zero_result_calls: 0` and `cleared_threshold: 133/133` — not a
# perfectly tuned surface but one structurally unable to record its
# own misses. #3311 read that artifact as a measurement and a whole
# milestone was scoped on it. A call that found nothing is the ONLY
# evidence a threshold is set too high, and it is the row every note
# surface has always written (write_path: 421 zeroes of 613 calls;
# auto_inject: 114 of 326). A SURFACING is different in kind: nothing
# was shown, so no such event occurred, and its log stays guarded.
#
# `results=fresh`, not `hits`: the note arms pass their exclusions
# INTO semantic_search_notes, so what they log is already
# post-exclusion. semantic_search_rules takes no such parameter and
# this filter is where the equivalent happens — logging `hits` would
# quietly make this row mean something other than every other row in
# the same readout.
record_retrieval(
user_id=user_id, source="write_path_rule", query=code or path,
threshold=cfg["rule_threshold"], limit=RULEHINT_LIMIT,
project_id=project_id,
is_task=None, results=fresh, duration_ms=rule_ms,
)
if fresh: if fresh:
# TWO tables, and the split is not arbitrary. retrieval_logs is one
# row per CALL, keyed on the score distribution a threshold is
# tuned from. rule_usage_events is one row per RULE per event,
# which is the grain "was this hint ever acted on" needs and the
# grain a JSONB result_ids array cannot be indexed at.
#
# This comment used to say rule ids had nowhere to go — that
# note_usage_events remaps ids on restore, so a rule id there would
# return attached to whatever note took that number. That is still
# true of the NOTE table, and it is exactly why rule_usage_events
# is its own (milestone 333 step 1). The gap it described is closed.
record_retrieval(
user_id=user_id, source="write_path_rule", query=code or path,
threshold=cfg["rule_threshold"], limit=RULEHINT_LIMIT,
project_id=project_id,
is_task=None, results=fresh, duration_ms=rule_ms,
)
# `rule_ids` is `fresh`, i.e. AFTER exclude_rule_ids. A rule the # `rule_ids` is `fresh`, i.e. AFTER exclude_rule_ids. A rule the
# session already holds was considered and not shown, and counting # session already holds was considered and not shown, and counting
# it would inflate the denominator with claims the agent never saw # it would inflate the denominator with claims the agent never saw
@@ -1319,6 +1338,21 @@ async def build_tool_rule_hint(
already = set(exclude_rule_ids or []) already = set(exclude_rule_ids or [])
fresh = [(score, rule) for score, rule in hits if rule.id not in already] fresh = [(score, rule) for score, rule in hits if rule.id not in already]
# Logged BEFORE the early return, for the reason spelled out at length
# on the write-path arm above: a call that found nothing is the only
# evidence a threshold is too high, and an arm that logs only the calls
# it liked reports a flawless clear-rate however badly it is tuned.
# This arm shipped with the same defect inherited from its sibling, and
# it mattered more here — a surface with no rows at all cannot be told
# apart from a hook that never fired, which is precisely the silent
# failure the arm was built to stop.
record_retrieval(
user_id=user_id, source="pre_tool_rule", query=query,
threshold=cfg["rule_threshold"], limit=RULEHINT_LIMIT,
project_id=project_id,
is_task=None, results=fresh, duration_ms=duration_ms,
)
if not fresh: if not fresh:
return out return out
@@ -1335,12 +1369,6 @@ async def build_tool_rule_hint(
) )
rule_ids.append(rule.id) rule_ids.append(rule.id)
record_retrieval(
user_id=user_id, source="pre_tool_rule", query=query,
threshold=cfg["rule_threshold"], limit=RULEHINT_LIMIT,
project_id=project_id,
is_task=None, results=fresh, duration_ms=duration_ms,
)
# RANKED, not ambient: this arm chose what it showed, so a pull can # RANKED, not ambient: this arm chose what it showed, so a pull can
# settle whether the choice was any good. `rule_usage.RANKED_SOURCES` # settle whether the choice was any good. `rule_usage.RANKED_SOURCES`
# carries the same name. # carries the same name.
+17 -5
View File
@@ -12,11 +12,23 @@ Two event streams, deliberately independent:
WHY THIS ARM AND NOT ANOTHER. Every other surface declines most of the time — 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 `write_path` returns nothing on 78% of calls, `reuse_slot` on 79%, auto-inject
on 39%. The rule arm has never once returned nothing (#3311). That is either a on 39%. The rule arm APPEARED never to have returned nothing (#3311), and this
perfectly tuned surface or a bar it cannot fail to clear, and `retrieval_logs` docstring used to put that forward as the puzzle worth measuring: "either a
cannot tell the two apart: it records what the ranker scored, never whether the perfectly tuned surface or a bar it cannot fail to clear".
hint was any use. The ratio these two streams produce is the missing half, and
without it any threshold change is a number picked off a histogram. 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.
Design notes, mirroring `note_usage`: Design notes, mirroring `note_usage`:
- Writes are fire-and-forget through `background.spawn`, so telemetry never - Writes are fire-and-forget through `background.spawn`, so telemetry never
+136 -11
View File
@@ -47,12 +47,14 @@ _PRIOR_ART = [(0.72, fake_note(id=9, title="debounce helper", user_id=1,
note_type="snippet"))] 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. """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 `cfg`, `rule_search` and `retrieval_log` are overridable so a caller can
arm ASKED for rather than only what it did with the answer — patching them inspect what the arm ASKED for, and what it told the CALL log, rather than
a second time on top would work, but reads as an accident. only what it did with the answer — patching them a second time on top would
work, but reads as an accident.
""" """
return ( return (
patch.object(pc, "get_writepath_config", 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)), else prior_art)),
patch.object(pc, "semantic_search_rules", patch.object(pc, "semantic_search_rules",
rule_search or AsyncMock(return_value=hits)), 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_surfaced", MagicMock()),
patch.object(pc, "record_rule_surfaced", recorder), patch.object(pc, "record_rule_surfaced", recorder),
patch.object(pc, "owner_names_for", AsyncMock(return_value={})), 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 from scribe.services import plugin_context as pc
with ExitStack() as stack: 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) stack.enter_context(ctx)
return await pc.build_write_path_hint( return await pc.build_write_path_hint(
1, "frontend/src/api/client.ts", code="x" * 400, **kwargs 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. # 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 ( return (
patch.object(pc, "get_writepath_config", patch.object(pc, "get_writepath_config",
AsyncMock(return_value=cfg or { AsyncMock(return_value=cfg or {
@@ -418,16 +421,16 @@ def _tool_patches(pc, hits, recorder, cfg=None):
"top_k": 3, "rule_threshold": 0.6, "top_k": 3, "rule_threshold": 0.6,
})), })),
patch.object(pc, "semantic_search_rules", AsyncMock(return_value=hits)), 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), patch.object(pc, "record_rule_surfaced", recorder),
) )
async def _run_tool_arm(hits, recorder, command="curl -s https://git.example/api/v1/runs", 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 from scribe.services import plugin_context as pc
with ExitStack() as stack: 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) stack.enter_context(ctx)
return await pc.build_tool_rule_hint(1, tool, command, **kwargs) 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, ( assert f'request.args.get("{arg}")' in handler, (
f"the hook sends {arg!r} and the route never reads it" 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"
)
+12 -4
View File
@@ -360,10 +360,18 @@ async def test_telemetry_uses_its_own_source():
patch.object(pc, "record_retrieval", rec), \ patch.object(pc, "record_retrieval", rec), \
patch.object(pc, "owner_names_for", AsyncMock(return_value={})): patch.object(pc, "owner_names_for", AsyncMock(return_value={})):
await pc.build_write_path_hint(1, "src/x.py", code=REAL_CODE, project_id=4) await pc.build_write_path_hint(1, "src/x.py", code=REAL_CODE, project_id=4)
rec.assert_called_once() sources = [c.kwargs["source"] for c in rec.call_args_list]
assert rec.call_args.kwargs["source"] == "write_path" assert sources.count("write_path") == 1, sources
assert rec.call_args.kwargs["source"] != "auto_inject" assert "auto_inject" not in sources
assert rec.call_args.kwargs["project_id"] == 4
note_arm = next(c for c in rec.call_args_list if c.kwargs["source"] == "write_path")
assert note_arm.kwargs["project_id"] == 4
# The rule arm rides along on the same hint and logs its own call even when
# it finds nothing (#3497). This assertion used to be `assert_called_once`,
# which passed only because that row was never written — the test encoded
# the defect. The second row is the point of having two sources.
assert "write_path_rule" in sources, sources
@pytest.mark.asyncio @pytest.mark.asyncio