# #17a semantic-merge spike — restoring the corrupted recall signal (2026-06-17)

audience: AI coding agents first. MEASURED log; tags [MEASURED]/[INFERRED]. Do not prettify.

## BLUF
The band-1 k-roll union dedupes by lexical title-overlap (`gate.py:union_rolls`), so independent rolls
that paraphrase the SAME bug fragment into several `1/3` groups — the recall signal reads flaky when it
is actually solid (S6-001: one SSRF-on-PATCH canonical surfaced as THREE `1/3` entries; true union 3/3).
**#17a — a CONSERVATIVE LLM semantic-merge pass over the union groups — is MEASURED to fix this** on the
owned S6-001 fixture: the SSRF-PATCH canonical merges to a single 3/3 group, the role-case cluster merges,
and 3 distinct-bug pairs stay SEPARATE, stable across k=3 rolls. **Now WIRED into `gate.py` and validated
end-to-end on a fresh gate run** (below). The merge is the *next* fork, ahead of the recall sweep (#24):
sweeping first would measure distorted-low rates and manufacture phantom blind spots. **Scope: MECHANISM
proven on n=1 cell; NOT yet multi-cell.** Code: `orchestrator/semantic_merge.py` + `gate.py` (default-on,
`--no-merge` for A/B).

## Why dedup before the recall sweep [MEASURED reasoning]
- The roll-count is the recall instrument. Lexical union corrupts it for any paraphrase-prone class:
  a 3/3 catch reads as three 1/3s. Measured on S6 (STEP2 zync-pilot).
- A sweep run before the fix records distorted-low rates → conservative for no-false-coverage (it
  undersells, never oversells) BUT manufactures **phantom blind spots** — and a phantom recall gap is
  exactly what would tempt the banned #16 prompt rewrite to "fix" a dedup artifact. Fix the instrument first.
- The sweep needs hand-dedup per cell to judge recall anyway (`bench.is_flagged` is a weak lexical proxy).
  That is #17a done by hand, per cell, untooled. Build it once.

## The two-halves split [reconciled with advisor 2026-06-17]
#17 is TWO problems; only one is safe to build now.
- **#17a semantic merge (THIS spike):** collapse paraphrased restatements of the SAME finding; union the
  roll sets. **Origin-invariant** — a paraphrase is a paraphrase whether findings come from one file or a
  cross-file resolution → **decoupled from #36**, safe now.
- **#17b severity/triage calibration (DEFERRED):** ranking + severity tuning runs against the finding
  union, which **#36 changes** (cross-file resolution pulls in more findings) → tuning it now risks rework.
  Stays deferred behind the user-gated #36.

## Mechanism [MEASURED — S6-001 fixture, k=3, /tmp/sg_cfg clean config]
`merge_groups(groups)` → conservative LLM pass → groups that share location + root cause + fix collapse;
roll set of a merged group = UNION of members' rolls. Self-test (`semantic_merge.py --selftest`):

| roll | groups (of 17) | SSRF-PATCH merged | role-case merged | distinct kept separate |
|------|----------------|-------------------|------------------|------------------------|
| 0 | 12 | yes | yes | yes |
| 1 | 13 | yes | yes | yes |
| 2 | 13 | yes | yes | yes |

- **SSRF-PATCH canonical restored:** the 3 paraphrases (fixture idx 1,2,4) → ONE group every roll. With the
  measured distinct-roll membership (STEP2: the 3 were found in 3 different rolls) the union recount = **3/3**
  — the flaky-1/3 artifact is gone. Recount arithmetic unit-checked independently (distinct-roll → union).
- **Conservative bias holds (no over-merge):** 3 distinct-bug pairs kept separate every roll —
  missing-guard-on-PATCH vs DNS-rebinding-inside-guard (diff root cause+fix); missing-guard vs octal-IPv4
  parsing gap (the S6-004 canonical); IDOR vs SSRF (diff class).
- **Tool is stable:** the two canonical clusters group identically across all 3 rolls. The 12-vs-13 variance
  is on a non-canonical borderline pair (retry status-guard) — conservative UNDER-merge, the safe direction.

## Safety units [MEASURED — LLM-free, `--unittest`]
A gate's merge must NEVER silently lose a finding. All pass:
- partition guard FAILS LOUD (raises `MergeError`) on duplicate-index, missing-index, out-of-range,
  no-JSON, empty-group — so the LLM can't drop or double-count a bug undetected.
- degraded-fallback: any LLM/parse failure → input returned UNCHANGED + `degraded=True` + stderr notice
  (non-silent, same discipline as the oracle on unresolved imports). Never a silent un-deduped pass.
- fold takes the WORST severity across merged members; recount unions roll sets.

## End-to-end gate run [MEASURED — S6-001 pre-fix cell, bec8bc8~1, k=3, merge default-on]
Reconstructed `endpoints.ts` (533 lines, PATCH route lacks `assertSafeWebhookUrl`) → `gate.py ... --k 3`.
Exit 0, no degraded warning (merge succeeded). LLM-leg report:
- `[high] PATCH /:id updates the endpoint URL without SSRF validation` — **3/3** (the S6-001 canonical, CAUGHT).
- `[medium] Uncaught JSON.parse on secretEncrypted` — **3/3, 3 paraphrases merged**.
- `[high] Retry re-dispatch with no state guard` — **3/3, 2 paraphrases merged**.
- `[medium] SSRF guard validates hostname not resolved IP — DNS rebinding` — **2/3, kept SEPARATE** from the
  PATCH-missing-guard entry (conservative bias holds on a fresh run; no over-merge).
So the merge runs in-pipeline, restores roll-counts on real paraphrase clusters, and tags merged groups.
**Stochastic nuance (honest):** the SSRF-PATCH canonical did NOT fragment THIS run (lexical union already
3/3) — fragmentation is per-run stochastic. The merge's value is proven on the clusters that DID fragment
this run (JSON.parse ×3, retry ×2). The instrument-fix is real and active; whether it fires on any given
cluster depends on that run's phrasing spread.

## NOT established (honest)
- **n=1 cell.** Mechanism is general (LLM semantic grouping); one measured instance is enough to ACT, but
  this is a point estimate, not a multi-cell rate. Multi-cell/multi-class merge validation pending.
- **Under-merge rate uncharacterized.** Conservative bias means occasional borderline pairs stay split
  (safe). Its frequency across cells is unmeasured.
- **Cost.** Merge adds one `claude -p` call per file. Acceptable; not benchmarked.

## Next [ordered]
1. Multi-cell merge validation across classes (fold into the recall sweep, not a separate pass).
2. THEN the recall sweep (#24) runs on un-distorted rates (merge default-on).
3. #36 (band-2 cross-file resolver) remains the existential #1 and is USER-GATED — #17a does not touch it.
   If a snag appears, S11 deterministic legs (pnpm-audit + headers) are the clean LLM-free fallback.

## Pointers
- Code + self-test: `orchestrator/semantic_merge.py` (`--selftest` LLM, `--unittest` LLM-free).
- Distortion evidence: `~/.claude/security-gate/zync-pilot/STEP2_ZYNC_PILOT.md`, `.../S6_gate_v2.md`.
- Lexical union being fixed: `orchestrator/gate.py:union_rolls` (~line 142).
