Skip to content

Commit be1c774

Browse files
NickCirvclaude
andcommitted
feat(resolver): Phase A — cross-provider displacement (dedup + blend-rank)
Turn the context resolver from a concatenator into a displacement engine: it now de-duplicates context ACROSS providers and ranks by a blended confidence+priority score, so adding providers can only SHRINK the packet, never grow it. The honest "spine" win — less context, ranked + deduped — in the regimes that matter (multi-provider overlap, sub-agent fan-out). No cost claims (W1.9): counters attest "redundancy eliminated" / "tokens displaced", never "saved". - src/providers/dedup.ts (NEW): FNV-1a shingle Jaccard dedup (section + line), monotonic (only removes content), + applyDisplacement() wiring helper. Pure, DI-tested. - src/providers/ranking.ts (NEW): blendScore = 0.6*confidence + 0.4*priorityWeight; a high-confidence low-priority result can now out-rank a weak high-priority one. - src/providers/resolver.ts: blendSort -> applyDisplacement -> never-worse gate; RichPacket gains redundantTokensEliminated + tokensDisplaced (candidate-set redundancy removed pre-budget, NOT final-packet delta — documented inline). - docs/design/phaseA-displacement-resolver.md: design spec (14 cases + 3 adversarial). Audit: tsc 0 . 1205/1205 tests (45 new) . 2 independent supervisors (honesty=SHIP/0 P0; correctness fixes applied: pure applyDisplacement + resolver-wiring tests + streaming-doc). Notes: - AST/structure special-case KEPT as a deterministic fast path (spec proposed folding it in; keeping it avoids a regression if their text is <0.85 similar). General dedup covers every other provider pair. - Streaming (resolveRichPacketStreaming / SSE) is deliberately NON-displacement (incremental emission can't see the full set); the hook path IS fully displaced. Buffer-then-displace for streaming is a tracked follow-up. - .gitignore: added *.pem/*.key after gitleaks caught an ed25519 private key accidentally generated in this repo during a go-live runbook; key moved out of the repo, never committed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1547c23 commit be1c774

7 files changed

Lines changed: 637 additions & 13 deletions

File tree

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,9 @@ docs/demos/work-*/
2424
dist-extensions/
2525
bench/results/session-level-2*.md
2626
.claude/
27+
28+
# Private keys / secrets — NEVER commit. (An ed25519 .pem was once generated
29+
# here by accident during a go-live runbook; gitleaks caught it pre-commit.)
30+
*.pem
31+
*.key
32+
.engram-secrets/
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Phase A — Resolver: Concatenator → Displacement Engine
2+
3+
**Status:** Design spec (read-only research) · **Date:** 2026-06-20 · **Author:** engram-phaseA-researcher
4+
**Scope:** `src/providers/resolver.ts` only. No code edited. Honesty: DISPLACEMENT (aggregate→rank→DISCARD), never "aggregate more"; counter attests *displaced*/*redundant*, never "saved". W1.9: net-of-caching ≈ 0 — no cost claims.
5+
6+
## 0. Premise corrections (file:line evidence)
7+
8+
- **Paths wrong in brief.** Files are `src/providers/resolver.ts`, `src/providers/mcp-client.ts`, `src/providers/types.ts` — NOT `src/intercept/providers/`. Line numbers below are the real ones.
9+
- **`confidence` default 0.75** is real: `mcp-client.ts:293` `tool.confidence ?? 0.75`.
10+
- **Priority array** is real: `types.ts:163` `PROVIDER_PRIORITY` (9 entries, `engram:ast``engram:lsp`).
11+
- **PARTIAL DEDUP ALREADY EXISTS** (brief said "ZERO" — half-wrong): `resolver.ts:174-179` drops `engram:structure` when `engram:ast` succeeded. It is a single hard-coded provider-pair special case, NOT general semantic dedup. Phase A generalises it; do not claim it's net-new.
12+
- **`ProviderResult` shape** (`types.ts:33-42`): `{ provider, content, confidence, cached }`. **No symbol id, no path, no line range** — only free-text `content`. This is load-bearing: dedup can only operate on normalized `content`, not structured keys. Symbol-level dedup would require a provider-contract change (out of Phase A scope; noted as risk).
13+
14+
## 1. Where duplication arises
15+
16+
The Read handler resolves enrichment providers (`read.ts:182-188`): `engram:mistakes, engram:git, mempalace, context7, obsidian` (structure already in `fileCtx.summary`). MCP code-graph plugins join via `getAllProviders()` (`resolver.ts:145`). Cross-provider dupes:
17+
- **structure ↔ MCP code-graph** — same function signature/callers text from `engram:structure` and an MCP graph server (only AST↔structure is currently handled, 174-179).
18+
- **mistakes ↔ git** — same commit/line cited as "broke here" (mistakes) and "churned here" (git).
19+
- **mempalace ↔ obsidian** — same decision note synced to both stores.
20+
- **context7 ↔ MCP docs plugin** — same library doc snippet.
21+
22+
Each arrives as a *section blob* of `content` (`mcp-client.ts:248` joins multiple tool outputs with `\n\n`). Dedup granularity is therefore the **section** and, finer, the **line/shingle** within a section.
23+
24+
## 2. Design — three changes
25+
26+
All three slot into `resolveRichPacket` between collection (`resolver.ts:172`) and assembly (`resolver.ts:212`), in this order: **(b) blend rank → (a) dedup → (c) size gate**. Rank first so dedup's keep-decision honours blended score; gate last over the final packet.
27+
28+
### (a) Dedup pass — content-normalized shingles (cheap first)
29+
New `dedupResults(results: ProviderResult[]): { kept; redundantTokens }` called at `resolver.ts:185` (replacing the AST/structure special-case 174-179, which becomes one general rule).
30+
31+
- **Normalize:** lowercase, collapse whitespace runs→single space, NFKC unicode fold, strip section header/label noise. → `norm(content)`.
32+
- **Shingle:** w=5 word k-shingles → `Set<hash>` (FNV-1a 32-bit, reuse `estimateTokens` neighbour util style; no new dep).
33+
- **Compare:** Jaccard over shingle sets. `J ≥ 0.85` ⇒ duplicate (tune via tests; 0.85 tolerates reformatting, rejects merely-same-topic).
34+
- **Sub-section dedup:** within the *kept* result, drop individual lines whose 5-shingle set has `J ≥ 0.9` against a line already emitted by a higher-ranked result. This is where most real displacement happens (partial overlap, not whole-section).
35+
- **Keep rule (tiebreak):** on a dup cluster keep the member with **highest blended score** (from (b)); on score tie keep **highest raw `confidence`**; on confidence tie keep **first by priority index**. Deterministic.
36+
- **Accounting:** sum `estimateTokens` of every discarded section + discarded line ⇒ `redundantTokens` (for §4).
37+
- **Embeddings: deferred, NOT used.** Justification gate (must hold to adopt later): only if a stress corpus shows shingle-Jaccard misses ≥15% of true semantic dupes (paraphrase with <0.85 lexical overlap) AND the false-keep tokens exceed the embedding compute cost. No data yet ⇒ hashing only. Recording this so a future session doesn't silently add a model dependency.
38+
39+
### (b) Blend confidence across providers (invert the comparator)
40+
Replace the comparator at `resolver.ts:197-204` (priority-primary, confidence-tiebreak) with a blended score:
41+
42+
```
43+
priorityWeight(p) = (P - idx(p)) / P // idx via PROVIDER_PRIORITY; unknown ⇒ idx=99 ⇒ ~0
44+
score(r) = 0.6 * r.confidence + 0.4 * priorityWeight(r.provider)
45+
sort desc by score; tie → higher confidence; tie → lower priority idx (deterministic)
46+
```
47+
48+
- `P = PROVIDER_PRIORITY.length`. Weights chosen so a **low-priority high-confidence** result (e.g. MCP graph, idx 8, conf 0.95 → 0.6·0.95+0.4·0.11=0.61) **beats** a **high-priority low-confidence** result (engram:git idx 6, conf 0.3 → 0.6·0.3+0.4·0.33=0.31). Priority still contributes (it's the 0.4 term) so it remains a real signal, not discarded.
49+
- `boostByMistakes` (`resolver.ts:192`, 458-477) stays — it adjusts `confidence` *before* scoring, so mistake-touching results rank up across the blend, not just within a tier. This is an intended upgrade over today's tie-only effect.
50+
- `0.6/0.4` weights are config-exposed (`config.rankConfidenceWeight`, default 0.6) so they're tunable without a code change.
51+
52+
### (c) Never-worse size gate (reuse ADR-0007 pattern)
53+
ADR-0007's grep gate (`grep.ts:93 rawGrepFloorTokens`, gate at `read.ts:166-172`) compares `estimateTokens(packet)` vs a conservative raw floor and PASSTHROUGHs if not strictly smaller. Reuse the *shape*, not the rg floor.
54+
55+
- **Baseline = pre-displacement packet tokens.** Compute `baselineTokens = sum(estimateTokens(r.content))` over the budgeted-but-not-yet-deduped results (the tokens engram would emit today). Compute `finalTokens` after dedup+rank+assembly.
56+
- **Gate:** `if (finalTokens >= baselineTokens) ` → emit the **smaller** of {deduped packet, today's packet}; never emit a packet larger than today's. Because dedup only removes, `finalTokens ≤ baselineTokens` always holds for the *content*; the gate guards against header/label churn re-inflating it (see §5 risk). Mirrors `read.ts:170 if (summaryTokens >= fileTokens) return PASSTHROUGH`.
57+
- Honest framing: gate proves packet is **smaller-or-equal** while rank keeps top-relevance first.
58+
59+
## 3. Hook points (resolver.ts)
60+
| Step | Line today | Change |
61+
|---|---|---|
62+
| collect results | 165-172 | unchanged |
63+
| AST/structure special-case | 174-179 | **remove** — folded into general `dedupResults` |
64+
| per-provider budget | 185 | unchanged (runs before dedup) |
65+
| boostByMistakes | 192 | unchanged (feeds confidence into blend) |
66+
| **blend sort (b)** | 197-204 | **replace comparator** with blended `score` |
67+
| **dedup (a)** | new, after sort (~205) | `{kept, redundantTokens} = dedupResults(sorted)` |
68+
| assembly loop | 212-226 | iterate `kept` |
69+
| **size gate (c)** | new, after 228 | compare final vs baseline; emit smaller |
70+
| return RichPacket | 241-247 | add `displacedTokens`, `redundantTokens` |
71+
72+
## 4. engram-counter instrumentation
73+
Extend `RichPacket` (`resolver.ts:112-123`) + counter audit block:
74+
- `redundantTokensEliminated` — tokens removed by dedup (§2a accounting). Attest "redundancy eliminated".
75+
- `tokensDisplaced``baselineTokens − finalTokens` (gate's measured delta). Attest "tokens displaced".
76+
- **Never** a `saved`/`cost` field. Counter consumes these two integers; aggregate is sum, never a % cost claim.
77+
78+
## 5. Where this risks INCREASING tokens (flagged)
79+
1. **Header/label re-inflation** — if dedup drops a whole section, `providerCount` shrinks but per-section labels stay; net could rise if dedup removes tiny sections and keeps big ones. Mitigation: gate (c) is the backstop; refuse any packet ≥ baseline.
80+
2. **Sub-section line-dedup leaving orphans** — removing interior lines can leave a dangling header with no body. Mitigation: drop a section whose body falls to 0 lines post-dedup.
81+
3. **Blend promoting a verbose low-priority result** that pushes a terse high-priority one past budget at `resolver.ts:214`. Ranking changes *order*, not size — but order changes *which* sections survive the budget cut. Net tokens unchanged (budget caps it), but relevance could drop; covered by the "low-priority-high-confidence beats" test asserting the displaced result is genuinely more relevant, not just longer.
82+
83+
## 6. TDD test list (ordered) — `tests/providers/resolver.test.ts`
84+
1. **dedup collapses identical sections** — two providers, same `content` → one section, `redundantTokensEliminated == estimateTokens(dup)`.
85+
2. **dedup collapses whitespace/unicode variants** — same text, NFKC + collapsed spaces differ → still deduped (J≥0.85).
86+
3. **dedup keeps highest blended score on a cluster** — dup pair, assert the kept one is the higher-score member, not first-arrival.
87+
4. **sub-section line dedup** — partial overlap (3 of 5 shared lines) → shared lines removed once, both sections survive minus overlap.
88+
5. **blend: low-priority high-confidence beats high-priority low-confidence** — MCP graph(idx8,0.95) ordered above engram:git(idx6,0.30).
89+
6. **blend: priority still contributes** — equal confidence → higher-priority first (the 0.4 term decides).
90+
7. **boostByMistakes feeds blend** — mistake-touching low-priority result outranks a non-touching equal-confidence one.
91+
8. **size gate refuses larger packet** — synthetic case where labels inflate → assert emitted packet ≤ baseline (never larger).
92+
9. **size gate: deduped is strictly smaller** → emits deduped, `tokensDisplaced > 0`.
93+
10. **empty results** → returns null (parity with `resolver.ts:172`).
94+
11. **malformed result (empty content / non-string)** → skipped, no throw, others survive.
95+
12. **never-worse under N providers** — property test, 2..8 random providers w/ random overlap → `finalTokens ≤ baselineTokens` ALWAYS.
96+
13. **AST/structure regression** — old special-case behaviour (drop structure when AST present) still holds via general dedup (guard against regressing 174-179).
97+
14. **counter fields populated**`redundantTokensEliminated` + `tokensDisplaced` present and consistent (`displaced == baseline − final`).
98+
99+
### 3 adversarial scenarios
100+
- **A1 — near-dup-not-dup (J just under threshold):** two results J≈0.84 (same topic, different specifics). Assert BOTH kept (dedup must not eat distinct context). Tunes the 0.85 boundary.
101+
- **A2 — hostile MCP plugin returns 50KB single section, confidence 1.0:** per-provider budget (185) truncates first; blend can't let it starve others; gate proves no net increase. Assert other providers still present + packet ≤ baseline.
102+
- **A3 — adversarial label collision:** plugin sets its `content` to mimic another provider's section verbatim to get itself kept. Assert keep-rule uses blended score+confidence (not content identity) so the impersonator doesn't displace the genuine higher-confidence source.

src/providers/dedup.ts

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
/**
2+
* dedup.ts — Phase A displacement: collapse redundant context ACROSS providers.
3+
*
4+
* The resolver fans out to many providers (built-in + plugin + MCP). Two of them
5+
* routinely return the SAME thing (structure ↔ an MCP code-graph; mistakes ↔ git;
6+
* mempalace ↔ obsidian). Concatenating both INFLATES the packet — the opposite of
7+
* what a context spine should do. This module turns concatenation into
8+
* DISPLACEMENT: given results already ranked highest-first, it keeps the
9+
* highest-ranked member of each duplicate cluster and discards the rest, reporting
10+
* how many tokens it eliminated.
11+
*
12+
* Honesty (W1.9): this only ever REMOVES content — it can never grow the packet.
13+
* The accounting is "redundancy eliminated", never "cost saved".
14+
*
15+
* Pure + dependency-free: shingled Jaccard over normalized content (FNV-1a hashing,
16+
* no embeddings — see docs/design/phaseA-displacement-resolver.md §2a for the
17+
* data-gated justification on why hashing first). The token estimator is injected
18+
* so this stays unit-testable without the resolver's internals.
19+
*/
20+
21+
import type { ProviderResult } from "./types.js";
22+
23+
/** k-shingle width (words). 5 balances reformatting-tolerance vs topic-collision. */
24+
export const SHINGLE_W = 5;
25+
/** Section-level Jaccard ≥ this ⇒ duplicate section. Tolerates reformatting. */
26+
export const SECTION_THRESHOLD = 0.85;
27+
/** Line-level Jaccard ≥ this ⇒ duplicate line (stricter — partial overlap). */
28+
export const LINE_THRESHOLD = 0.9;
29+
30+
/** FNV-1a 32-bit. Fast, no deps, good enough for shingle identity. */
31+
export function fnv1a(s: string): number {
32+
let h = 0x811c9dc5;
33+
for (let i = 0; i < s.length; i++) {
34+
h ^= s.charCodeAt(i);
35+
h = Math.imul(h, 0x01000193);
36+
}
37+
return h >>> 0;
38+
}
39+
40+
/**
41+
* Normalize for comparison: NFKC-fold, lowercase, strip markdown headers +
42+
* bracketed labels (so `[engram:git]`-style section noise doesn't drive matches),
43+
* collapse all whitespace. Comparison-only — the original content is preserved.
44+
*/
45+
export function normalize(content: string): string {
46+
return content
47+
.normalize("NFKC")
48+
.toLowerCase()
49+
.replace(/^#+\s*/gm, "") // markdown headers
50+
.replace(/\[[^\]\n]*\]/g, " ") // [label] noise
51+
.replace(/\s+/g, " ")
52+
.trim();
53+
}
54+
55+
/** Word-level k-shingle set of normalized text. Short text → one whole-text shingle. */
56+
export function shingles(normText: string, w: number = SHINGLE_W): Set<number> {
57+
const set = new Set<number>();
58+
const words = normText.split(" ").filter(Boolean);
59+
if (words.length === 0) return set;
60+
if (words.length < w) {
61+
set.add(fnv1a(words.join(" ")));
62+
return set;
63+
}
64+
for (let i = 0; i + w <= words.length; i++) {
65+
set.add(fnv1a(words.slice(i, i + w).join(" ")));
66+
}
67+
return set;
68+
}
69+
70+
/** Jaccard similarity of two shingle sets. Both empty ⇒ 1; one empty ⇒ 0. */
71+
export function jaccard(a: Set<number>, b: Set<number>): number {
72+
if (a.size === 0 && b.size === 0) return 1;
73+
if (a.size === 0 || b.size === 0) return 0;
74+
const [small, large] = a.size < b.size ? [a, b] : [b, a];
75+
let inter = 0;
76+
for (const x of small) if (large.has(x)) inter++;
77+
return inter / (a.size + b.size - inter);
78+
}
79+
80+
export interface DedupOutput {
81+
/** Survivors, in the input (ranked) order, with line-level dupes stripped. */
82+
readonly kept: ProviderResult[];
83+
/** Tokens removed by section + line dedup (for engram-counter attestation). */
84+
readonly redundantTokens: number;
85+
}
86+
87+
/**
88+
* Collapse cross-provider redundancy. `ranked` MUST be pre-sorted highest-first
89+
* (the resolver blends confidence+priority before calling this) so the kept member
90+
* of every cluster is the most relevant. Two passes:
91+
* 1. SECTION dedup — drop a whole result whose shingles ≥ SECTION_THRESHOLD vs
92+
* any already-kept result.
93+
* 2. LINE dedup — within survivors (in rank order), drop individual lines whose
94+
* shingles ≥ LINE_THRESHOLD vs a line already emitted by a higher-ranked
95+
* result. A result whose body falls to nothing is dropped entirely.
96+
* Total-monotonic: output content ⊆ input content, so it can never grow the packet.
97+
*/
98+
export function dedupResults(
99+
ranked: readonly ProviderResult[],
100+
estimateTokens: (s: string) => number,
101+
opts: { sectionThreshold?: number; lineThreshold?: number } = {}
102+
): DedupOutput {
103+
const sectionT = opts.sectionThreshold ?? SECTION_THRESHOLD;
104+
const lineT = opts.lineThreshold ?? LINE_THRESHOLD;
105+
106+
const kept: ProviderResult[] = [];
107+
const keptShingles: Set<number>[] = [];
108+
const emittedLineShingles: Set<number>[] = [];
109+
let redundantTokens = 0;
110+
111+
for (const r of ranked) {
112+
// Defensive: a malformed provider result must never break dedup.
113+
if (!r || typeof r.content !== "string") continue;
114+
const norm = normalize(r.content);
115+
if (norm.length === 0) {
116+
kept.push(r); // nothing to compare; pass through untouched
117+
continue;
118+
}
119+
120+
const sh = shingles(norm);
121+
if (keptShingles.some((ks) => jaccard(sh, ks) >= sectionT)) {
122+
redundantTokens += estimateTokens(r.content); // whole section is redundant
123+
continue;
124+
}
125+
126+
// Line-level dedup within this (kept) result.
127+
const lines = r.content.split("\n");
128+
const survivors: string[] = [];
129+
let droppedLineTokens = 0;
130+
for (const line of lines) {
131+
const ln = normalize(line);
132+
if (ln.length === 0) {
133+
survivors.push(line); // preserve blank/structural lines
134+
continue;
135+
}
136+
const lsh = shingles(ln);
137+
if (emittedLineShingles.some((es) => jaccard(lsh, es) >= lineT)) {
138+
droppedLineTokens += estimateTokens(line);
139+
continue;
140+
}
141+
survivors.push(line);
142+
emittedLineShingles.push(lsh);
143+
}
144+
145+
const newContent = survivors.join("\n");
146+
if (normalize(newContent).length === 0) {
147+
// Body fully dedup'd away → drop the orphaned section entirely.
148+
redundantTokens += estimateTokens(r.content);
149+
continue;
150+
}
151+
152+
redundantTokens += droppedLineTokens;
153+
keptShingles.push(sh);
154+
kept.push(newContent === r.content ? r : { ...r, content: newContent });
155+
}
156+
157+
return { kept, redundantTokens };
158+
}
159+
160+
export interface Displacement {
161+
/** Results to assemble (deduped), or the original ranked set if the
162+
* never-worse backstop tripped. */
163+
readonly kept: ProviderResult[];
164+
/** Tokens removed by dedup (redundancy eliminated). */
165+
readonly redundantTokens: number;
166+
/** Content tokens displaced before budgeting (baseline − final). Always ≥ 0. */
167+
readonly tokensDisplaced: number;
168+
/** Candidate-set content tokens BEFORE dedup (what engram would emit today). */
169+
readonly baselineTokens: number;
170+
}
171+
172+
/**
173+
* The resolver's displacement step as a pure, testable unit: measure baseline,
174+
* dedup, measure final, apply a never-worse backstop, and report honest counters.
175+
* `ranked` MUST be pre-sorted highest-first. Because dedup is monotonic the gate
176+
* never trips in practice — it's a belt-and-braces guard against any future
177+
* regression that could inflate content. Returns counters labelled "displaced" /
178+
* "redundancy eliminated" — never "saved" (W1.9).
179+
*/
180+
export function applyDisplacement(
181+
ranked: readonly ProviderResult[],
182+
estimateTokens: (s: string) => number
183+
): Displacement {
184+
const baselineTokens = ranked.reduce(
185+
(sum, r) => sum + (typeof r?.content === "string" ? estimateTokens(r.content) : 0),
186+
0
187+
);
188+
const { kept, redundantTokens } = dedupResults(ranked, estimateTokens);
189+
const finalTokens = kept.reduce((sum, r) => sum + estimateTokens(r.content), 0);
190+
191+
if (finalTokens > baselineTokens) {
192+
// Should be impossible (dedup only removes) — fall back to the original set.
193+
return { kept: [...ranked], redundantTokens: 0, tokensDisplaced: 0, baselineTokens };
194+
}
195+
return { kept, redundantTokens, tokensDisplaced: baselineTokens - finalTokens, baselineTokens };
196+
}

0 commit comments

Comments
 (0)