find_callers returns a structural zero with no disclosure, while search_symbols discloses the same fact fully #99

Closed
opened 2026-09-04 11:42:21 +02:00 by buildagent · 1 comment
Member

Found during #84 Phase 2 by the SDK lane, and reproduced independently.

The asymmetry

A const, static, module or impl is used as a bare identifier, and the extractors do not record a reference kind that captures such a use. So a zero on one of those symbols is structural — the population was empty and nothing could ever have been counted — not a measurement that nothing uses it.

search_symbols says exactly that, at length:

"name_fallback_unmeasured": "no_use_reference_channel"
note: "(basis: unmeasurable): no USE-BEARING reference row bears that name, AND this
index records no reference kind that would capture a USE of a symbol of that kind and
language (a `const`, `static`, `module` or `impl` is used as a bare identifier, which
the extractors do not record; an `import` naming it is not a use). The counter had an
empty population, so a `0` would have been structural rather than measured. ...
`ref_count: 0` is not evidence of dead code."

find_callers on the same symbol says nothing:

find_callers(symbol_id=393542)   // GUEST_SRC_OFFSET, a `const`
{"results":[],"total":0,"total_resolved":0,"next_cursor":null,
 "confidence":{"graph_semantics":"static in-project evidence; unresolved, external,
  and dynamic uses may be absent", ...}}

The confidence block is generic — it is the same text for a function with genuinely no callers. Nothing in the reply distinguishes "nothing calls this" from "a const cannot have callers and this tool cannot answer the question you asked".

Why this matters more than it looks

This is the project's own central rule — which zeros are uninformative — applied on one tool and not its neighbour. An agent that has learned to trust name_fallback_unmeasured from search_symbols will read find_callers' bare total: 0 as the measured absence it is not, and conclude the symbol is dead. search_symbols explicitly warns against that conclusion; find_callers invites it.

It is also the exact shape of the defect closed in #97: a fact the system knows, disclosed on one surface and silent on another, where the silent one looks like a normal answer.

What the right answer looks like

find_callers already knows the symbol's kind and language, which is all search_symbols uses to decide. When the target is a kind with no use-reference channel, the reply should say so and name the tool that CAN answer — find_references, which spans ref kinds that are not calls — rather than returning an unqualified empty list.

Suggested shape, reusing the existing vocabulary rather than inventing a second one:

{"results":[],"total":0,
 "callers_unmeasured":"no_use_reference_channel",
 "hint":"`GUEST_SRC_OFFSET` is a `const`: this index records no reference kind that
  captures a call to one, so this 0 is structural and not evidence that nothing uses
  it. Use `find_references` for the channels that can see it."}

Absent when it does not apply, present when it does — so a bare total: 0 keeps meaning "measured, and nothing calls it".

Check the neighbours too

find_callees and change_impact seed from the same graph and may carry the same silence for the same kinds. Whatever shape is chosen should be applied wherever the population can be structurally empty, not patched onto find_callers alone — the fix for #97 covered all 23 tools by construction for exactly this reason.

Found during #84 Phase 2 by the SDK lane, and reproduced independently. ## The asymmetry A `const`, `static`, `module` or `impl` is used as a bare identifier, and the extractors do not record a reference kind that captures such a use. So a zero on one of those symbols is **structural** — the population was empty and nothing could ever have been counted — not a measurement that nothing uses it. `search_symbols` says exactly that, at length: ``` "name_fallback_unmeasured": "no_use_reference_channel" note: "(basis: unmeasurable): no USE-BEARING reference row bears that name, AND this index records no reference kind that would capture a USE of a symbol of that kind and language (a `const`, `static`, `module` or `impl` is used as a bare identifier, which the extractors do not record; an `import` naming it is not a use). The counter had an empty population, so a `0` would have been structural rather than measured. ... `ref_count: 0` is not evidence of dead code." ``` `find_callers` on the **same symbol** says nothing: ``` find_callers(symbol_id=393542) // GUEST_SRC_OFFSET, a `const` {"results":[],"total":0,"total_resolved":0,"next_cursor":null, "confidence":{"graph_semantics":"static in-project evidence; unresolved, external, and dynamic uses may be absent", ...}} ``` The `confidence` block is generic — it is the same text for a function with genuinely no callers. Nothing in the reply distinguishes **"nothing calls this"** from **"a const cannot have callers and this tool cannot answer the question you asked"**. ## Why this matters more than it looks This is the project's own central rule — *which zeros are uninformative* — applied on one tool and not its neighbour. An agent that has learned to trust `name_fallback_unmeasured` from `search_symbols` will read `find_callers`' bare `total: 0` as the measured absence it is not, and conclude the symbol is dead. `search_symbols` explicitly warns against that conclusion; `find_callers` invites it. It is also the exact shape of the defect closed in #97: a fact the system knows, disclosed on one surface and silent on another, where the silent one looks like a normal answer. ## What the right answer looks like `find_callers` already knows the symbol's kind and language, which is all `search_symbols` uses to decide. When the target is a kind with no use-reference channel, the reply should say so and name the tool that CAN answer — `find_references`, which spans ref kinds that are not calls — rather than returning an unqualified empty list. Suggested shape, reusing the existing vocabulary rather than inventing a second one: ```json {"results":[],"total":0, "callers_unmeasured":"no_use_reference_channel", "hint":"`GUEST_SRC_OFFSET` is a `const`: this index records no reference kind that captures a call to one, so this 0 is structural and not evidence that nothing uses it. Use `find_references` for the channels that can see it."} ``` Absent when it does not apply, present when it does — so a bare `total: 0` keeps meaning "measured, and nothing calls it". ## Check the neighbours too `find_callees` and `change_impact` seed from the same graph and may carry the same silence for the same kinds. Whatever shape is chosen should be applied wherever the population can be structurally empty, not patched onto `find_callers` alone — the fix for #97 covered all 23 tools by construction for exactly this reason.
Author
Member

Fixed by the same single mechanism as #101 — full detail in #101's close comment.

The part specific to this issue:

annotate_evidence_gaps reads SymbolRow::name_fallback_unmeasured off get_symbol for every symbol_id / symbol_ids in the request, and publishes it in the evidence_gaps block. That is deliberately the same field from the same producer that search_symbols already publishes, so the two tools cannot come to disagree about the same symbol — which was the asymmetry this issue reported.

Covered: find_callers, find_callees, change_impact, find_references, safe_delete, check_rename. The grader sits above the router, so this is not a per-tool patch and a future tool 25 is covered without opting in.

The mutation that proves it is not vacuous: probe_unmeasured_population → empty goes RED with left: None / right: Some(2) on a body that is verbatim this issue's bug report.

One deliberate narrowing, made on evidence

My first version also downgraded the absence verdict on this fact — i.e. safe_delete would drop no_evidence_of_use when the population was unmeasured. The pre-existing refactor_tools_disclose_a_saturated_fts_candidate_window went red on Widget, an ordinary struct, because no_use_reference_channel fires wherever the index demonstrates there is no use-channel for a (kind, lang) pair — which on a small repo is most kinds. A guard that fires on nearly everything discloses nothing.

So the split is: #99 gets disclosure beside the verdict; #101 gets the downgrade. The reasoning is written into downgrade_absence_verdict's doc so it is not silently re-widened later.

Known limit, stated rather than implied

explain_dependency's from/to and repo_map's focus_ids carry symbol ids under generic names, and the grader reads only symbol_id/symbol_ids. Widening to generic names would make a future from: "2026-01-01" a symbol probe. Documented at seed_symbol_ids.

A structural zero is still a zero — a function nothing calls reports a bare total: 0 with no block, because that zero is earned.

## Fixed by the same single mechanism as #101 — full detail in #101's close comment. The part specific to this issue: `annotate_evidence_gaps` reads `SymbolRow::name_fallback_unmeasured` off `get_symbol` for every `symbol_id` / `symbol_ids` in the **request**, and publishes it in the `evidence_gaps` block. That is deliberately **the same field from the same producer that `search_symbols` already publishes**, so the two tools cannot come to disagree about the same symbol — which was the asymmetry this issue reported. Covered: `find_callers`, `find_callees`, `change_impact`, `find_references`, `safe_delete`, `check_rename`. The grader sits above the router, so this is not a per-tool patch and a future tool 25 is covered without opting in. The mutation that proves it is not vacuous: `probe_unmeasured_population` → empty goes RED with `left: None / right: Some(2)` on a body that is **verbatim this issue's bug report**. ### One deliberate narrowing, made on evidence My first version also **downgraded the absence verdict** on this fact — i.e. `safe_delete` would drop `no_evidence_of_use` when the population was unmeasured. The pre-existing `refactor_tools_disclose_a_saturated_fts_candidate_window` went red on `Widget`, an ordinary struct, because `no_use_reference_channel` fires wherever the index *demonstrates* there is no use-channel for a (kind, lang) pair — which on a small repo is most kinds. A guard that fires on nearly everything discloses nothing. So the split is: **#99 gets disclosure beside the verdict; #101 gets the downgrade.** The reasoning is written into `downgrade_absence_verdict`'s doc so it is not silently re-widened later. ### Known limit, stated rather than implied `explain_dependency`'s `from`/`to` and `repo_map`'s `focus_ids` carry symbol ids under generic names, and the grader reads only `symbol_id`/`symbol_ids`. Widening to generic names would make a future `from: "2026-01-01"` a symbol probe. Documented at `seed_symbol_ids`. A structural zero is still a zero — a function nothing calls reports a bare `total: 0` with no block, because that zero is earned.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
h-dv/code-index#99
No description provided.