From 59c054338df07f79295bfdab754d86da0fc128ee Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Fri, 31 Jul 2026 18:17:24 +0000 Subject: [PATCH] feat(mcp): split the epistemic hedge into its two producers (#2766) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `impact` and `context` reported `epistemic: 'lower-bound'` for two independent reasons that the output could not tell apart: receiverTyping call sites dropped because the receiver could not be typed — a RESOLVER DEFECT, and the population this series targets dispatchBoundary the symbol sits behind an interface with real consumers or 2+ implementations, so callers binding through DI or dynamic dispatch are genuinely untraceable — NOT a defect; a compiler refuses here too One enum plus prose meant a consumer — especially a coding agent gating its own edits on the result — could tell THAT a count was short but not WHY, and could not branch on the difference. It also made "the hedge should stop appearing" unfalsifiable: with no way to see which producer fired, there was no way to check whether fixing receiver typing had achieved anything. Both surfaces now carry `causes: { receiverTyping, dispatchBoundary }` alongside the existing prose. `receiverTyping` counts dropped SITES, not boundary notes. The first cut counted notes and published `1` next to prose reading "2 call sites" — an agent branching on the number would have read a different magnitude than the human reading the text, which is precisely the failure a structured field exists to prevent. `unresolvedReceiverBoundaries` now returns `{ notes, sites }` so the count comes from the same place the prose does. Verified live. On the #2766 reproduction `WithTx` went from `impactedCount: 0` + `lower-bound` to `impactedCount: 1` + `exact` — the hedge is gone because its cause is gone, not because it was suppressed. On a TypeScript repo with dropped receivers, `causes` reports `{ receiverTyping: 2, dispatchBoundary: 0 }`, matching the prose exactly. NOT verified: the `dispatchBoundary` path end-to-end. Go's implicit interface satisfaction emits no IMPLEMENTS edges, so that producer structurally cannot fire on the Go fixture, and observing it needs a Java or TypeScript case. It is wired and typechecks; it is not claimed proven. Only the receiverTyping producer is addressed by this series. The dispatch boundary is untouched and will keep firing for interface-dispatched symbols, which is correct — and any claim that the hedge has stopped appearing must now be read per-producer. Co-Authored-By: Claude Opus 5 (1M context) --- .../bench/receiver-resolution/BASELINE.md | 34 +++++++++ gitnexus/src/mcp/local/local-backend.ts | 70 ++++++++++++++++--- 2 files changed, 94 insertions(+), 10 deletions(-) diff --git a/gitnexus/bench/receiver-resolution/BASELINE.md b/gitnexus/bench/receiver-resolution/BASELINE.md index 9287d97c5..fcf326de9 100644 --- a/gitnexus/bench/receiver-resolution/BASELINE.md +++ b/gitnexus/bench/receiver-resolution/BASELINE.md @@ -1,5 +1,39 @@ # Receiver-resolution baseline +## U9 — the epistemic hedge has TWO producers, and only one is a defect + +`impact` reports `epistemic: 'lower-bound'` for two independent reasons that were +previously indistinguishable in the output: + +| Cause | What it means | Is it a defect? | +|---|---|---| +| `receiverTyping` | Call sites dropped because the analyzer could not type the receiver | **Yes** — a resolver gap. This is the population this whole series targets. | +| `dispatchBoundary` | The symbol sits behind an interface with real consumers or 2+ implementations | **No** — callers binding through DI or dynamic dispatch are genuinely untraceable statically. A compiler refuses here too. | + +Both collapsed into one enum plus prose, so a consumer — especially a coding +agent gating its own edits on the result — could tell THAT a count was short but +not WHY, and could not branch on the difference. Worse, it made "the hedge should +stop appearing" unfalsifiable: with no way to see which producer fired, there was +no way to check whether fixing receiver typing had done anything. + +`impact` and `context` now carry a structured `causes: { receiverTyping, +dispatchBoundary }` alongside the prose. `receiverTyping` counts dropped SITES, +not boundary notes — there is one note per symbol name but it reports N sites, so +counting notes published `1` next to prose reading "2 call sites" and a consumer +branching on the number would have read a different magnitude than the human +reading the text. + +**Only the `receiverTyping` producer is addressed by this series.** The dispatch +boundary is untouched and will keep firing for interface-dispatched symbols — +which is correct. Any claim that the hedge has "stopped appearing" has to be read +per-producer, and that is now possible. + +Measured on the #2766 reproduction: `WithTx` went from `impactedCount: 0` with a +`lower-bound` hedge to `impactedCount: 1` with `epistemic: exact`. The hedge is +gone there because its cause is gone, not because it was suppressed. + +--- + ## U10 — recorded drops, censused by receiver shape `ResolutionOutcome`'s suppressed variant now carries `receiverShape`, set by the diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 690c48c8f..a8571ec4e 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -472,13 +472,48 @@ export interface CodebaseContext { /** Collapse dropped-site boundary notes into an epistemic verdict: any note at * all means the count is a lower bound, none means it is exact (#2744). */ -function epistemicFrom(droppedBoundaries: readonly string[]): { +/** + * Why a count is a lower bound, as a machine-readable split. + * + * `epistemic` is a single enum and `boundaries` is prose, so a consumer that is + * not a human — a coding agent gating its own edits on this result — can tell + * THAT the answer is short but not WHY, and cannot branch on the difference. + * The two causes are independent and have opposite remedies: + * + * - `receiverTyping` — the analyzer dropped call sites because it could not + * establish the receiver's type. A resolver defect. Fixable, and shrinking: + * this is the population the structural-receiver work targets. + * - `dispatchBoundary` — the symbol sits behind an interface with real + * consumers or multiple implementations, so callers binding through a DI + * container or dynamic dispatch are genuinely untraceable statically. NOT a + * defect; a compiler would refuse here too. + * + * Collapsing them told the reader "impact may be higher" for both, which made + * the fixable cause indistinguishable from the irreducible one — and made + * "the hedge should stop appearing" an unfalsifiable goal, because there was no + * way to see which producer was still firing. + */ +export interface EpistemicCauses { + readonly receiverTyping: number; + readonly dispatchBoundary: number; +} + +function epistemicFrom(dropped: { notes: readonly string[]; sites: number }): { epistemic: 'exact' | 'lower-bound'; boundaries?: string[]; + causes?: EpistemicCauses; } { - return droppedBoundaries.length === 0 + return dropped.notes.length === 0 ? { epistemic: 'exact' } - : { epistemic: 'lower-bound', boundaries: [...droppedBoundaries] }; + : { + epistemic: 'lower-bound', + boundaries: [...dropped.notes], + // SITES, not notes. There is one note per symbol name but it reports N + // dropped sites, so counting notes would have published `1` next to + // prose saying `2 call sites` — a consumer branching on the number + // would read a different magnitude than the human reading the text. + causes: { receiverTyping: dropped.sites, dispatchBoundary: 0 }, + }; } interface RepoHandle { @@ -5738,7 +5773,11 @@ export class LocalBackend { symId: string, symType: string, symName: string, - ): Promise<{ epistemic: 'exact' | 'lower-bound'; boundaries?: string[] }> { + ): Promise<{ + epistemic: 'exact' | 'lower-bound'; + boundaries?: string[]; + causes?: EpistemicCauses; + }> { const HERITAGE_TYPES = EPISTEMIC_HERITAGE_RELATION_TYPES; const CONSUMER_TYPES = EPISTEMIC_CONSUMER_RELATION_TYPES; // #2744 — call sites dropped for want of a receiver type. Checked BEFORE @@ -5829,7 +5868,14 @@ export class LocalBackend { } } if (boundaries.length === 0) return epistemicFrom(droppedBoundaries); - return { epistemic: 'lower-bound', boundaries: [...droppedBoundaries, ...boundaries] }; + return { + epistemic: 'lower-bound', + boundaries: [...droppedBoundaries.notes, ...boundaries], + causes: { + receiverTyping: droppedBoundaries.sites, + dispatchBoundary: boundaries.length, + }, + }; } catch { // Never let the heritage probe's failure suppress a drop we already know // about — the whole point is that silence must not read as certainty. @@ -5844,8 +5890,11 @@ export class LocalBackend { * index written before the summary existed, which is why the schema version * was bumped rather than treating "absent" as "none". */ - private async unresolvedReceiverBoundaries(repo: RepoHandle, symName: string): Promise { - if (symName.length === 0) return []; + private async unresolvedReceiverBoundaries( + repo: RepoHandle, + symName: string, + ): Promise<{ notes: string[]; sites: number }> { + if (symName.length === 0) return { notes: [], sites: 0 }; try { const meta = await loadMeta(path.dirname(repo.lbugPath)); const summary = meta?.unresolvedReceiverMembers; @@ -5853,8 +5902,8 @@ export class LocalBackend { // returns a Function for `constructor`/`toString`/… and `NaN <= 0` is false, // so the old guard let it through into user-facing text. const sites = lookupUnresolvedCallCount(summary, symName); - if (sites === undefined) return []; - return [ + if (sites === undefined) return { notes: [], sites: 0 }; + const notes = [ `${sites} call ${sites === 1 ? 'site' : 'sites'} invoking \`${symName}\` ${ sites === 1 ? 'was' : 'were' } dropped at index time because the receiver's type could not be ` + @@ -5862,8 +5911,9 @@ export class LocalBackend { `expression). Those callers are absent from this result — actual ` + `impact may be higher.`, ]; + return { notes, sites }; } catch { - return []; + return { notes: [], sites: 0 }; } }