feat(mcp): split the epistemic hedge into its two producers (#2766)

`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) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-07-31 18:17:24 +00:00
parent 075ad0508d
commit 59c054338d
2 changed files with 94 additions and 10 deletions

View file

@ -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

View file

@ -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<string[]> {
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 };
}
}