Files
gbrain/scripts/build-contradictions-fixture.ts
T
9a5606af6d v0.32.6 feat: brain-consistency probe + doctor + MCP + dream-cycle wire-up (#901)
* feat(eval-contradictions): types + pure helpers for v0.33.0 probe

Foundational module for the contradiction measurement probe (v0.33.0 plan).
Pure, hermetic, no engine or LLM dependencies. Sets the wire contract for
the rest of the implementation.

- types.ts: schema_version + PROMPT_VERSION + TRUNCATION_POLICY constants,
  ProbeReport + ContradictionPair + JudgeVerdict + cache/run row shapes.
- calibration.ts: Wilson 95% CI on the headline percentage with exact
  clamping at p=0 and p=1 (floating-point overshoot regression guard);
  small_sample_note when n<30.
- judge-errors.ts: first-class typed error collector (Codex fix — bias
  guard for the silent-skip-on-throw decision); classifier maps to
  parse_fail/refusal/timeout/http_5xx/unknown.
- severity-classify.ts: parseSeverity defaults to 'low' on garbage input;
  bucketBySeverity + buildHotPages (descending rank + tie-break by severity).
- date-filter.ts: three-rule A1 pre-filter — same-paragraph-dual-date
  beats the separation rule (flip-flop case); missing dates falls through
  to the judge; only "both explicit AND >30d apart" actually skips.

51 hermetic tests across the four pure modules; typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(eval-contradictions): schema migrations + engine methods (v0.33.0)

Adds the persistent surface the contradiction probe needs: two new tables
plus five BrainEngine methods, mirrored cleanly across PGLite + Postgres.

Migrations v51 + v52 (idempotent on both engines):
  - eval_contradictions_cache: composite PK on (chunk_a_hash, chunk_b_hash,
    model_id, prompt_version, truncation_policy) per Codex outside-voice
    fix; verdict JSONB; expires_at-driven TTL.
  - eval_contradictions_runs: one row per probe run; Wilson CI bounds,
    judge-error totals, source-tier breakdown, full report_json.

Engine methods (interface + 2 impls each):
  - listActiveTakesForPages(pageIds, opts): P1 batched per-page fetch.
    Single WHERE page_id = ANY($1) AND active = true; replaces the O(K)
    loop the probe would otherwise pay per query.
  - writeContradictionsRun(row): M5 time-series insert; idempotent on
    run_id via ON CONFLICT DO NOTHING.
  - loadContradictionsTrend(days): M5 history read, newest first.
  - getContradictionCacheEntry(key): P2 cache lookup; 5-component key
    includes prompt_version + truncation_policy.
  - putContradictionCacheEntry(opts): cache upsert with TTL refresh.
  - sweepContradictionCache(): periodic expired-row purge.

JSONB writes use sql.json() on Postgres (matches existing eval_takes_quality
+ raw_data patterns; not the literal-template-tag pattern banned by
scripts/check-jsonb-pattern.sh). PGLite uses $N::jsonb positional binds.

17 hermetic tests on PGLite cover P1 (4 cases: empty, grouped, supersede-
excludes, holder-allow-list), M5 (5 cases: write+read, idempotent run_id,
newest-first, days-window, JSONB round-trip), P2 (6 cases: miss, put-get,
prompt-version differs, truncation differs, upsert refreshes, sweep
deletes expired). Existing 109 migrate + bootstrap tests still green.

Schema mirror in pglite-schema.ts; source.sql regenerated to schema-embedded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(eval-contradictions): cross-source + cost-tracker + cache wrappers

Three pure-orchestration modules between the engine surface and the
runner. Each is independently testable; the cache wrapper does hit the
PGLite engine end-to-end since its job is to round-trip through P2.

- cross-source.ts (M6): classifySlugTier maps a slug to curated/bulk/other
  using DEFAULT_SOURCE_BOOSTS (boost > 1.05 = curated, < 0.95 = bulk).
  buildSourceTierBreakdown produces the {curated_vs_curated,
  curated_vs_bulk, bulk_vs_bulk, other} counts; order-independent on
  the pair members.

- cost-tracker.ts (A2 + P3): estimateUpperBoundCost for pre-flight refuse.
  CostTracker records judge calls (per-token-pricing per model) AND
  embedding calls (Codex P3 fix). Soft-ceiling semantics documented
  in the estimate_note string surfaced in the final report (Codex
  caveat: "hard ceiling" was overclaimed for token estimates).
  Anthropic + OpenAI pricing baked in; unknown models fall back to
  Haiku rates.

- cache.ts (P2 wrapper): hashContent (sha256), buildCacheKey with
  lex-sorted (a, b) so verdicts are order-independent and key bakes in
  PROMPT_VERSION + TRUNCATION_POLICY (Codex outside-voice fix). JudgeCache
  class tracks hits/misses for the run report. Shape validation guards
  against corrupt rows: a cache row that doesn't parse as JudgeVerdict
  treats as a miss instead of crashing downstream.

40 hermetic tests across the three modules. Cache tests hit PGLite for
real round-trip coverage of the new engine methods committed in C2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(eval-contradictions): judge + auto-supersession + fixture-redact

Three modules that together turn an LLM into a contradiction probe and
its output into actionable resolutions.

- judge.ts: judgeContradiction() is the single LLM call. Query-conditioned
  prompt (Codex outside-voice fix — the judge sees what the user asked).
  Holder context for take pairs (C3). UTF-8-safe truncation at maxPairChars
  (default 1500, --max-pair-chars overridable; C4 wire-up). C1
  double-enforcement: orchestrator filters contradicts:true with confidence
  < 0.7 to false regardless of prompt rules. parseJudgeJSON is a 3-strategy
  generic parser (direct → fence-strip → trailing-comma + quote + first-{}
  extraction) — we don't reuse parseModelJSON because that's shape-locked
  to cross-modal-eval's scores payload. Refusal detection via stopReason
  AND text-pattern fallback. chatFn injection for hermetic tests.

- auto-supersession.ts (M7): proposeResolution classifies each pair into
  takes_supersede / dream_synthesize / takes_mark_debate / manual_review
  and emits a paste-ready CLI command. Judge's hint wins on cross-slug
  pairs (it has semantic context); structural fallback prefers
  dream_synthesize when either side is a curated entity slug
  (companies/, people/, deals/, projects/). pairToFinding merges a pair +
  verdict into a ContradictionFinding.

- fixture-redact.ts (T2): privacy-redacted pass for the gold fixture
  build. Layers PII scrubber (v0.25.0 eval-capture-scrub) + slug rewrites
  (people/<name> → people/alice-example, deterministic per session) +
  capitalized firstname-lastname detection + monetary obfuscation
  (multiply revenues by session salt to preserve magnitude shape).
  isCleanForCommit is the pre-commit safety net: blocks if any raw name
  or email shape survives. Audit trail records every redaction made.

60 hermetic tests. Judge tests use direct chatFn stub (cleaner than
module-level transport seam for one-shot wrapper).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(eval-contradictions): trends + runner orchestrator (v0.33.0)

The heart of the probe — runner.ts ties every prior module together,
trends.ts writes one row per run to eval_contradictions_runs and produces
the trend chart for the CLI `trend` sub-subcommand.

runner.ts:
  - Pair generation: cross-slug across top-K results (same-slug skipped)
    + intra-page chunk-vs-take via P1 batched listActiveTakesForPages.
  - A1 date pre-filter wired: pairs separated by >30 days skip without
    judge calls; same-paragraph-dual-date overrides separation rule
    (flip-flop case sees the judge).
  - A3 deterministic sampling: combined_score DESC, slug-lex tiebreaker,
    stable across re-runs.
  - A2 soft budget ceiling: pre-flight estimate refuses without --yes;
    mid-run cumulative cost stops the run and emits a partial report.
  - P2 cache integration: lookup before judge call, store after; hit/miss
    counters drive the cache stats block in the report.
  - C2 first-class judge_errors: every throw counted via the typed
    collector, surfaced in report.judge_errors with the no-silent-skip
    `note` field.
  - Wilson CI on the headline percentage; small_sample_note when n<30.
  - source_tier_breakdown + hot_pages aggregated across all findings.
  - AbortSignal propagation for cancellation mid-run.
  - PreFlightBudgetError exported as a discriminable rejection class.
  - Hermetic via judgeFn + searchFn dependency injection — runner tests
    stub both without ever touching the real gateway or hybridSearch.

trends.ts:
  - writeRunRow flattens a ProbeReport into the eval_contradictions_runs
    row shape, including Wilson CI bounds + duration_ms.
  - loadTrend reads back as typed TrendRow[].
  - renderTrendChart produces a fixed-width ASCII bar chart; empty input
    prints a friendly message naming the command to populate runs.

41 new hermetic tests on PGLite (15 trends, 26 runner). Full
eval-contradictions suite at 194/194 across 13 files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(eval-contradictions): CLI + eval dispatch + mini fixture (v0.33.0)

User-facing surface: `gbrain eval suspected-contradictions [run|trend|review]`.
Engine-required sub-subcommand, dispatched via the existing eval.ts pattern
(matches `replay`).

Run mode:
  --queries-file FILE | --query "..." | --from-capture  (mutually exclusive)
  --top-k N=5  --judge MODEL=claude-haiku-4-5  --limit N
  --budget-usd N (default $5 TTY / $1 non-TTY) --yes
  --output FILE  --max-pair-chars N=1500
  --sampling deterministic|score-first  --no-cache  --refresh-cache  --json

Trend mode: --days N=30 [--json]
Review mode: --severity low|medium|high  --since YYYY-MM-DD

A4 wired: --from-capture detects empty eval_candidates and exits 2 with
hint naming GBRAIN_CONTRIBUTOR_MODE=1 / eval.capture config key.

Human summary on stderr always prints Wilson CI band, judge_errors counts
broken out by class, cache hit-rate, source-tier breakdown, hot pages.
Partial-report warning when mid-run budget cap fires.

Run-row persistence (M5) writes to eval_contradictions_runs every successful
run; subsequent `trend` and `review` invocations read from there.

PreFlightBudgetError surfaces as exit 1 with the calculated estimate + cap
in the message — operators see the exact number to pass to --budget-usd
or override with --yes.

TrendRow type extended with report_json so `review` can fetch the latest
run's findings without a second query.

test/fixtures/contradictions-mini.jsonl: 5 redacted queries for CLI smoke.

Full eval-contradictions suite: 194 hermetic tests across 13 files. Real-
brain CLI smoke covered by the E2E in commit 9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(eval-contradictions): doctor + MCP + synthesize integrations (M1+M2+M3)

Three thin wire-ups that turn the probe's output into action surfaces:

M1 (doctor): src/commands/doctor.ts adds a `contradictions` check after
the eval_capture check. Reads loadContradictionsTrend(7), surfaces the
latest run's headline + severity breakdown + Wilson CI band + first 3
high-severity findings with paste-ready resolution commands. ok status
when no runs exist or no findings; warn when high-severity > 0. Graceful
skip when the table doesn't exist yet (pre-migration brain).

M3 (MCP): src/core/operations.ts adds `find_contradictions` op (scope:
read, NOT localOnly — agent-callable over HTTP MCP). Params: slug
(substring match), severity (low|medium|high), limit. Reads
loadContradictionsTrend(30), returns the latest run's findings filtered.
NOT in the subagent allowlist by design — user-initiated only, not
autonomous-action surface. New FIND_CONTRADICTIONS_DESCRIPTION constant
in operations-descriptions.ts.

M2 (synthesize): src/core/cycle/synthesize.ts pre-fetches the latest
probe findings once at phase start (loadPriorContradictionsBlock helper)
and threads up to 5 highest-severity items into buildSynthesisPrompt as
an informational block. Subagent sees what to reconcile when writing
compiled_truth to flagged slugs. Empty trend yields empty block (existing
behavior unchanged on fresh installs). Try/catch around the engine call
keeps synthesize robust even when the contradiction tables don't exist
yet.

11 new hermetic tests for the MCP op (registry presence, scope, empty
case, slug+severity+limit filters) and the M1/M2 data-shape contracts
(end-to-end runDoctor coverage deferred to commit 9's E2E because doctor
calls process.exit).

Full eval-contradictions suite: 226/226 across 15 test files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(eval-contradictions): build-contradictions-fixture script (T2)

Local-only operator script for building the privacy-redacted gold fixture
used by the precision/recall test (deferred to v0.34 when probe data
informs the labeling). Runs against the user's REAL brain via the local
gbrain engine config; never auto-run in CI.

Flow:
  1. Read --queries-file (JSONL); spin up engine via loadConfig +
     toEngineConfig + createEngine + connectWithRetry.
  2. Run the contradiction probe with --no-cache and a stubbed judgeFn
     that captures candidate pairs without spending tokens.
  3. Interactive prompts (skipped under --non-interactive): for each
     candidate, the operator labels y/n/skip + severity + axis.
  4. Apply the v0.33.0 fixture-redact passes (slug rewrite, name
     placeholders, monetary obfuscation, PII scrubber).
  5. Pre-commit safety gate: every text field passes isCleanForCommit;
     anything that fails gets a [REDACT?] sentinel + an _operator_review
     marker on the JSONL line, and the script exits 1 so the operator
     can't accidentally commit unredacted output.

Audit comment block at the top of the JSONL records every redaction
the session made (slug→placeholder, name→placeholder, monetary
multiplication) so reviewers can see what was changed.

Usage:
  bun run scripts/build-contradictions-fixture.ts \\
    --queries-file FILE.jsonl \\
    [--top-k N] [--judge MODEL] [--max-pairs N] [--output PATH] \\
    [--non-interactive]

Output defaults to test/fixtures/contradictions-eval-gold.jsonl.

Typecheck clean; redactor + isCleanForCommit guard tested separately
in test/eval-contradictions-fixture-redact.test.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(e2e): real-Postgres E2E for contradiction probe (v0.33.0, T1)

Required-on-DATABASE_URL E2E covering Postgres-specific behavior that
PGLite can't exercise. Six surface areas, 12 cases total. All pass on
fresh pgvector/pgvector:pg16:

1. Migrations v51 + v52 apply cleanly; both tables exist in
   information_schema; Wilson CI columns are REAL; composite PK on
   eval_contradictions_cache includes prompt_version + truncation_policy
   (Codex outside-voice fix pinned at the schema level).

2. JSONB round-trip on Postgres: writeContradictionsRun + loadTrend
   preserves nested object shapes (regression guard against the v0.12
   double-encode bug class). Confirmed via jsonb_typeof = 'object', not
   'string'.

3. P2 cache with real now(): lookup/upsert round-trip, expired rows
   hidden from lookup, sweepContradictionCache deletes them, and
   different prompt_version is a separate cache key.

4. M5 trend semantics: TIMESTAMPTZ ordering DESC is stable on real PG;
   days-window filter via ran_at >= cutoff correctly excludes/includes
   backdated rows.

5. find_contradictions MCP op end-to-end: empty case returns "No probe
   runs" note; populated case returns latest run findings with slug
   substring + severity filters applied.

Verified locally against pgvector:pg16 on port 5434 — all 12 cases pass.
Skips gracefully when DATABASE_URL is unset per gbrain E2E convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.33.0 feat: brain-consistency probe + doctor + MCP + dream-cycle wire-up

VERSION 0.32.0 → 0.33.0. package.json + CHANGELOG.md + llms-full.txt synced.

Headline: gbrain learns to detect its own integrity drift.

  - new command: gbrain eval suspected-contradictions [run|trend|review]
  - new MCP op: find_contradictions(slug?, severity?, limit?)
  - new doctor check: contradictions (paste-ready resolution commands)
  - new dream-cycle hook: synthesize reads prior contradictions per slug
  - new schema: v51 (eval_contradictions_cache) + v52 (eval_contradictions_runs)
  - 6 new engine methods (listActiveTakesForPages, write/load run, P2 cache trio)

Codex outside-voice review folded in:
  - Command name "suspected-contradictions" (was "contradictions" — describes
    what the tool actually does, not what it pretends to evaluate)
  - judge_errors first-class output (not silent stderr — biased denominator)
  - prompt_version + truncation_policy in cache key (prompt edits cleanly
    invalidate prior verdicts)
  - Wilson 95% CI on headline % + small_sample_note when n<30
  - Query-conditioned judge prompt (sees user's query, not just two chunks)
  - Deterministic sampling for prevalence metric (stable cache hit-rate)

Decision criterion for the bigger swing (chunk-level revises field):
  Wilson CI lower-bound:
    <5%  → source-boost + recency-decay + curated pages handle the load
    5-15% → operator's call
    >15% → plan for v0.34+

New docs:
  - docs/contradictions.md (architecture, severity rubric, action criteria)
  - docs/eval-bench.md extended (nightly cadence + trend workflow)
  - skills/migrations/v0.33.0.md (post-upgrade agent instructions)

Full test suite green at the cut:
  - 226 hermetic unit tests across 15 files (eval-contradictions-*)
  - 12 real-Postgres E2E (DATABASE_URL=...; verified locally on pgvector:pg16)
  - typecheck clean
  - build:llms regenerated and the test/build-llms.test.ts gate passes

Plan reference:
  ~/.claude/plans/system-instruction-you-are-working-hashed-dewdrop.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: regen llms-full.txt for v0.32.6 rename

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:42:54 -07:00

309 lines
12 KiB
TypeScript

#!/usr/bin/env bun
/**
* scripts/build-contradictions-fixture.ts (v0.32.6, T2)
*
* Build a privacy-redacted gold fixture for the contradiction probe judge
* by running the probe against the user's REAL brain and hand-labeling
* the candidate pairs. Output: test/fixtures/contradictions-eval-gold.jsonl.
*
* Privacy posture (CLAUDE.md rule): the operator MUST inspect the
* generated file before commit. The redactor (fixture-redact.ts) is
* best-effort; the pre-commit review is the safety net. Fail-closed if
* any pair fails the isCleanForCommit check after redaction.
*
* Usage:
* bun run scripts/build-contradictions-fixture.ts \
* [--queries-file FILE.jsonl] \
* [--top-k N=5] \
* [--judge MODEL=claude-haiku-4-5] \
* [--max-pairs N=50] \
* [--output PATH=test/fixtures/contradictions-eval-gold.jsonl] \
* [--non-interactive]
*
* Interactive flow:
* - Probe runs with --no-cache (so candidate pairs aren't pre-judged).
* - For each candidate pair, the script prints A + B and prompts:
* y) contradiction, n) not contradiction, s) skip
* If y: prompt for severity (low|medium|high) and one-line axis.
* - After labeling, redact in-memory, write JSONL with audit comments.
* - Pre-commit safety: isCleanForCommit per line. Failures abort with
* a sentinel string the operator must resolve manually.
*
* Non-interactive flow (`--non-interactive`): captures candidates with
* NO labels, redacts, writes JSONL. Operator labels manually later.
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { createInterface } from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';
import { loadConfig, toEngineConfig } from '../src/core/config.ts';
import { createEngine } from '../src/core/engine-factory.ts';
import { connectWithRetry } from '../src/core/db.ts';
import type { BrainEngine } from '../src/core/engine.ts';
import { runContradictionProbe } from '../src/core/eval-contradictions/runner.ts';
async function connectLocalEngine(): Promise<BrainEngine> {
const cfg = loadConfig();
if (!cfg) throw new Error('No brain configured. Run `gbrain init` first.');
const engineCfg = toEngineConfig(cfg);
const engine = await createEngine(engineCfg);
await connectWithRetry(engine, engineCfg, { noRetry: false });
return engine;
}
import {
createRedactionSession,
isCleanForCommit,
redactSlug,
redactText,
} from '../src/core/eval-contradictions/fixture-redact.ts';
import type { ContradictionPair, Severity } from '../src/core/eval-contradictions/types.ts';
interface ParsedFlags {
queriesFile?: string;
topK: number;
judge: string;
maxPairs: number;
output: string;
nonInteractive: boolean;
help: boolean;
}
function parseFlags(argv: string[]): ParsedFlags {
const f: ParsedFlags = {
topK: 5,
judge: 'anthropic:claude-haiku-4-5',
maxPairs: 50,
output: 'test/fixtures/contradictions-eval-gold.jsonl',
nonInteractive: false,
help: false,
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
const next = (): string => {
const v = argv[++i];
if (v === undefined) throw new Error(`flag ${a} requires a value`);
return v;
};
if (a === '--help' || a === '-h') f.help = true;
else if (a === '--queries-file') f.queriesFile = next();
else if (a === '--top-k') f.topK = Number.parseInt(next(), 10);
else if (a === '--judge') f.judge = next();
else if (a === '--max-pairs') f.maxPairs = Number.parseInt(next(), 10);
else if (a === '--output') f.output = next();
else if (a === '--non-interactive') f.nonInteractive = true;
else throw new Error(`unknown flag: ${a}`);
}
return f;
}
function printHelp(): void {
process.stderr.write(`Build a privacy-redacted gold fixture for the contradiction probe judge.
Usage:
bun run scripts/build-contradictions-fixture.ts \\
--queries-file FILE.jsonl # one JSON object per line, {query: "..."}
[--top-k N=5]
[--judge MODEL=claude-haiku-4-5]
[--max-pairs N=50]
[--output PATH=test/fixtures/contradictions-eval-gold.jsonl]
[--non-interactive]
Output: JSONL with one labeled-and-redacted pair per line. Lines that
fail isCleanForCommit are marked with a sentinel string the operator
MUST resolve manually before commit. Audit log printed to stderr.
`);
}
function readQueriesFile(path: string): string[] {
const raw = readFileSync(path, 'utf8');
const out: string[] = [];
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) continue;
if (trimmed.startsWith('{')) {
try {
const parsed = JSON.parse(trimmed) as { query?: string };
if (typeof parsed.query === 'string' && parsed.query.length > 0) {
out.push(parsed.query);
}
} catch {
// ignore
}
} else {
out.push(trimmed);
}
}
return out;
}
async function promptLabel(rl: ReturnType<typeof createInterface>, pair: ContradictionPair): Promise<{
contradicts: boolean;
severity: Severity;
axis: string;
skip: boolean;
}> {
process.stderr.write(`\n--- Pair ---\n`);
process.stderr.write(`A (${pair.a.slug}): ${pair.a.text.slice(0, 240)}${pair.a.text.length > 240 ? '…' : ''}\n`);
process.stderr.write(`B (${pair.b.slug}): ${pair.b.text.slice(0, 240)}${pair.b.text.length > 240 ? '…' : ''}\n`);
const ans = (await rl.question('Contradiction? [y/n/s skip]: ')).trim().toLowerCase();
if (ans === 's' || ans === 'skip') {
return { contradicts: false, severity: 'low', axis: '', skip: true };
}
if (ans !== 'y' && ans !== 'yes') {
return { contradicts: false, severity: 'low', axis: '', skip: false };
}
let sev = (await rl.question('Severity [low/medium/high, default low]: ')).trim().toLowerCase();
if (sev !== 'low' && sev !== 'medium' && sev !== 'high') sev = 'low';
const axis = (await rl.question('One-line axis: ')).trim();
return { contradicts: true, severity: sev as Severity, axis, skip: false };
}
async function main(): Promise<void> {
let flags: ParsedFlags;
try {
flags = parseFlags(process.argv.slice(2));
} catch (err) {
process.stderr.write(`Error: ${(err as Error).message}\n`);
printHelp();
process.exit(2);
}
if (flags.help) {
printHelp();
return;
}
if (!flags.queriesFile) {
process.stderr.write(`--queries-file is required for the fixture build.\n`);
printHelp();
process.exit(2);
}
const queries = readQueriesFile(flags.queriesFile);
if (queries.length === 0) {
process.stderr.write(`No queries in ${flags.queriesFile}.\n`);
process.exit(2);
}
process.stderr.write(`Building gold fixture against the local brain.\n`);
process.stderr.write(`Queries: ${queries.length} Top-K: ${flags.topK} Max pairs: ${flags.maxPairs}\n`);
process.stderr.write(`Output: ${flags.output}\n\n`);
const engine = await connectLocalEngine();
try {
// Run the probe with --no-cache so we get candidate pairs without
// pre-judged verdicts. We don't keep verdicts; we hand-label every pair.
// We intercept pairs via judgeFn returning contradicts:false (so nothing
// is filtered to findings) and accumulating them for labeling instead.
const candidatePairs: ContradictionPair[] = [];
await runContradictionProbe({
engine,
queries,
judgeModel: flags.judge,
topK: flags.topK,
noCache: true,
// Wide budget so we don't hit cap during candidate collection.
budgetUsd: 100,
yesOverride: true,
// Hijack the judge to collect pairs without spending tokens.
judgeFn: async (input) => {
candidatePairs.push({
kind: 'cross_slug_chunks', // best-effort label; runner emits both kinds
a: { slug: input.a.slug, chunk_id: 0, take_id: null, source_tier: 'curated', holder: input.a.holder ?? null, text: input.a.text },
b: { slug: input.b.slug, chunk_id: 0, take_id: null, source_tier: 'curated', holder: input.b.holder ?? null, text: input.b.text },
combined_score: 0,
});
return {
verdict: { contradicts: false, severity: 'low', axis: '', confidence: 0, resolution_kind: null },
usage: { inputTokens: 0, outputTokens: 0 },
};
},
});
process.stderr.write(`\nCollected ${candidatePairs.length} candidate pairs.\n`);
const capped = candidatePairs.slice(0, flags.maxPairs);
// Label.
const rl = createInterface({ input, output });
const session = createRedactionSession();
const labeled: Array<{
contradicts: boolean;
severity: Severity;
axis: string;
query_redacted: string;
a: { slug: string; text: string };
b: { slug: string; text: string };
}> = [];
for (let i = 0; i < capped.length; i++) {
const pair = capped[i];
process.stderr.write(`\n[${i + 1}/${capped.length}]`);
let label: { contradicts: boolean; severity: Severity; axis: string; skip: boolean };
if (flags.nonInteractive) {
label = { contradicts: false, severity: 'low', axis: '', skip: false };
} else {
label = await promptLabel(rl, pair);
if (label.skip) continue;
}
const redactedA = {
slug: redactSlug(session, pair.a.slug),
text: redactText(session, pair.a.text),
};
const redactedB = {
slug: redactSlug(session, pair.b.slug),
text: redactText(session, pair.b.text),
};
labeled.push({
contradicts: label.contradicts,
severity: label.severity,
axis: redactText(session, label.axis),
// Query gets redacted too, in case it referenced real names.
query_redacted: '', // candidatePairs don't carry the query; populated by future iteration
a: redactedA,
b: redactedB,
});
}
rl.close();
// Pre-commit safety: every text field must pass isCleanForCommit.
const out: string[] = [];
let flagged = 0;
out.push(`# Gold fixture for contradiction probe judge (v0.32.6)`);
out.push(`# schema_version: 1`);
out.push(`# Generated: ${new Date().toISOString()}`);
out.push(`# Audit (in-memory redactions applied):`);
for (const entry of session.audit.slice(0, 100)) {
out.push(`# ${entry}`);
}
out.push(`# Total redactions: ${session.audit.length}`);
out.push(`#`);
for (const row of labeled) {
const cleanA = isCleanForCommit(row.a.text) && isCleanForCommit(row.a.slug);
const cleanB = isCleanForCommit(row.b.text) && isCleanForCommit(row.b.slug);
const sentinel = !cleanA || !cleanB ? ' [REDACT?]' : '';
if (sentinel) flagged++;
out.push(JSON.stringify({ ...row, ...(sentinel ? { _operator_review: 'REDACTION INCOMPLETE — fix manually before commit' } : {}) }));
}
// Ensure output dir exists, then write.
mkdirSync(dirname(flags.output), { recursive: true });
if (existsSync(flags.output)) {
process.stderr.write(`\nWARN: ${flags.output} already exists. Overwriting.\n`);
}
writeFileSync(flags.output, out.join('\n') + '\n');
process.stderr.write(`\nWrote ${labeled.length} labeled pairs to ${flags.output}.\n`);
if (flagged > 0) {
process.stderr.write(`*** ${flagged} pair(s) flagged with [REDACT?] — review before commit ***\n`);
process.exit(1);
}
process.stderr.write(`OK — pre-commit safety pass. Inspect the file once more before committing.\n`);
} finally {
await engine.disconnect();
}
}
main().catch((err) => {
process.stderr.write(`fatal: ${(err as Error).message}\n`);
process.exit(1);
});