Files
gbrain/src/core/eval-contradictions/judge.ts
T
dd1cc121d8 v0.35.3.1 feat(eval): temporal-aware contradiction probe + verdict enum (#1052)
* rfc: temporal axis for contradiction probe

Field report on residual HIGH findings from gbrain eval suspected-contradictions
and proposal for a 4-phase fix (Phase 1 = judge prompt + verdict enum is the
recommended starting point).

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

* feat(eval): pass effective_date to judge prompt; bump PROMPT_VERSION

Lane A1 of the temporal-contradiction-probe wave. Threads page-level
effective_date through the search projection into the contradiction judge so
the LLM can reason about supersession instead of treating every dated pair as
a contradiction.

Changes:
- SearchResult interface adds optional effective_date + effective_date_source
  fields; rowToSearchResult populates them from the row data with date-only
  YYYY-MM-DD normalization (handles both postgres.js Date and PGLite string).
- 8 SELECT projection sites (3 in postgres-engine, 5 in pglite-engine) now
  carry p.effective_date + p.effective_date_source through their inner CTEs
  and outer SELECTs so search results expose the field on both engines.
- PairMember (eval-contradictions/types.ts) gets the two fields as required
  (string | null) so the type forces every constructor to think about temporal
  anchoring. Runner's searchResultToMember + takeToMember handle the
  normalization; takes inherit the chunk's page-level date.
- buildJudgePrompt emits `Statement A (from: YYYY-MM-DD)` when effective_date
  is non-null, else `(date unknown)`. Prompt instructions explain the tag so
  the model knows what to do with it.
- PROMPT_VERSION bumps '1' → '2'. Cache-key tuple shape unchanged; old rows
  miss naturally on first run against the new prompt.

Test fixtures in 5 files updated to include the new required fields. All 205
eval-contradictions unit tests + 101 search-related tests pass. Typecheck
clean.

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

* feat(eval): replace contradicts:boolean with verdict:enum (6 members)

Lane A2 of the temporal-contradiction-probe wave. Expands the judge's
classification vocabulary from a binary contradicts:bool to a six-member
verdict enum so the probe can distinguish "this changed" from "this is wrong".

Verdict taxonomy:
  no_contradiction       — drop from findings
  contradiction          — genuine conflict at same point in time
  temporal_supersession  — newer claim updates/replaces older; not an error
  temporal_regression    — metric/status went backwards over time (signal)
  temporal_evolution     — legitimate change, neither supersession nor regression
  negation_artifact      — judge misread an explicit negation

Changes:
- types.ts: Verdict union (6 members); Severity gains 'info'; ResolutionKind
  extended with temporal_supersede, flag_for_review, log_timeline_change;
  JudgeVerdict.contradicts → verdict; ContradictionFinding now carries verdict;
  ProbeReport adds queries_with_any_finding + verdict_breakdown (additive).
- judge.ts: parseResolutionKind + parseVerdict guards; normalizeVerdict reads
  the new field and applies the C1 confidence floor only to verdict='contradiction'
  (the new verdicts are informational classifications, no floor). Prompt rubric
  rewritten to ask for verdict + extended severity scale.
- severity-classify.ts: 'info' joins the rank with value 0; defaultSeverityForVerdict
  maps each verdict to its baseline severity (D7 — supersession=info, regression=high,
  etc.). parseSeverity gains a fallback param so consumers can override 'low' default.
- auto-supersession.ts: classifyResolution + renderResolutionCommand handle the
  three new resolution kinds. Probe still NEVER auto-mutates — the new kinds
  render paste-ready commands or informational lines.
- cache.ts: isJudgeVerdict shape check matches the new verdict field; old v1
  rows fail the guard and treat as misses.
- runner.ts: emit predicate at cache-hit and judge-success branches changes
  from `verdict.contradicts` to `verdict.verdict !== 'no_contradiction'`.
  Without this, the new verdicts vanish from the report. Added per-verdict
  tally + queriesWithAnyFinding alongside the strict queriesWithContradiction.
- trends.ts: latest run verdict breakdown surfaces in the trend chart.

Test fixtures updated across 8 test files. All 210 eval-contradictions unit
tests pass. Typecheck clean.

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

* feat(eval): relax date-filter rule 3 when both sides dated

Lane B of the temporal-contradiction-probe wave. The v1 date pre-filter
skipped pairs whose chunk-text-extracted dates differed by >30 days as a
cost-saving heuristic. That heuristic silently killed exactly the cases the
new verdict taxonomy exists to surface — role transitions across years
(e.g. a 2017 historical record vs. a 2025 current state), MRR claims years
apart, status changes recorded over time.

Lane A1+A2 made temporal supersession explicit and cheap to classify. The
filter no longer needs to skip these pairs; the judge can label them.

Changes:
- date-filter.ts: shouldSkipForDateMismatch accepts optional effectiveDateA
  and effectiveDateB. When BOTH are non-null, returns skip=false with the new
  'both_have_effective_date' reason — the judge will see the dates via the
  (from: YYYY-MM-DD) prompt tag from Lane A1. Other rules (same-paragraph
  dual-date override, missing-date fallback) preserved verbatim and still
  run first.
- runner.ts: threads pair.{a,b}.effective_date into the date-filter call.
  Pairs that previously vanished into the skip bucket now reach the judge.

Tests (R1 IRON RULE regression suite, 6 new cases):
- both sides effective_date → not skipped
- both sides effective_date overrides >30d chunk-text rule
- rule 1 (same-paragraph dual-date) still wins over effective_date relaxation
- rule 2 (missing chunk dates) still applies when effective_date partially present
- undefined effective_dates fall through to v1 behavior (back-compat)
- empty-string effective_date treated as missing (only real dates enable the relaxation)

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

* feat(cli): cost-estimate prompt + --budget-usd + Haiku routing

Lane C of the temporal-contradiction-probe wave. Three layers of cost
guardrail, all stacked:

(a) cost-estimate prompt at probe-run-time. Before the runner spends any
    tokens after a PROMPT_VERSION change, eval-suspected-contradictions
    reads the most recent persisted prompt_version from
    eval_contradictions_runs and compares. When they differ:
      - TTY: prints an upper-bound estimate + Ctrl-C window (default 10s,
        override via GBRAIN_PROBE_PROMPT_GRACE_SECONDS).
      - non-TTY: prints the estimate + auto-proceeds (autopilot path).
      - --yes override or GBRAIN_NO_PROBE_PROMPT=1: skip entirely.
    Mirrors the v0.32.7 runPostUpgradeReembedPrompt pattern.

(b) --budget-usd N hard cap (pre-existing; PreFlightBudgetError surfaces
    when the estimate alone exceeds the cap, and CostTracker halts the
    run mid-flight when cumulative cost exceeds it). Documented in the
    help text alongside (a).

(c) Judge model now routes through resolveModel() with configKey
    'models.eval.contradictions_judge', tier 'utility' (Haiku-class
    default), and env var GBRAIN_CONTRADICTIONS_JUDGE_MODEL. The legacy
    --judge CLI flag still wins as the highest-precedence override.
    Doctor's model touchpoint registry (src/commands/models.ts:50) carries
    the new key so `gbrain models` and `gbrain models doctor` surface it.

Also in this lane:
- CLI: --severity accepts 'info' (the new Severity member from Lane A2).
- CLI: --severity output shows [verdict] tag alongside slug pairs so
  operators distinguish genuine contradictions from temporal classifications.
- Human summary: prints the new queries_with_any_finding metric and the
  per-verdict breakdown table.
- Help text: explains the cost-prompt + budget-cap + model-routing
  interactions in one paragraph.

New tests (9 cases on the cost-prompt helper):
- --yes override skips
- GBRAIN_NO_PROBE_PROMPT=1 skips
- prompt_version unchanged → skips
- non-TTY auto-proceeds with stderr note
- TTY proceeds after grace
- TTY aborts on Ctrl-C
- fresh brain (no prior runs) fires the prompt
- GBRAIN_PROBE_PROMPT_GRACE_SECONDS override honored
- estimate banner contains query count + judge model + dollar amount

All 225 eval-contradictions tests + 25 model-config tests pass. Typecheck clean.

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

* test(eval): R4/R5/R6 IRON-RULE regressions for the verdict-enum wave

Lane D of the temporal-contradiction-probe wave. The Lanes A1/A2/B/C lanes
landed the behavior; this lane pins the regressions that protect the wave
against future drift.

R4 (runner emit predicate): five new tests, one per non-no_contradiction
verdict, prove the runner.ts emit rule surfaces each one as a finding with
the correct verdict tag, and that:
  - queries_with_contradiction (Wilson-CI denominator) ONLY counts verdict
    ='contradiction' — the strict metric is preserved
  - queries_with_any_finding counts every non-no_contradiction verdict
  - verdict_breakdown tallies correctly
Plus one negative case: verdict='no_contradiction' produces zero findings.
Without R4, a future runner refactor could collapse the new verdicts back
to /dev/null and the report would silently shrink.

R5 (cache key shape): direct shape assertion on buildCacheKey output. The
key tuple is exactly 5 fields (chunk_a_hash, chunk_b_hash, model_id,
prompt_version, truncation_policy). Adding a 6th field would silently break
every operator's brain (no migration path).

R6 (contradiction severity unchanged): four tests on normalizeVerdict pin
the legacy semantics — judge-supplied severity wins (whether 'high' or
'low'), and on garbage severity input the fallback is 'medium' (per
defaultSeverityForVerdict('contradiction')) NOT 'low'. The contradiction
verdict's severity must never default to 'low', which would silently mask
genuine conflicts as cosmetic naming issues. The temporal_regression case
is included for parity (garbage → 'high' since regressions are real
investor red flags).

236 eval-contradictions tests pass (211 + 6 R4 + 1 R5 + 4 R6 + 9 cost-prompt
from Lane C).

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

* feat(ci): privacy lint for docs/proposals/*.md

Captures the residual TODO from the temporal-contradiction-probe wave's
plan: prevent the bug class where an RFC lands in docs/proposals/ with
PII that should never appear in a public technical artifact. The
original RFC had to be scrubbed at force-push time (Step 0); this lint
catches the same patterns at CI time so the next one can't slip through.

Sibling to scripts/check-privacy.sh:
- check-privacy.sh: bans the literal "Wintermute" repo-wide.
- check-proposal-pii.sh: focuses on docs/proposals/*.md and the OTHER
  PII classes — personal-relationship vocabulary, private repo refs.

Design contract: the denylist names PATTERNS, not real people. Naming
specific real names (deceased relatives, therapist first names,
dealflow contacts) inside this script would leak PII into the repo
just by appearing here. The structural patterns below catch the
SURROUNDING vocabulary that always accompanies such content in
personal RFC prose. Trade-off: a future RFC that names a real person
without any contextual markers won't be caught — accepted as residual
risk handled by human review.

Patterns flagged in docs/proposals/*.md:
- garrytan/brain (private repo reference)
- trial separation, permanent separation
- couples session, couples therapist
- divorce attorney(s)
- grandmother's funeral, aunt's funeral
- wintermute (also caught by check-privacy.sh; listed here for
  proposal-scoped clarity)

Bare common words (separation, funeral) are NOT banned — only the
combined personal-context phrases. "Separation of concerns" and other
software vocabulary survives.

Wired into:
- `bun run verify` (gates every push)
- `bun run check:all`
- `bun run check:proposal-pii` (standalone)

Tests: 15 cases in test/scripts/check-proposal-pii.test.ts.
- Each pattern flagged when present, plus exit-code + stderr signal.
- Two negative cases (separation-of-concerns, funeral metaphor) prove
  the lint doesn't false-positive on legitimate software prose.
- No-proposals-dir → exit 0 (not a failure).
- Multi-hit case proves all patterns surface together with a summary
  count.
- The two test fixtures that name "Wintermute" / "WINTERMUTE" as
  sentinel literals are allowlisted in check-test-real-names.sh per
  the same meta-rule-enforcement exception as check-privacy.sh itself.

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

* chore(privacy): allowlist new privacy-guard files in check-privacy.sh

check-privacy.sh bans the literal Wintermute repo-wide. The two new files
from the v0.34 privacy lint (scripts/check-proposal-pii.sh and its test)
necessarily name the token to do their job. Same meta-rule-enforcement
exception as scripts/check-privacy.sh itself, scripts/check-test-real-names.sh,
test/recency-decay.test.ts, and the existing entries — describing what
the rule forbids requires naming it.

Without this allowlist, `bun run verify` fails on check:privacy.

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

* chore: bump version and changelog (v0.35.1.0)

Temporal-contradiction-probe wave — Phase 1 of the RFC at
docs/proposals/temporal-contradiction-probe.md.

Headline: the contradiction probe now classifies pairs into a 6-member
verdict enum (no_contradiction, contradiction, temporal_supersession,
temporal_regression, temporal_evolution, negation_artifact) and sees the
page-level effective_date for each chunk via a (from: YYYY-MM-DD) tag in
the prompt. The pre-judge date filter no longer skips dated wide-gap pairs,
so the role-transition class (e.g. a 2017 historical record vs. a 2025
current state) reaches the judge and gets classified as
temporal_supersession instead of vanishing into the skip bucket.

PROMPT_VERSION bumped 1 → 2 (cache fully invalidated). Three-layer cost
guardrail: TTY-only cost-estimate prompt with Ctrl-C window, --budget-usd
hard cap, Haiku-tier routing via new models.eval.contradictions_judge
config key.

Also adds a CI privacy lint (scripts/check-proposal-pii.sh) wired into
bun run verify that catches PII patterns in docs/proposals/*.md so future
RFCs can't ship with personal-context vocabulary the way this wave's
source RFC did at draft time.

Phases 2-4 deferred to follow-up RFCs per the plan.

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

---------

Co-authored-by: garrytan-agents <garrytan-agents@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 08:32:03 -07:00

394 lines
16 KiB
TypeScript

/**
* eval-contradictions/judge — the LLM contradiction judge wrapper.
*
* One-call, one-pair: send Statement A + Statement B + the user's query to
* the chat gateway and parse the verdict JSON. The prompt is the canonical
* text bumped via PROMPT_VERSION when edits land.
*
* Codex fixes incorporated:
* - Query-conditioned: the judge sees the user's query so it can decide
* "contradiction relevant to what was asked" instead of free-form pair
* disagreement (Codex outside-voice finding).
* - Confidence floor double-enforcement (C1): if the model says
* contradicts: true with confidence < 0.7, the orchestrator downgrades
* to false. Belt-and-suspenders against models that ignore the prompt.
* - judge_errors as first-class: throws are typed and counted in the
* denominator — see judge-errors.ts for the collector shape.
*
* Provider-neutral via the gateway. Hermetically testable via
* gateway.__setChatTransportForTests.
*/
import { chat, type ChatResult } from '../ai/gateway.ts';
import { parseSeverity, defaultSeverityForVerdict } from './severity-classify.ts';
import type { JudgeVerdict, ResolutionKind, Verdict } from './types.ts';
const FENCE_RE = /```(?:json)?\s*\n?([\s\S]*?)```/i;
/**
* Generic 3-strategy LLM JSON parser. Throws when no strategy works rather
* than fabricating an empty object — caller maps to judge_errors.parse_fail.
*
* (We don't reuse parseModelJSON from cross-modal-eval because that one is
* shape-specific to {scores, overall, improvements} and rejects our verdict
* payload. Same 4-strategy spirit, narrower contract.)
*/
export function parseJudgeJSON(text: string): unknown {
if (!text) throw new Error('parseJudgeJSON: empty response');
// Strategy 1: direct parse (strict JSON).
try {
return JSON.parse(text);
} catch {
// fall through
}
// Strategy 2: strip ```json fences.
const fenceMatch = text.match(FENCE_RE);
if (fenceMatch && fenceMatch[1]) {
try {
return JSON.parse(fenceMatch[1].trim());
} catch {
// fall through
}
}
// Strategy 3: common-repairs pass — trailing commas, single→double quotes.
const cleaned = text
.replace(FENCE_RE, (_, inner) => inner)
.replace(/,(\s*[}\]])/g, '$1')
.replace(/(['"])?([\w-]+)\1?\s*:/g, '"$2":')
.trim();
// Extract the first {...} block if there's surrounding prose.
const braceMatch = cleaned.match(/\{[\s\S]*\}/);
if (braceMatch) {
try {
return JSON.parse(braceMatch[0]);
} catch {
// fall through
}
}
throw new Error('parseJudgeJSON: all strategies failed');
}
/** Default per-pair text budget (UTF-8-safe truncation). C4 default. */
export const DEFAULT_MAX_PAIR_CHARS = 1500;
/**
* UTF-8-safe truncation: cap at maxChars but never split a multi-byte
* character. Returns the text unchanged if already under the limit.
*
* Pattern reused from src/core/minions/handlers/subagent-audit.ts which
* faces the same multi-byte concern.
*/
export function truncateUtf8(text: string, maxChars: number): string {
if (!text) return '';
if (text.length <= maxChars) return text;
// Walk back from maxChars to land at a complete code-point boundary.
// UTF-16 surrogate pairs occupy two code units; if maxChars lands inside
// one, drop both halves so we don't keep half an emoji.
let end = maxChars;
if (end > 0 && end < text.length) {
const unitAtEnd = text.charCodeAt(end);
const unitBefore = text.charCodeAt(end - 1);
const isHighSurrogate = (c: number) => c >= 0xd800 && c <= 0xdbff;
const isLowSurrogate = (c: number) => c >= 0xdc00 && c <= 0xdfff;
// Case 1: about to split between high(end-1) and low(end) — drop both.
if (isHighSurrogate(unitBefore) && isLowSurrogate(unitAtEnd)) {
end -= 1;
} else if (isHighSurrogate(unitBefore)) {
// Stray high surrogate at end — drop it.
end -= 1;
} else if (isLowSurrogate(unitBefore)) {
// We're inside an emoji and end-1 is the low surrogate; back up to
// BEFORE the high surrogate (drop both halves).
end -= 2;
}
}
return text.slice(0, Math.max(0, end));
}
export interface JudgeInput {
/** The user's query for the search that retrieved both members. */
query: string;
/**
* Statement A: slug + text + optional source-tier + holder (if take) +
* optional effective_date (Lane A1). When effective_date is null/undefined
* the prompt shows `(date unknown)` for that side; the judge classifies
* based on chunk text alone, same as the v1 prompt did.
*/
a: {
slug: string;
text: string;
source_tier?: string;
holder?: string | null;
effective_date?: string | null;
};
b: {
slug: string;
text: string;
source_tier?: string;
holder?: string | null;
effective_date?: string | null;
};
/** Provider:model id; routed through gateway.chat. */
model: string;
/** UTF-8-safe truncation limit per pair member. C4 flag. */
maxPairChars?: number;
/** Test hook: pass a stubbed chat for hermetic tests. Production passes undefined → real gateway. */
chatFn?: typeof chat;
/** Abort signal for cancellation. */
abortSignal?: AbortSignal;
}
export interface JudgeOutput {
verdict: JudgeVerdict;
/** Token usage from the gateway. Forwarded to the cost tracker. */
usage: { inputTokens: number; outputTokens: number };
}
/**
* Validated resolution_kind values. Anything outside this set defaults to
* 'manual_review' (the safe, no-action option for contradictions; new verdicts
* route through auto-supersession.ts when resolution_kind is null on input).
*
* v0.34 / Lane A2: added temporal_supersede, flag_for_review, log_timeline_change.
*/
function parseResolutionKind(value: unknown): ResolutionKind | null {
if (
value === 'takes_supersede' ||
value === 'dream_synthesize' ||
value === 'takes_mark_debate' ||
value === 'manual_review' ||
value === 'temporal_supersede' ||
value === 'flag_for_review' ||
value === 'log_timeline_change'
) {
return value;
}
return null;
}
const VALID_VERDICTS: ReadonlySet<Verdict> = new Set([
'no_contradiction',
'contradiction',
'temporal_supersession',
'temporal_regression',
'temporal_evolution',
'negation_artifact',
]);
/** Validate a verdict string from JSON; throws on missing/invalid so caller maps to parse_fail. */
export function parseVerdict(value: unknown): Verdict {
if (typeof value !== 'string' || !VALID_VERDICTS.has(value as Verdict)) {
throw new Error(`judge JSON missing or invalid verdict: ${JSON.stringify(value)}`);
}
return value as Verdict;
}
/**
* Validate the raw parsed JSON against the JudgeVerdict shape. Throws on
* fundamentally-broken shape (missing verdict/confidence) so the caller
* counts it under judge_errors.parse_fail rather than fabricating a verdict.
*
* v0.34 / Lane A2: parses the new `verdict: Verdict` enum field instead of
* the v1 `contradicts: boolean`. PROMPT_VERSION = '2' (bumped in A1) means
* the persistent cache won't return v1-shaped rows for these calls.
*
* C1 enforcement: `verdict === 'contradiction'` with confidence < 0.7 is
* downgraded to `'no_contradiction'` (belt-and-suspenders against models
* ignoring the prompt rule). The 5 non-contradiction verdicts do NOT have a
* confidence floor — they're informational classifications, not error flags.
*/
export function normalizeVerdict(raw: unknown): JudgeVerdict {
if (!raw || typeof raw !== 'object') {
throw new Error('judge JSON missing or not an object');
}
const v = raw as Record<string, unknown>;
// Parse verdict first so we can throw a useful error before checking other
// fields. Old v1-shaped responses (`contradicts: true/false` without
// `verdict`) will throw here and the caller maps it to parse_fail — correct
// semantics because the prompt now asks for verdict explicitly.
let verdict = parseVerdict(v.verdict);
const rawConfidence = v.confidence;
if (typeof rawConfidence !== 'number' || !Number.isFinite(rawConfidence)) {
throw new Error('judge JSON missing or invalid confidence');
}
const clampedConfidence = Math.min(1, Math.max(0, rawConfidence));
const axisRaw = typeof v.axis === 'string' ? v.axis : '';
const resolutionKind = parseResolutionKind(v.resolution_kind);
// C1 double-enforce: only `verdict === 'contradiction'` carries the
// confidence floor. Downgrade to no_contradiction below the threshold.
if (verdict === 'contradiction' && clampedConfidence < 0.7) {
verdict = 'no_contradiction';
}
// Severity: judge can set it; if invalid, fall back to the default for the
// verdict (D7 map: temporal_supersession→info, temporal_regression→high, ...).
// parseSeverity coerces unknown strings to 'low' historically, but now we
// route through defaultSeverityForVerdict instead so each verdict gets a
// meaningful default.
const severity = parseSeverity(v.severity, defaultSeverityForVerdict(verdict));
const isFinding = verdict !== 'no_contradiction';
// Only `contradiction` keeps the v1 fallback to 'manual_review' when the
// judge omits a resolution_kind. The new verdicts pass through whatever the
// judge said (or null) and auto-supersession.ts picks the kind based on
// verdict semantics.
let normalizedResolutionKind: ResolutionKind | null;
if (verdict === 'contradiction') {
normalizedResolutionKind = resolutionKind ?? 'manual_review';
} else if (isFinding) {
normalizedResolutionKind = resolutionKind ?? null;
} else {
normalizedResolutionKind = null;
}
return {
verdict,
severity,
axis: isFinding ? axisRaw : '',
confidence: clampedConfidence,
resolution_kind: normalizedResolutionKind,
};
}
/**
* Build the judge prompt. Query-conditioned (Codex fix) — the model sees
* what the user actually asked so it can decide whether the disagreement is
* relevant to the query.
*
* Holder is shown when present (take pairs): "Garry holds X" vs "Garry
* holds not-X" is a flip; "Alice holds X" vs "Bob holds not-X" is not.
*/
export function buildJudgePrompt(opts: {
query: string;
a: {
slug: string;
text: string;
source_tier?: string;
holder?: string | null;
effective_date?: string | null;
};
b: {
slug: string;
text: string;
source_tier?: string;
holder?: string | null;
effective_date?: string | null;
};
maxPairChars: number;
}): string {
const a = truncateUtf8(opts.a.text, opts.maxPairChars);
const b = truncateUtf8(opts.b.text, opts.maxPairChars);
const aMeta = [opts.a.slug, opts.a.source_tier && `source-tier ${opts.a.source_tier}`, opts.a.holder && `holder ${opts.a.holder}`].filter(Boolean).join(', ');
const bMeta = [opts.b.slug, opts.b.source_tier && `source-tier ${opts.b.source_tier}`, opts.b.holder && `holder ${opts.b.holder}`].filter(Boolean).join(', ');
// Lane A1: emit the page-level effective_date on its own line so the judge
// can reason temporally. `(date unknown)` keeps the v1 fallback behavior
// when the page has no effective_date — judge classifies on text alone.
const aDateTag = opts.a.effective_date ? `(from: ${opts.a.effective_date})` : '(date unknown)';
const bDateTag = opts.b.effective_date ? `(from: ${opts.b.effective_date})` : '(date unknown)';
return [
'You are a contradiction judge for a personal knowledge brain. The user',
'ran a search and got two results back. Decide whether the two statements',
"contradict each other in a way that would mislead someone trying to",
"answer the user's query.",
'',
`User's query: ${opts.query}`,
'',
`Statement A ${aDateTag} (${aMeta}):`,
a,
'',
`Statement B ${bDateTag} (${bMeta}):`,
b,
'',
'Rules:',
'- The (from: YYYY-MM-DD) tag is the page-level effective date. Use it to',
' classify what kind of difference this is, not just whether it exists.',
' (date unknown) means the page has no temporal anchor — judge on text',
' alone for that side.',
'- Pick exactly one verdict from the six values below.',
'- Use temporal_supersession when the newer-dated claim updates or replaces',
' the older one (role change, status change). Not an error.',
'- Use temporal_regression when a metric or status went BACKWARDS over time',
' (e.g., MRR dropped from $200K to $150K). This is a signal worth flagging.',
'- Use temporal_evolution for legitimate change over time that is neither',
' supersession nor regression (e.g., evolving narrative, multi-step decision).',
'- Use negation_artifact when one side contains an explicit negation that',
' the surface tokens make look like a positive claim (e.g., "NOT X" parsed',
' as "X"). The data is correct; the apparent conflict is a parsing artifact.',
'- Use contradiction ONLY for genuinely conflicting claims at the same point',
' in time, where the dates do not explain the difference.',
'- Use no_contradiction when the statements are compatible.',
'',
'- Subjective opinions held at different times by the SAME holder may be',
' a contradiction (a flip). Opinions held by DIFFERENT holders are not.',
'- Different aspects of the same entity are not contradictions.',
"- Incidental disagreements unrelated to the user's query do not count.",
' Judge only on claims relevant to what the user asked.',
'',
'Reply with JSON ONLY:',
'{',
' "verdict": "no_contradiction" | "contradiction" | "temporal_supersession" | "temporal_regression" | "temporal_evolution" | "negation_artifact",',
' "severity": "info" | "low" | "medium" | "high",',
' "axis": "<one-line: what they disagree about, or empty>",',
' "confidence": 0.0..1.0,',
' "resolution_kind": "takes_supersede" | "dream_synthesize" | "takes_mark_debate" | "manual_review" | "temporal_supersede" | "flag_for_review" | "log_timeline_change" | null',
'}',
'',
'Severity rubric:',
'- info: temporal_supersession and temporal_evolution (not errors; informational).',
'- low: naming/format differences (Alice Smith vs A. Smith); negation artifacts.',
'- medium: factual values that may be stale (revenue, headcount).',
'- high: identity / structural claims (founder/CEO/CFO role); temporal_regression.',
'',
'Reply verdict:contradiction only when confidence >= 0.7. Other verdicts have',
'no confidence floor.',
].join('\n');
}
/** Detect refusal-shaped responses. Caller maps to judge_errors.refusal. */
function isRefusalResponse(result: ChatResult): boolean {
if (result.stopReason === 'refusal') return true;
const txt = result.text?.toLowerCase?.() ?? '';
return (
txt.includes("i can't help") ||
txt.includes('i cannot help') ||
txt.includes('refuse to answer')
);
}
/**
* Main entry. Calls the gateway, parses JSON, normalizes the verdict with
* C1 confidence enforcement. Throws on parse / refusal / transport errors;
* caller wraps in try/catch and records via JudgeErrorCollector.
*/
export async function judgeContradiction(input: JudgeInput): Promise<JudgeOutput> {
const maxPairChars = input.maxPairChars ?? DEFAULT_MAX_PAIR_CHARS;
// input.a/b carry effective_date through PairMember (Lane A1); buildJudgePrompt
// emits it on the Statement line or falls through to `(date unknown)`.
const prompt = buildJudgePrompt({
query: input.query,
a: input.a,
b: input.b,
maxPairChars,
});
const callFn = input.chatFn ?? chat;
const result = await callFn({
model: input.model,
messages: [{ role: 'user', content: prompt }],
maxTokens: 200,
abortSignal: input.abortSignal,
});
if (isRefusalResponse(result)) {
throw new Error('judge refused to answer');
}
const raw = parseJudgeJSON(result.text);
const verdict = normalizeVerdict(raw);
return {
verdict,
usage: {
inputTokens: result.usage.input_tokens ?? 0,
outputTokens: result.usage.output_tokens ?? 0,
},
};
}