scrum: blind-response guard + anchor-grounding post-verifier

Two signal-quality fixes for the scrum loop:

1. isBlindResponse() — detects models that emit structurally-valid
   review JSON containing "no source code visible / cannot verify"
   even when source WAS supplied. Rejects so the ladder cycles to
   the next rung instead of accepting the blind hallucination.

2. verifyAnchorGrounding() + appendGroundingFooter() — post-process
   verifier that extracts every backtick-quoted snippet from the
   review and checks it against the original source content.
   Appends a grounding footer reporting grounded vs ungrounded
   counts so humans can audit hallucination rate at a glance.

Born from the iter where llm_team_ui.py review came back with 6/10
findings hallucinated (invented render_template_string calls,
fabricated logger.exception sites, made-up SHA-256 hashing).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
root 2026-04-25 17:07:30 -05:00
parent b843a23433
commit 9ecc5848fa

View File

@ -382,14 +382,131 @@ async function chat(opts: {
// Accept a file-review answer if it's substantive + structured.
// We're not validating Rust here — we're validating that the model
// produced a coherent suggestion set.
//
// BLIND-RESPONSE GUARD (added after iter 4 regression on llm-team-ui):
// Some models pretend the source code wasn't supplied even when it was —
// they produce structurally-valid JSON with one critical_failure of the
// form "No source code visible; cannot verify..." Those should be
// rejected so the ladder cycles to the next rung. We check for a small
// set of telltale phrases inside critical_failures descriptions.
function isBlindResponse(answer: string): boolean {
// Cheap substring match on the descriptions area of the JSON
const blindPhrases = [
/no source code (visible|provided|supplied)/i,
/cannot (view|see|verify|access) (the )?source/i,
/no code (was )?(visible|provided|supplied|attached)/i,
/unable to (view|access|read) (the )?(source|file|code)/i,
/source (code )?was not (provided|supplied|attached|included)/i,
];
return blindPhrases.some((re) => re.test(answer));
}
// Anchor-grounding verifier — runs after a review is accepted (only
// when tree-split fired, since small files don't need it). Extracts
// every backtick-quoted code snippet from the review and checks
// whether it appears in the original source content. Returns the
// stats + a footer that gets appended to the review so humans can
// audit grounding rate at a glance.
//
// Why: 2026-04-24 verification of llm_team_ui.py (13K lines, 61 shards)
// showed 0/10 findings real, 6/10 hallucinated. Model invented
// `render_template_string(f"<h1>{user}</h1>")`, `logger.exception(e)`,
// SHA-256 password hashing — none of which existed in the actual
// source. The reviewer wrote what *fit* the PRD's worry-list rather
// than what the code actually does. This verifier catches that.
function verifyAnchorGrounding(answer: string, sourceContent: string) {
// Pull both inline `quoted` and triple-fenced ```quoted``` snippets.
// Skip very short ones (≤ 3 chars — they false-match too easily on
// common tokens like \`a\` or \`if\`).
const inline = [...answer.matchAll(/`([^`\n]{4,})`/g)].map((m) => m[1]);
const fenced = [...answer.matchAll(/```(?:[a-z]+\n)?([\s\S]+?)```/g)]
.map((m) => m[1].trim())
.flatMap((b) => b.split("\n"))
.map((l) => l.trim())
.filter((l) => l.length >= 6);
const allQuotes = [...new Set([...inline, ...fenced])];
const grounded: string[] = [];
const ungrounded: string[] = [];
const sourceLower = sourceContent.toLowerCase();
// The model often emits the review wrapped in a JSON envelope, so
// backtick-quoted snippets have their internal `"` escaped as `\"`,
// `\n` as `\\n`, etc. Try unescaped variant first; if that's in the
// source consider it grounded. Also normalize curly quotes to ASCII
// since some models smart-quote string literals.
const unescapeJsonish = (s: string) =>
s
.replace(/\\"/g, '"')
.replace(/\\'/g, "'")
.replace(/\\n/g, "\n")
.replace(/\\t/g, "\t")
.replace(/\\\\/g, "\\")
.replace(/[“”]/g, '"')
.replace(/[]/g, "'");
for (const q of allQuotes) {
// Strip leading offset markers like "@123456" the anchors carry
const cleaned = q.replace(/^@\d+\s*/, "").trim();
if (cleaned.length < 4) continue;
const candidates = [cleaned, unescapeJsonish(cleaned)];
const hit = candidates.some((c) => sourceLower.includes(c.toLowerCase()));
if (hit) grounded.push(cleaned);
else ungrounded.push(cleaned);
}
const total = grounded.length + ungrounded.length;
const groundedPct = total > 0 ? Math.round((grounded.length / total) * 100) : null;
return { total, grounded: grounded.length, ungrounded, groundedPct };
}
function appendGroundingFooter(
answer: string,
stats: ReturnType<typeof verifyAnchorGrounding>,
): string {
const lines = [
"",
"─── ANCHOR GROUNDING (post-process verifier) ───",
`Backtick-quoted snippets: ${stats.total}`,
`Grounded in source (literal substring match): ${stats.grounded}` +
(stats.groundedPct !== null ? ` (${stats.groundedPct}%)` : ""),
`Ungrounded (likely hallucinated, treat findings using these as low-confidence):`,
];
if (stats.ungrounded.length === 0) {
lines.push(" (none — every quoted snippet matches the source verbatim)");
} else {
for (const u of stats.ungrounded.slice(0, 12)) {
lines.push(` · \`${u.slice(0, 80)}\``);
}
if (stats.ungrounded.length > 12) {
lines.push(` · ... and ${stats.ungrounded.length - 12} more`);
}
}
lines.push("─────────────────────────────────────────────────");
return answer + "\n" + lines.join("\n");
}
function isAcceptable(answer: string): boolean {
if (answer.length < 200) return false; // too thin
// Must at least try a structured form — numbered list, bullets,
// or sections. Models that just hand-wave fail.
const hasStructure = /^\s*[-*]\s/m.test(answer)
|| /^\s*\d+\.\s/m.test(answer)
|| /^\s*#/m.test(answer);
return hasStructure;
if (isBlindResponse(answer)) return false; // hallucinated "no source"
// Two accepted shapes:
// (a) Markdown — bullets, numbered list, or headers. Original shape.
// (b) Forensic JSON — `{"verdict":"..."}` with at least one of the
// finding arrays populated. SCRUM_FORENSIC_PROMPT.md requires
// this shape; previous version rejected it because the first
// character is `{`, not `-`/`#`/`1.`. Iter-2 observation in
// SCRUM_LOOP_NOTES flagged this as `[FORENSIC vs thin-detector
// mismatch]` — this is the fix.
const hasMarkdownStructure = /^\s*[-*]\s/m.test(answer)
|| /^\s*\d+\.\s/m.test(answer)
|| /^\s*#/m.test(answer);
if (hasMarkdownStructure) return true;
// Accept JSON verdict shape even without surrounding markdown.
// Check for a `"verdict"` key and at least one populated finding
// array — empty objects still fail.
if (/"verdict"\s*:\s*"(pass|fail|needs_patch)"/i.test(answer)) {
const hasFindings = /"(critical_failures|pseudocode_flags|prd_mismatches|broken_pipelines|missing_components|risk_points|verified_components|required_next_actions)"\s*:\s*\[\s*\{/.test(answer);
if (hasFindings) return true;
}
return false;
}
function retrieveTopK(query_emb: number[], pool: Chunk[], k: number): Chunk[] {
@ -400,6 +517,171 @@ function retrieveTopK(query_emb: number[], pool: Chunk[], k: number): Chunk[] {
.map(x => ({ ...x.c, _score: x.score } as any));
}
// File substrate — replaces the original tree-split summarize/reduce
// architecture. The original was lossy: model paraphrased shards into
// prose, paraphrase-of-paraphrase fed reviewer, reviewer hallucinated
// against PRD worry-list (verified 2026-04-24: 0/10 real findings on
// llm_team_ui.py 13K lines).
//
// The substrate approach (J's redesign, 2026-04-24):
//
// 1. ANCHORS zone — deterministic regex extraction of literally-
// suspicious lines (route defs, auth calls, SQL, secrets, exception
// handlers, env access). No LLM, no paraphrasing. Reviewer can
// quote any anchor verbatim.
//
// 2. NEIGHBORS zone — the file is chunked line-aware and embedded
// via the sidecar's nomic-embed-text. For every relevant PRD
// chunk, we hybrid-retrieve the top-K matching FILE chunks.
// Reviewer sees actual code regions semantically close to each
// PRD worry-area, not summaries.
//
// 3. RANGE-LOOKUP zone — full-file kept in memory; reviewer can
// ask for byte-exact ranges. (Currently surfaced via the verifier
// check — every backtick-quoted snippet must literal-match the
// source. Future: tool-call interface for in-prompt range fetch.)
//
// All three zones feed the reviewer with grounded code, not paraphrased
// distillation.
interface AnchorLine {
byte_offset: number;
line_no: number;
text: string;
kind: string;
}
interface FileChunk {
byte_offset: number;
line_from: number;
line_to: number;
text: string;
embedding: number[];
}
interface FileSubstrate {
anchors: AnchorLine[];
chunks: FileChunk[];
queryFile: (emb: number[], k: number) => FileChunk[];
}
// Deterministic regex extraction of reviewer-relevant lines. Each
// pattern targets a class of risk surface common to web services:
// auth, SQL, secrets, templating, HTTP routing, exception flow.
const ANCHOR_PATTERNS: Array<{ kind: string; re: RegExp }> = [
{ kind: "route", re: /^@\w+\.route\s*\(/m },
{ kind: "func_def", re: /^\s*(async\s+)?def\s+\w+\s*\(/m },
{ kind: "class_def", re: /^class\s+\w+/m },
{ kind: "import", re: /^(from\s+\S+\s+import|import\s+\S+)/m },
{ kind: "auth_decorator", re: /@(login_required|admin_required|api_key_required|require_\w+)/ },
{ kind: "sql_exec", re: /\.\s*execute\s*\(/ },
{ kind: "f_string_sql", re: /f["'][^"']*\b(SELECT|INSERT|UPDATE|DELETE|CREATE|DROP)\b/i },
{ kind: "secret", re: /(secret|api_key|token|password|FLASK_SECRET|DB_URL)/i },
{ kind: "template", re: /render_template(_string)?\s*\(/ },
{ kind: "exception", re: /\bexcept\s+\w+/ },
{ kind: "env_access", re: /os\.(environ|getenv)\b/ },
{ kind: "rate_limit", re: /(rate_limit|limiter|RateLimit)/ },
{ kind: "subprocess", re: /\b(subprocess|os\.system|exec\s*\(|eval\s*\()/ },
{ kind: "todo", re: /\b(TODO|FIXME|XXX|HACK)\b/ },
];
function extractAnchors(content: string): AnchorLine[] {
const lines = content.split("\n");
const anchors: AnchorLine[] = [];
let byteCursor = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineByte = byteCursor;
byteCursor += line.length + 1; // +1 for the \n
const trimmed = line.trim();
if (trimmed.length === 0 || trimmed.length > 240) continue;
for (const p of ANCHOR_PATTERNS) {
if (p.re.test(line)) {
anchors.push({
byte_offset: lineByte,
line_no: i + 1,
text: line.length > 200 ? line.slice(0, 200) + "…" : line,
kind: p.kind,
});
break; // first matching kind wins; one line, one anchor entry
}
}
}
return anchors;
}
// Line-aware chunker. Targets ~800-char chunks but won't split a line.
// Each chunk records the line range so the reviewer can cite "lines N-M".
function chunkFileLineAware(content: string, target = 800): Array<{
byte_offset: number;
line_from: number;
line_to: number;
text: string;
}> {
const lines = content.split("\n");
const chunks: Array<{ byte_offset: number; line_from: number; line_to: number; text: string }> = [];
let buf: string[] = [];
let bufBytes = 0;
let chunkStartByte = 0;
let chunkStartLine = 1;
let byteCursor = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineLen = line.length + 1;
if (bufBytes + lineLen > target && buf.length > 0) {
chunks.push({
byte_offset: chunkStartByte,
line_from: chunkStartLine,
line_to: i,
text: buf.join("\n"),
});
buf = [];
bufBytes = 0;
chunkStartByte = byteCursor;
chunkStartLine = i + 1;
}
buf.push(line);
bufBytes += lineLen;
byteCursor += lineLen;
}
if (buf.length > 0) {
chunks.push({
byte_offset: chunkStartByte,
line_from: chunkStartLine,
line_to: lines.length,
text: buf.join("\n"),
});
}
return chunks;
}
async function buildFileSubstrate(filePath: string, content: string): Promise<FileSubstrate> {
const anchors = extractAnchors(content);
const rawChunks = chunkFileLineAware(content, 800);
log(` substrate: ${anchors.length} anchors · ${rawChunks.length} chunks (line-aware, 800-char target)`);
// Embed chunks in batches of 64 (nomic-embed-text handles this well).
const embeddings: number[][] = [];
const BATCH = 64;
for (let i = 0; i < rawChunks.length; i += BATCH) {
const batch = rawChunks.slice(i, i + BATCH).map((c) => c.text);
const embs = await embedBatch(batch);
embeddings.push(...embs);
}
const chunks: FileChunk[] = rawChunks.map((c, i) => ({ ...c, embedding: embeddings[i] }));
return {
anchors,
chunks,
queryFile: (emb: number[], k: number) =>
chunks
.map((c) => ({ c, score: cosine(emb, c.embedding) }))
.sort((a, b) => b.score - a.score)
.slice(0, k)
.map((x) => x.c),
};
}
// Tree-split a large file: shard it, summarize each shard into a
// running scratchpad, THEN run a reduce step that collapses the
// scratchpad into one file-level synthesis with shard boundaries
@ -412,6 +694,9 @@ function retrieveTopK(query_emb: number[], pool: Chunk[], k: number): Chunk[] {
// the reviewer prompt, which led to kimi-k2:1t writing review titles
// like "Forensic Audit Report file.rs (shard 3)" because the shard
// markers bled through. Fix 2026-04-24 adds the reduce step.
//
// DEPRECATED 2026-04-24: superseded by buildFileSubstrate() above.
// Kept temporarily as a fallback if substrate ingestion fails.
async function treeSplitFile(
filePath: string,
content: string,
@ -422,34 +707,57 @@ async function treeSplitFile(
shards.push({ from: i, to: end, text: content.slice(i, end) });
}
// MAP — each shard produces a digest that feeds the next shard's
// context. Internal markers are kept to help the reducer align
// overlapping observations across shards; they're stripped before
// the reviewer sees anything.
// MAP — each shard digests independently. Previously the prompt
// carried the accumulating scratchpad of all prior shard outputs,
// which made MAP cost O(n²) in shard count AND forced late shards
// to fight for context-window space against the prior notes (on a
// 209-shard file, the prior-notes block alone hit ~40K tokens). The
// cost/budget fix: each shard sees only its own text. The reducer
// integrates the cross-shard view, not MAP.
//
// Instruction also changed to require SPECIFIC line/byte markers
// and identifiers — previous "flat facts" framing produced generic
// prose summaries where "line 9959: model_sets default contains
// mistral:latest" collapsed to "the file routes to local models".
// Scrum iter 11 observation: fine-grained fixes vanished from the
// reviewer's view because specific-line detail didn't survive MAP.
let workingScratchpad = "";
let cloud_calls = 0;
log(` tree-split: ${content.length} chars → ${shards.length} shards of ${FILE_SHARD_SIZE}`);
for (const [si, shard] of shards.entries()) {
const prompt = `You are writing a SECTION of a full-file summary. File: ${filePath}. This is one piece (bytes ${shard.from}..${shard.to}) of a larger source file you are NOT seeing in its entirety right now.
const prompt = `You are writing a SECTION of a full-file summary. File: ${filePath}. This is one piece (bytes ${shard.from}..${shard.to}) of a larger source file.
source
${shard.text}
end source
Prior-piece notes so far (if empty, this is the first piece):
${workingScratchpad || "(empty)"}
Output two parts in order:
Extract facts about the code in this piece that will help review the FULL file later: function + struct names with brief purpose, struct fields + types, invariants, TODOs, error-handling style, obvious gaps. Under 150 words. Flat facts only, no headings, no phrases like "this shard" or "in my section".`;
PART A Flat-bullet digest (200 words):
- Every function, struct, class, or public type by name with one-line purpose.
- Every hardcoded default, literal, or model name a caller might override.
- Every TODO, FIXME, placeholder, or stub return.
- Every exception handler and what it swallows vs re-raises.
Do NOT say "this section" or "this shard".
PART B VERBATIM ANCHORS (REQUIRED 5 to 10 lines copied character-perfect from the source above):
Format each as a code-fenced block with the byte offset within the shard:
\`\`\`
@${shard.from}+OFFSET
EXACT LINE OF SOURCE DO NOT PARAPHRASE, DO NOT TRUNCATE
\`\`\`
Pick the most reviewer-relevant lines: route definitions (e.g. \`@app.route(...)\`), function signatures, security-sensitive calls (auth/SQL/exec/template/secrets), hardcoded credentials/defaults, exception handlers, sensitive imports. The reviewer will REFUSE to act on any claim not backed by a verbatim anchor — so anchors are how you prove findings are real.`;
const r = await chat({
provider: "ollama_cloud",
model: "gpt-oss:120b",
prompt,
max_tokens: 400,
max_tokens: 900,
});
cloud_calls += 1;
if (r.content) {
// Keep internal alignment markers for the reducer; stripped later.
workingScratchpad += `\${si + 1}§\n${r.content.trim()}`;
// Keep internal alignment markers with byte offsets so the
// reducer can correlate findings back to file regions.
workingScratchpad += `\n§bytes ${shard.from}..${shard.to}§\n${r.content.trim()}`;
}
}
@ -458,35 +766,55 @@ Extract facts about the code in this piece that will help review the FULL file l
// produces a single narrative the reviewer can treat as "the file".
// Shard markers are NOT in the output. This is what fixes the
// shard-leakage bug that affected both the scrum and the auditor.
const reducePrompt = `You are producing a SINGLE coherent summary of a Rust/TypeScript source file from a set of prior-piece notes. The notes were taken while walking the file in order but should be merged into one description of the whole file.
// REDUCE — the one place where the cross-shard view comes together.
// Previous max_tokens=900 asked for 40K tokens → 900 compression,
// which destroyed specific line references. Raised to 2400 and the
// prompt now explicitly requires preserving byte-offset markers and
// concrete literals (hardcoded model names, line snippets, TODOs)
// so fine-grained findings actually survive to the reviewer.
//
// Fix for shard-leakage: the reducer output is the SINGLE source
// the reviewer sees as "the file" — per prior iter 3 observation
// ("tree_split_fired:true is supposed to mean reducer-merged summary").
const reducePrompt = `You are producing a SINGLE coherent file-level summary of a source file from byte-addressed piece notes. Each piece note has TWO parts: a prose digest (PART A) and VERBATIM ANCHORS (PART B — code-fenced blocks with @offset markers and literal source lines).
FILE: ${filePath} (${content.length} bytes, ${shards.length} pieces)
PRIOR-PIECE NOTES (markers §N§ delimit pieces but are artifacts do not mention them):
PIECE NOTES:
${workingScratchpad}
Produce ONE coherent file-level summary:
1. One-sentence purpose of the file.
2. Key public types / functions / constants (names + one-line purpose each).
3. Known gaps, TODOs, or error-handling inconsistencies the notes surfaced.
4. Obvious invariants the file relies on.
Produce ONE coherent output with TWO sections:
Do NOT say "piece 1" or "shard N" or "section" present the summary as if you read the whole file at once. Under 600 words.`;
NARRATIVE
- One-sentence purpose of the file.
- All public types / functions / constants with byte-offset markers like §bytes 24500..28000§.
- Every hardcoded default, model name, or literal a caller might override keep the EXACT string.
- Every TODO / FIXME / stub return / placeholder.
- Every exception handler and what it does with the error.
- Obvious invariants.
Under 1200 words. Do NOT mention "piece N" or "section".
VERBATIM ANCHORS
COPY EVERY anchor block from the piece notes IN ORDER, character-perfect. DO NOT paraphrase. DO NOT shorten. DO NOT skip any. The reviewer will use these to ground findings if you elide one, real risks become invisible.
Output the anchor blocks under their original \`\`\`@offset...\`\`\` fences, each on its own with a blank line between. The reviewer rejects findings that don't quote a string from this anchors block, so completeness here directly determines review quality.`;
const reduced = await chat({
provider: "ollama_cloud",
model: "gpt-oss:120b",
prompt: reducePrompt,
max_tokens: 900,
max_tokens: 2400,
});
cloud_calls += 1;
const synthesis = reduced.content?.trim() ?? "";
// Safety: if the reducer returned thin output, fall back to the
// raw scratchpad stripped of markers — better than nothing.
// raw scratchpad — with byte markers preserved since the reviewer
// benefits from offsets regardless of whether they're inside the
// reducer's narrative or the raw per-piece bullets.
const final = synthesis.length > 200
? synthesis
: workingScratchpad.replace(/§\d+§\n/g, "").trim();
: workingScratchpad.trim();
return { scratchpad: final, shards: shards.length, cloud_calls };
}
@ -518,31 +846,90 @@ async function reviewFile(
...topPlan.map(c => `[PLAN @${c.offset}]\n${c.text.slice(0, 600)}`),
].join("\n\n");
// Files bigger than FILE_TREE_SPLIT_THRESHOLD get tree-split.
// Summarize each shard to a scratchpad, then review against the
// scratchpad instead of the truncated first chunk. Prevents the
// false-positive pattern where the model claims a field is
// "missing" because it's past the context cutoff.
// Files bigger than FILE_TREE_SPLIT_THRESHOLD trigger the substrate
// path: deterministic anchor extraction + per-file vector index.
// Reviewer sees three zones:
// ANCHORS — verbatim suspicious lines (regex-extracted, never paraphrased)
// NEIGHBORS — top-K file chunks retrieved per PRD chunk via cosine
// PRD/PLAN — already retrieved, kept as-is
// No LLM-paraphrased prose is shown. Reviewer is required to quote
// anchors or chunks verbatim; verifier drops findings whose backtick-
// quoted snippets don't appear in the original source.
let sourceForPrompt: string;
let treeSplitFired = false;
let shardsSummarized = 0;
let extraCloudCalls = 0;
let substrateAnchorBlock = "";
let substrateRetrievedBlock = "";
if (content.length > FILE_TREE_SPLIT_THRESHOLD) {
treeSplitFired = true;
const ts = await treeSplitFile(rel, content);
shardsSummarized = ts.shards;
extraCloudCalls = ts.cloud_calls;
sourceForPrompt = `[FULL-FILE SCRATCHPAD — distilled from ${ts.shards} shards via tree-split]\n${ts.scratchpad}`;
const sub = await buildFileSubstrate(rel, content);
shardsSummarized = sub.chunks.length;
// ANCHORS zone — pick representative anchors per kind, cap to ~30
// to keep the block readable.
const byKind = new Map<string, AnchorLine[]>();
for (const a of sub.anchors) {
const arr = byKind.get(a.kind) || [];
arr.push(a);
byKind.set(a.kind, arr);
}
const balanced: AnchorLine[] = [];
const PER_KIND = 4;
const MAX_ANCHORS = 40;
for (const [, arr] of byKind) balanced.push(...arr.slice(0, PER_KIND));
balanced.sort((a, b) => a.byte_offset - b.byte_offset);
const trimmedAnchors = balanced.slice(0, MAX_ANCHORS);
substrateAnchorBlock = trimmedAnchors
.map((a) => `[L${a.line_no} @byte ${a.byte_offset} kind=${a.kind}]\n${a.text}`)
.join("\n\n");
log(` substrate anchors selected: ${trimmedAnchors.length}/${sub.anchors.length}`);
// NEIGHBORS zone — for each top PRD chunk, pull the top-2 file
// chunks that semantically match it. Surfaces the actual code
// regions the PRD's worry-areas point at. Dedup by byte_offset.
const seen = new Set<number>();
const neighbors: FileChunk[] = [];
for (const prdChunk of topPrd) {
const top = sub.queryFile(prdChunk.embedding, 2);
for (const fc of top) {
if (seen.has(fc.byte_offset)) continue;
seen.add(fc.byte_offset);
neighbors.push(fc);
}
}
substrateRetrievedBlock = neighbors
.slice(0, 8)
.map((c) => `[lines ${c.line_from}-${c.line_to} @byte ${c.byte_offset}]\n${c.text}`)
.join("\n\n──\n\n");
log(` substrate neighbors retrieved: ${neighbors.length} (showing top 8)`);
sourceForPrompt =
`═══ ANCHORS (verbatim source lines extracted by regex — quotable) ═══\n${substrateAnchorBlock}\n\n` +
`═══ NEIGHBORS (file chunks retrieved by similarity to PRD worry-areas — quotable) ═══\n${substrateRetrievedBlock}`;
} else {
sourceForPrompt = content;
}
// Prompt — when tree-split fired, include an explicit instruction
// not to claim a field/function is "missing" because the scratchpad
// is a distillation not the full file. Attacks the rubric-tuning
// concern J called out.
// is a distillation not the full file. Plus a hard quote-or-die
// requirement: every finding MUST quote a literal string from the
// VERBATIM ANCHORS section. Without this, big-file reviews
// hallucinate against the PRD worry-list (verified 2026-04-24:
// 0/10 real findings on 13K-line llm_team_ui.py). The
// post-acceptance verifier (verifyAnchorGrounding) drops findings
// whose backtick-quoted strings don't appear in the original
// source — last-line defense against confabulation.
const truncationWarning = treeSplitFired
? `\nIMPORTANT: the "source" below is a multi-shard distillation (tree-split across ${shardsSummarized} shards), NOT the full raw file. DO NOT claim any field, function, or feature is "missing" based on its absence from this distillation — the distillation may have elided it. Only call out gaps that appear DIRECTLY contradicted by the PRD excerpts.\n`
? `\nIMPORTANT: this is a LARGE file (${content.length} bytes / ${shardsSummarized} chunks). You are NOT seeing the full raw source. You are seeing TWO grounded zones:
ANCHORS regex-extracted verbatim source lines (route defs, auth calls, SQL, secrets, exception handlers, etc.) with line numbers and byte offsets. Every line is character-perfect.
NEIGHBORS file chunks retrieved by cosine-similarity to each relevant PRD excerpt. Every chunk is character-perfect source code.
QUOTE-OR-DIE RULE NON-NEGOTIABLE:
EVERY finding you list MUST include a backtick-quoted snippet of literal source text drawn from the ANCHORS or NEIGHBORS zones. If you cannot quote literal source for a claim, DO NOT make the claim. Generic "this file lacks X" is NOT acceptable when X isn't visibly absent from the anchors/neighbors you can see instead, if you suspect X is absent, write "could not verify presence of X in retrieved zones" with low confidence rather than asserting it as a critical failure.
The pipeline runs a post-process verifier that drops findings whose quoted code doesn't appear in the original source byte-for-byte. Make every claim grounded.\n`
: "";
const forensicPrefix = FORENSIC_PREAMBLE
@ -675,6 +1062,17 @@ Respond with markdown. Be specific, not generic. Cite file-region + PRD-chunk-of
accepted = r.content;
acceptedModel = `${rung.provider}/${rung.model}`;
acceptedOn = n;
// Post-acceptance: when tree-split fired, run the anchor-grounding
// verifier and append a footer with the grounding rate. The footer
// surfaces ungrounded quotes so humans can spot hallucinated
// findings at a glance — prevents the 0/10 confabulation pattern
// observed on llm_team_ui.py 2026-04-24.
if (treeSplitFired) {
const stats = verifyAnchorGrounding(accepted, content);
log(` ⚓ anchor grounding: ${stats.grounded}/${stats.total} quotes matched source` +
(stats.groundedPct !== null ? ` (${stats.groundedPct}%)` : ""));
accepted = appendGroundingFooter(accepted, stats);
}
log(` ✓ ACCEPTED on attempt ${n} (${rung.model}, ${r.content.length} chars)`);
break;
}