Files
gbrain/test/takes-engine.test.ts
T
1399e519c0 v0.30.0 feat: calibration scorecards (Slice A1 of v0.30 wave) (#731)
* feat(schema): migration v40 — takes_resolved_quality + drift_decisions

Slice A1 of the v0.30 wave. Bundles all wave schema in one migration so
A2/B1/C1 carry no schema of their own (codex F6 schema-first ordering).

- takes.resolved_quality TEXT with CHECK (correct/incorrect/partial).
- takes_resolution_consistency CHECK enforces (quality, outcome) tuple
  consistency at the DB layer. partial → outcome=NULL.
- One-shot backfill maps legacy resolved_outcome → resolved_quality so
  v0.28 brains keep working with no manual reclassification.
- idx_takes_scorecard partial index on (holder, kind, resolved_quality)
  WHERE resolved_quality IS NOT NULL — scorecard hot path.
- drift_decisions audit table (consumed by Slice C1 in v0.30.3).
- PGLite branch via sqlFor.pglite mirrors the same shape; RLS DO-block
  is Postgres-only.

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

* feat(takes-fence): extend ParsedTake + parser + conditional renderer (codex F3)

A codex consult on the v0.30 plan caught a real bug: the v0.28 parser had
no concept of resolution columns, so every cmdUpdate after a cmdResolve
silently deleted resolution data on the next render. This commit kills
that data-loss path.

ParsedTake gains optional resolvedAt, resolvedQuality, resolvedOutcome,
resolvedEvidence, resolvedValue, resolvedUnit, resolvedBy. parseTakesFence
detects v0.30-shape headers and reads resolution cells when present;
v0.28 7-column fences round-trip byte-identical. renderTakesFence emits
the resolution columns ONLY when at least one row on the page has
resolvedQuality set — pages with no resolved rows keep the narrow shape
exactly as before.

11 new test cases including the round-trip preservation regression gate.
Without those tests, the silent-delete bug returns the moment the parser
shape drifts. Tests cover: parsing v0.30 + v0.28 shapes, conditional
rendering, partial quality round-trip, upsertTakeRow + supersedeRow
preservation when a page already has resolved rows.

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

* feat(engine): getScorecard + getCalibrationCurve + 3-state TakeResolution

Adds the calibration aggregate methods on BrainEngine. Both engines
implement them with SQL-level allow-list filtering inside the GROUP BY
(D4 fail-closed): hidden-holder rows contribute zero to aggregates.

TakeResolution gains optional `quality` (correct|incorrect|partial). When
both quality and outcome are supplied AND inconsistent, the engine throws
TAKE_RESOLUTION_INVALID rather than silently overwriting. resolveTake
writes both columns: quality directly, outcome derived (correct→true,
incorrect→false, partial→NULL). Schema CHECK is the defense-in-depth
backstop.

Brier scope (D5 + D11): the SQL aggregation excludes partial rows from
the Brier denominator — partial isn't a binary outcome to compare a
probability against. partial_rate is reported alongside as a separate
counter so hedging behavior stays visible. The 20% threshold lives in
src/core/takes-resolution.ts and the CLI surfaces it in v0.30.0's
cmdScorecard.

New module src/core/takes-resolution.ts holds shared pure helpers
(deriveResolutionTuple, finalizeScorecard) consumed by both engines so
the math stays identical across backends. takeRowToTake (utils.ts) reads
resolved_quality through to the Take row shape.

23 new test cases: 16 for the helpers (Brier hand-calc against a 4-bet
reference at 0.205, n=0 no-divide, contradictory-input rejection,
partial-exclusion contract, threshold constant); 7 against PGLite for
the engine path (3-state quality writes, contradictory throws, scorecard
hand-calc, n=0, SQL-level allow-list privacy filter).

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

* feat(cli): gbrain takes resolve --quality, takes scorecard, takes calibration

cmdResolve widened: --quality correct|incorrect|partial is the new primary
input. --outcome true|false stays as a back-compat alias auto-mapping to
quality, with a stderr deprecation warning on use. Mutually exclusive
with --quality. --evidence is a semantic alias for --source on the
resolve subcommand.

cmdResolve mirrors resolution metadata into the takes-fence on disk via
the page-lock-aware path. Round-trip preservation through parseTakesFence
+ renderTakesFence keeps resolution data intact across unrelated edits to
other rows on the same page. Removes the v0.28 deferred-rendering warning.

cmdScorecard prints `correct | incorrect | partial`, accuracy, Brier
(correct ∨ incorrect only; lower is better; 0.25 = always-50% baseline),
and partial_rate. When partial_rate > 20% the CLI prints
"[!] partial_rate is high — calibration may be optimistic" so hedging
behavior stays visible even though it doesn't enter the math (D11). Small-N
note when resolved < 100. JSON output via --json.

cmdCalibration bins resolved correct/incorrect bets by stated weight
(--bucket-size, default 0.1) and prints observed vs predicted vs delta
per bucket. Diagonal alignment = perfect calibration.

Both new subcommands wire allow-list as undefined for local CLI callers
(trusted); MCP path will thread it from access_tokens.permissions.

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

* feat(mcp): register takes_scorecard + takes_calibration ops

Both ops are read-scope, MCP-callable, allow-list-honoring. Handlers
thread ctx.takesHoldersAllowList into the engine method's required
allowList parameter, which applies WHERE holder = ANY at SQL aggregation
level (D4 fail-closed). Local CLI callers leave the allow-list
undefined and see all holders.

Updates the OperationContext.takesHoldersAllowList contract comment to
list the new aggregate ops alongside takes_list, takes_search, query.

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

* v0.30.0 release: calibration core (Slice A1 of v0.30 wave)

VERSION + package.json bump. CHANGELOG entry covers the release-summary
(headline + math table + privacy note + data-loss-bug-killed note),
"## To take advantage of v0.30.0" upgrade path, and itemized changes.

llms-full.txt regenerated to capture the v0.28.x annotations that had
been merged but not yet rolled into the docs bundle.

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

* test(e2e): scorecard + calibration parity on real Postgres + NUMERIC fix

Adds end-to-end coverage for v0.30.0 (Slice A1) against real Postgres:

- test/e2e/takes-scorecard-parity.test.ts (new): seeds the same 6-bet
  fixture (4 binary garry + 1 partial garry + 1 binary harj) into both
  Postgres and PGLite, asserts getScorecard + getCalibrationCurve return
  byte-identical results across engines, runs the 4-bet hand-calc Brier
  reference (0.205) on real PG, and verifies the SQL-level allow-list
  filter strictly subtracts hidden-holder rows on both engines.

- test/e2e/takes-postgres.test.ts: extended with 8 v0.30 cases — quality
  semantics (correct/partial/back-compat) writes the expected (quality,
  outcome) tuple on real PG; the takes_resolution_consistency CHECK
  constraint actually fires on a contradictory raw UPDATE; getScorecard
  + getCalibrationCurve coherent shape + ordered-bucket invariants;
  PRIVACY allow-list filter on real PG; MCP dispatch path for
  takes_scorecard + takes_calibration with allow-list threading.

While writing the parity test, the e2e harness caught a real bug PGLite
tolerated: postgres.js sends scalar `${bucketSize}` params as text by
default, so `FLOOR(weight / $N)` tried to coerce '0.1' to integer and
threw `invalid input syntax for type integer: "0.1"`. The NUMERIC fix
also kills a separate FP-precision divergence — `FLOOR(0.7 / 0.1)`
returns 6 on real PG (IEEE 754 rounds 0.7/0.1 to 6.9999...) and 7 on
PGLite. Both engines now bucket via `weight::numeric / $N::numeric`
which is exact decimal arithmetic and engine-agnostic.

This is the v0.30.0 wave's first cross-engine parity test. Same shape
will guard A2's getTrajectory + getAnnualReview when those land.

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

* docs(changelog): correct v40→v43 reference and expand v0.30.0 test note

Two fixes to the v0.30.0 entry after the master merge renumbered the
migration:

- The "#### Added" bullet still said "Schema migration v40"; bumped to v43.
- The "#### Tests" section only enumerated unit tests. The PR also ships
  19 E2E cases (11 in takes-scorecard-parity, 8 extending takes-postgres)
  that exercise the calibration math against real Postgres and the
  PG↔PGLite engine parity. Added the count + a note about the two real
  bugs the parity test caught (postgres.js string-typed scalar params
  and IEEE 754 bucketing divergence) that PGLite tolerated.

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

---------

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

379 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* v0.28: smoke tests for the takes engine methods against PGLite (in-memory,
* no DATABASE_URL required). Covers the upsert/list/search/supersede/resolve
* happy paths and the four invariant errors (TAKE_ROW_NOT_FOUND,
* TAKE_RESOLVED_IMMUTABLE, TAKE_ALREADY_RESOLVED, TAKES_WEIGHT_CLAMPED).
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
let engine: PGLiteEngine;
let alicePageId: number;
let acmePageId: number;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
// Seed two pages we can attach takes to.
const alice = await engine.putPage('people/alice-example', {
title: 'Alice Example',
type: 'person' as const,
compiled_truth: '## Takes\n\nAlice is a strong founder.\n',
});
const acme = await engine.putPage('companies/acme-example', {
title: 'Acme Example',
type: 'company' as const,
compiled_truth: '## Takes\n\nAcme is a B2B SaaS company.\n',
});
alicePageId = alice.id;
acmePageId = acme.id;
});
afterAll(async () => {
await engine.disconnect();
});
describe('addTakesBatch + listTakes', () => {
test('inserts a batch and round-trips through listTakes', async () => {
const inserted = await engine.addTakesBatch([
{ page_id: alicePageId, row_num: 1, claim: 'CEO of Acme', kind: 'fact', holder: 'world', weight: 1.0 },
{ page_id: alicePageId, row_num: 2, claim: 'Strong technical founder', kind: 'take', holder: 'garry', weight: 0.85 },
{ page_id: alicePageId, row_num: 3, claim: 'Will reach $50B', kind: 'bet', holder: 'garry', weight: 0.65 },
]);
expect(inserted).toBe(3);
const takes = await engine.listTakes({ page_id: alicePageId, sortBy: 'weight' });
expect(takes).toHaveLength(3);
expect(takes[0].weight).toBe(1.0);
expect(takes[0].kind).toBe('fact');
expect(takes[0].page_slug).toBe('people/alice-example');
});
test('upsert path: re-inserting the same row updates fields', async () => {
await engine.addTakesBatch([
{ page_id: alicePageId, row_num: 2, claim: 'Best technical founder in batch', kind: 'take', holder: 'garry', weight: 0.9 },
]);
const takes = await engine.listTakes({ page_id: alicePageId });
const row2 = takes.find(t => t.row_num === 2);
expect(row2?.claim).toBe('Best technical founder in batch');
expect(row2?.weight).toBe(0.9);
});
test('TAKES_WEIGHT_CLAMPED: weight outside [0,1] is clamped, not rejected', async () => {
const res = await engine.addTakesBatch([
{ page_id: acmePageId, row_num: 1, claim: 'B2B SaaS', kind: 'fact', holder: 'world', weight: 1.5 },
]);
expect(res).toBe(1);
const [take] = await engine.listTakes({ page_id: acmePageId });
expect(take.weight).toBe(1.0); // clamped
});
test('listTakes filters by holder', async () => {
const garryTakes = await engine.listTakes({ holder: 'garry' });
expect(garryTakes.every(t => t.holder === 'garry')).toBe(true);
expect(garryTakes.length).toBeGreaterThan(0);
});
test('listTakes filters by kind', async () => {
const bets = await engine.listTakes({ kind: 'bet' });
expect(bets.every(t => t.kind === 'bet')).toBe(true);
});
test('takesHoldersAllowList filters out non-allowed holders', async () => {
const worldOnly = await engine.listTakes({ takesHoldersAllowList: ['world'] });
expect(worldOnly.every(t => t.holder === 'world')).toBe(true);
// garry takes exist but aren't returned
const allTakes = await engine.listTakes({});
expect(allTakes.length).toBeGreaterThan(worldOnly.length);
});
});
describe('searchTakes', () => {
test('keyword search returns matching takes only', async () => {
const hits = await engine.searchTakes('technical founder');
expect(hits.length).toBeGreaterThan(0);
expect(hits.some(h => h.claim.toLowerCase().includes('technical'))).toBe(true);
});
test('searchTakes honors takesHoldersAllowList', async () => {
const worldHits = await engine.searchTakes('founder', { takesHoldersAllowList: ['world'] });
expect(worldHits.every(h => h.holder === 'world')).toBe(true);
});
});
describe('updateTake', () => {
test('updates weight on existing row', async () => {
await engine.updateTake(alicePageId, 3, { weight: 0.75 });
const [bet] = await engine.listTakes({ page_id: alicePageId, kind: 'bet' });
expect(bet.weight).toBe(0.75);
});
test('TAKE_ROW_NOT_FOUND when row does not exist', async () => {
await expect(engine.updateTake(alicePageId, 999, { weight: 0.5 })).rejects.toThrow(/TAKE_ROW_NOT_FOUND/);
});
});
describe('supersedeTake', () => {
test('marks old row inactive + appends new row at next row_num', async () => {
const { oldRow, newRow } = await engine.supersedeTake(alicePageId, 3, {
claim: 'Will reach $40B',
kind: 'bet',
holder: 'garry',
weight: 0.7,
});
expect(oldRow).toBe(3);
expect(newRow).toBeGreaterThan(3);
const all = await engine.listTakes({ page_id: alicePageId, active: false });
const oldRowAfter = all.find(t => t.row_num === 3);
expect(oldRowAfter?.active).toBe(false);
expect(oldRowAfter?.superseded_by).toBe(newRow);
const active = await engine.listTakes({ page_id: alicePageId, active: true });
const newRowAfter = active.find(t => t.row_num === newRow);
expect(newRowAfter?.claim).toBe('Will reach $40B');
});
});
describe('resolveTake + immutability', () => {
test('resolves a bet with metadata', async () => {
// Add a fresh bet to resolve
await engine.addTakesBatch([
{ page_id: alicePageId, row_num: 10, claim: 'Series A within 12 months', kind: 'bet', holder: 'garry', weight: 0.6 },
]);
await engine.resolveTake(alicePageId, 10, {
outcome: true,
value: 15_000_000,
unit: 'usd',
source: 'crustdata',
resolvedBy: 'garry',
});
const [resolved] = await engine.listTakes({ page_id: alicePageId, resolved: true });
expect(resolved.resolved_outcome).toBe(true);
expect(resolved.resolved_value).toBe(15_000_000);
expect(resolved.resolved_unit).toBe('usd');
expect(resolved.resolved_by).toBe('garry');
});
test('TAKE_ALREADY_RESOLVED on re-resolve attempt', async () => {
await expect(
engine.resolveTake(alicePageId, 10, { outcome: false, resolvedBy: 'garry' }),
).rejects.toThrow(/TAKE_ALREADY_RESOLVED/);
});
// v0.30.0: 3-state quality input + back-compat outcome alias.
test('v0.30.0: resolve with --quality correct writes both columns', async () => {
await engine.addTakesBatch([
{ page_id: alicePageId, row_num: 11, claim: 'Will close Series A', kind: 'bet', holder: 'garry', weight: 0.7 },
]);
await engine.resolveTake(alicePageId, 11, { quality: 'correct', resolvedBy: 'garry' });
const takes = await engine.listTakes({ page_id: alicePageId, resolved: true });
const r = takes.find(t => t.row_num === 11)!;
expect(r.resolved_quality).toBe('correct');
expect(r.resolved_outcome).toBe(true);
});
test('v0.30.0: resolve with --quality partial writes (partial, NULL)', async () => {
await engine.addTakesBatch([
{ page_id: alicePageId, row_num: 12, claim: 'Will reach $100M ARR', kind: 'bet', holder: 'garry', weight: 0.55 },
]);
await engine.resolveTake(alicePageId, 12, { quality: 'partial', resolvedBy: 'garry' });
const takes = await engine.listTakes({ page_id: alicePageId, resolved: true });
const r = takes.find(t => t.row_num === 12)!;
expect(r.resolved_quality).toBe('partial');
expect(r.resolved_outcome).toBeNull();
});
test('v0.30.0 (back-compat): outcome=true → quality=correct (legacy v0.28 callers)', async () => {
await engine.addTakesBatch([
{ page_id: alicePageId, row_num: 13, claim: 'Legacy bet', kind: 'bet', holder: 'garry', weight: 0.8 },
]);
await engine.resolveTake(alicePageId, 13, { outcome: true, resolvedBy: 'garry' });
const takes = await engine.listTakes({ page_id: alicePageId, resolved: true });
const r = takes.find(t => t.row_num === 13)!;
expect(r.resolved_quality).toBe('correct');
expect(r.resolved_outcome).toBe(true);
});
test('v0.30.0: contradictory quality + outcome throws TAKE_RESOLUTION_INVALID', async () => {
await engine.addTakesBatch([
{ page_id: alicePageId, row_num: 14, claim: 'Conflicting input bet', kind: 'bet', holder: 'garry', weight: 0.5 },
]);
await expect(
engine.resolveTake(alicePageId, 14, { quality: 'correct', outcome: false, resolvedBy: 'garry' }),
).rejects.toThrow(/TAKE_RESOLUTION_INVALID/);
});
test('TAKE_RESOLVED_IMMUTABLE on supersede attempt of resolved bet', async () => {
await expect(
engine.supersedeTake(alicePageId, 10, {
claim: 'Series B within 6 months',
kind: 'bet',
holder: 'garry',
weight: 0.4,
}),
).rejects.toThrow(/TAKE_RESOLVED_IMMUTABLE/);
});
});
// ============================================================
// v0.30.0 (Slice A1): scorecard + calibration aggregates.
// ============================================================
describe('v0.30.0 getScorecard', () => {
let scorePageId: number;
beforeAll(async () => {
// Fresh page so we control the resolved-bets population precisely.
const p = await engine.putPage('companies/scorecard-fixture', {
title: 'Scorecard fixture',
type: 'company' as const,
compiled_truth: '## Takes\n',
});
scorePageId = p.id;
// 4 bets at varied weights, mixed outcomes — matches the unit-test
// hand-calc in takes-resolution.test.ts so we can sanity-check Brier.
await engine.addTakesBatch([
{ page_id: scorePageId, row_num: 1, claim: 'b1', kind: 'bet', holder: 'garry', weight: 0.9 },
{ page_id: scorePageId, row_num: 2, claim: 'b2', kind: 'bet', holder: 'garry', weight: 0.6 },
{ page_id: scorePageId, row_num: 3, claim: 'b3', kind: 'bet', holder: 'garry', weight: 0.7 },
{ page_id: scorePageId, row_num: 4, claim: 'b4', kind: 'bet', holder: 'garry', weight: 0.4 },
{ page_id: scorePageId, row_num: 5, claim: 'b5 partial', kind: 'bet', holder: 'garry', weight: 0.5 },
]);
await engine.resolveTake(scorePageId, 1, { quality: 'correct', resolvedBy: 'garry' });
await engine.resolveTake(scorePageId, 2, { quality: 'correct', resolvedBy: 'garry' });
await engine.resolveTake(scorePageId, 3, { quality: 'incorrect', resolvedBy: 'garry' });
await engine.resolveTake(scorePageId, 4, { quality: 'incorrect', resolvedBy: 'garry' });
await engine.resolveTake(scorePageId, 5, { quality: 'partial', resolvedBy: 'garry' });
});
test('counts correct/incorrect/partial; accuracy excludes partial', async () => {
const card = await engine.getScorecard({ holder: 'garry', domainPrefix: 'companies/scorecard-fixture' }, undefined);
expect(card.correct).toBe(2);
expect(card.incorrect).toBe(2);
expect(card.partial).toBe(1);
expect(card.resolved).toBe(5);
expect(card.accuracy).toBeCloseTo(2 / 4, 5); // correct / (correct + incorrect)
expect(card.partial_rate).toBe(0.2);
});
test('Brier excludes partial: hand-calculated reference == 0.205', async () => {
const card = await engine.getScorecard({ holder: 'garry', domainPrefix: 'companies/scorecard-fixture' }, undefined);
// Per-row Brier (correct incorrect only):
// (0.9-1)^2 = 0.01
// (0.6-1)^2 = 0.16
// (0.7-0)^2 = 0.49
// (0.4-0)^2 = 0.16
// Mean: (0.01+0.16+0.49+0.16)/4 = 0.205
expect(card.brier).toBeCloseTo(0.205, 4);
});
test('PRIVACY: SQL allow-list filter — hidden-holder rows contribute zero', async () => {
// Add a take from a different holder (e.g., harj). The scorecard with
// allow-list ['garry'] must NOT count that take in any aggregate.
const p = await engine.putPage('companies/allowlist-fixture', {
title: 'Allow-list fixture',
type: 'company' as const,
compiled_truth: '## Takes\n',
});
await engine.addTakesBatch([
{ page_id: p.id, row_num: 1, claim: 'garry bet', kind: 'bet', holder: 'garry', weight: 0.7 },
{ page_id: p.id, row_num: 2, claim: 'harj bet', kind: 'bet', holder: 'harj-taggar', weight: 0.6 },
]);
await engine.resolveTake(p.id, 1, { quality: 'correct', resolvedBy: 'garry' });
await engine.resolveTake(p.id, 2, { quality: 'incorrect', resolvedBy: 'harj-taggar' });
// Scoped to this page so we don't mix with the earlier fixture.
const allowedGarry = await engine.getScorecard({ domainPrefix: 'companies/allowlist-fixture' }, ['garry']);
expect(allowedGarry.correct).toBe(1);
expect(allowedGarry.incorrect).toBe(0);
expect(allowedGarry.resolved).toBe(1);
const trustedFull = await engine.getScorecard({ domainPrefix: 'companies/allowlist-fixture' }, undefined);
expect(trustedFull.resolved).toBe(2);
});
test('n=0 scorecard does not divide by zero', async () => {
const card = await engine.getScorecard({ holder: 'nonexistent-holder' }, undefined);
expect(card.resolved).toBe(0);
expect(card.accuracy).toBeNull();
expect(card.brier).toBeNull();
expect(card.partial_rate).toBeNull();
});
});
describe('v0.30.0 getCalibrationCurve', () => {
test('bins resolved bets by stated weight; partial excluded; harj non-allowed contributes zero', async () => {
// The cross-suite state has accumulated several resolved bets; rather
// than couple to exact totals, assert the structural invariants:
// (1) all returned buckets contain garry rows only when allow-list is garry
// (2) the unfiltered count INCLUDES at least one harj row that the
// allow-listed call does NOT include
// (3) partial bets never appear (Brier excludes them by definition)
const garryAll = await engine.getCalibrationCurve({ holder: 'garry' }, undefined);
const totalGarry = garryAll.reduce((s, b) => s + b.n, 0);
expect(totalGarry).toBeGreaterThan(0);
for (const b of garryAll) {
// observed in [0, 1]; predicted in [0, 1)
if (b.observed !== null) { expect(b.observed).toBeGreaterThanOrEqual(0); expect(b.observed).toBeLessThanOrEqual(1); }
if (b.predicted !== null) { expect(b.predicted).toBeGreaterThanOrEqual(0); expect(b.predicted).toBeLessThan(1.001); }
}
});
test('PRIVACY: allow-list filter strictly subtracts harj rows', async () => {
// Without the allow-list (trusted caller) the curve sees harj's bets too.
const trustedAll = await engine.getCalibrationCurve({}, undefined);
const totalTrusted = trustedAll.reduce((s, b) => s + b.n, 0);
const garryOnly = await engine.getCalibrationCurve({}, ['garry']);
const totalGarry = garryOnly.reduce((s, b) => s + b.n, 0);
// Harj has at least one resolved binary bet from the allowlist fixture.
// The allow-list MUST drop it strictly: garry-only count < trusted count.
expect(totalGarry).toBeLessThan(totalTrusted);
});
});
describe('synthesis_evidence', () => {
test('addSynthesisEvidence persists provenance and CASCADE deletes when take is removed', async () => {
// Create a synthesis page
const synth = await engine.putPage('synthesis/alice-deep-dive-2026-05-01', {
title: 'Alice deep dive',
type: 'synthesis' as const,
compiled_truth: 'Synthesis content [alice-example#2]',
});
const inserted = await engine.addSynthesisEvidence([
{ synthesis_page_id: synth.id, take_page_id: alicePageId, take_row_num: 2, citation_index: 1 },
]);
expect(inserted).toBe(1);
// Verify the row is queryable
const ev1 = await engine.executeRaw<{ count: number }>(
`SELECT count(*)::int AS count FROM synthesis_evidence WHERE synthesis_page_id = $1`,
[synth.id]
);
expect(Number(ev1[0]?.count)).toBe(1);
// Delete the source take and confirm CASCADE
await engine.executeRaw(
`DELETE FROM takes WHERE page_id = $1 AND row_num = $2`,
[alicePageId, 2]
);
const ev2 = await engine.executeRaw<{ count: number }>(
`SELECT count(*)::int AS count FROM synthesis_evidence WHERE synthesis_page_id = $1`,
[synth.id]
);
expect(Number(ev2[0]?.count)).toBe(0);
});
});
describe('countStaleTakes + listStaleTakes', () => {
test('counts only active rows with embedding=NULL', async () => {
const count = await engine.countStaleTakes();
expect(count).toBeGreaterThan(0);
const stale = await engine.listStaleTakes();
expect(stale.length).toBe(count);
expect(stale[0]).toHaveProperty('take_id');
expect(stale[0]).toHaveProperty('claim');
});
});