Files
gbrain/test/propose-takes.test.ts
T
Garry TanandClaude Opus 4.7 0fdcc54dde cycle: propose_takes phase + take_proposals queue write path (T3)
LLM-based take extraction from markdown prose. Walks pages updated since
last cycle, sends each page's body to a tuned extractor, writes the
extracted gradeable claims to the take_proposals queue. User accepts /
rejects via `gbrain takes propose --review` (lands in Lane C).

Cycle wiring:
  lint → backlinks → sync → synthesize → extract → extract_facts →
    resolve_symbol_edges → patterns → recompute_emotional_weight →
    consolidate → propose_takes (NEW) → grade_takes (NEW; T4) →
    calibration_profile (NEW; T6) → embed → orphans → purge

CyclePhase enum extended with 3 new entries; ALL_PHASES + NEEDS_LOCK_PHASES
updated. All three new phases acquire the cycle lock (writes to
take_proposals / take_grade_cache / calibration_profiles).

Idempotency contract:
  The (source_id, page_slug, content_hash, prompt_version) composite unique
  index on take_proposals means an unchanged page never re-spends LLM
  tokens. Bumping PROPOSE_TAKES_PROMPT_VERSION cleanly invalidates the
  cache so a tuned prompt re-runs proposals on every page. Mirrors the
  v0.23 dream_verdicts pattern.

F2 fence dedup:
  The phase reads the page's existing `<!-- gbrain:takes:begin -->` fence
  (when present) and passes the canonical take rows to the extractor as
  "things you have already captured." Prevents duplicate proposals when
  prose is appended to a page that already has takes. Records the fence
  rows the LLM was told to dedupe against on the take_proposals row for
  audit (dedup_against_fence_rows JSONB).

Auto-resolve posture:
  propose_takes only WRITES proposals to the queue. Nothing in this phase
  mutates the canonical takes table. Operator opt-in via the queue review
  CLI (Lane C) is the only path from queue to canonical fence (D17).

Prompt tuning status (v0.36.0.0 ship state):
  The default extractor prompt is annotated `v0.36.0.0-stub`. The real
  tuned prompt arrives via T19 synthetic corpus build (50 anonymized
  pages, 3-model parallel extraction, user reviews disagreement set,
  F1 ≥ 0.85 on training corpus + F1 ≥ 0.8 on ground-truth holdout).
  Until T19 lands, propose_takes runs but produces best-effort candidates
  the user reviews manually.

Architecture:
  ProposeTakesPhase extends BaseCyclePhase (T2). Inherits source-scope
  threading via scope(), budget metering via this.checkBudget(), error
  envelope wrapping. budgetUsdKey: cycle.propose_takes.budget_usd
  (default $5/cycle). Budget exhaustion mid-page returns status='warn'
  with details.budget_exhausted=true — clean partial-completion semantics.

  Test seam: opts.extractor injection so the phase can run hermetically
  without touching the gateway. defaultExtractor (production path) calls
  gateway.chat with the EXTRACT_TAKES_PROMPT and parses the JSON array
  output via parseExtractorOutput.

  parseExtractorOutput defends against common LLM output sins: markdown
  code fence wrapping, leading prose, single-object instead of array,
  unknown kind values, weight out of [0,1], rows missing claim_text or
  exceeding 500 chars.

Tests: 25 cases in test/propose-takes.test.ts cover the 4 pure helpers
(parseExtractorOutput, contentHash, hasCompleteFence,
extractExistingTakesForDedup) + 7 phase integration scenarios (happy path,
cache hit, fence dedup, extractor failure, empty pages, skipPagesWithFence,
proposal_run_id stability).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:03:42 -07:00

386 lines
15 KiB
TypeScript

/**
* v0.36.0.0 (T3) — propose_takes phase unit tests.
*
* Pure structural tests against a mock BrainEngine + injected extractor.
* No real LLM gateway, no PGLite — the phase's contract is exercised through
* the public surface and the engine's executeRaw/listPages stubs.
*
* Tests cover:
* - happy path: extracts proposals, writes via executeRaw with idempotency clause
* - cache hit path: skip pages already in take_proposals (F2 idempotency)
* - fence dedup: existing fence rows pass through to extractor as context
* - budget exhaustion mid-page: phase aborts cleanly with warn status
* - extractor parse failures: warning logged, phase continues
* - parseExtractorOutput unit tests for the raw JSON parser
*/
import { describe, test, expect } from 'bun:test';
import {
runPhaseProposeTakes,
parseExtractorOutput,
contentHash,
hasCompleteFence,
extractExistingTakesForDedup,
PROPOSE_TAKES_PROMPT_VERSION,
type ProposeTakesExtractor,
type ProposedTake,
} from '../src/core/cycle/propose-takes.ts';
import type { OperationContext } from '../src/core/operations.ts';
import type { BrainEngine } from '../src/core/engine.ts';
import type { Page } from '../src/core/types.ts';
// ─── Mock engine ────────────────────────────────────────────────────
interface CapturedSql {
sql: string;
params: unknown[];
}
function buildMockEngine(opts: {
pages: Page[];
existingProposals?: Set<string>; // composite-key strings already in take_proposals
}): { engine: BrainEngine; captured: CapturedSql[] } {
const captured: CapturedSql[] = [];
const existing = opts.existingProposals ?? new Set<string>();
const engine = {
kind: 'pglite',
async listPages() {
return opts.pages;
},
async executeRaw<T>(sql: string, params?: unknown[]): Promise<T[]> {
captured.push({ sql, params: params ?? [] });
// SELECT idempotency check
if (sql.includes('SELECT id FROM take_proposals')) {
const [sourceId, slug, ch, pv] = params ?? [];
const key = `${sourceId}|${slug}|${ch}|${pv}`;
if (existing.has(key)) return [{ id: 1 } as unknown as T];
return [];
}
// INSERT — return nothing
return [];
},
} as unknown as BrainEngine;
return { engine, captured };
}
function buildPage(opts: { slug: string; body: string; sourceId?: string }): Page {
return {
id: 1,
slug: opts.slug,
type: 'analysis',
title: opts.slug,
compiled_truth: opts.body,
timeline: '',
frontmatter: {},
source_id: opts.sourceId ?? 'default',
created_at: new Date(),
updated_at: new Date(),
} as Page;
}
function buildCtx(engine: BrainEngine): OperationContext {
return {
engine,
config: {} as never,
logger: { info() {}, warn() {}, error() {} } as never,
dryRun: false,
remote: false,
sourceId: 'default',
};
}
// ─── parseExtractorOutput ───────────────────────────────────────────
describe('parseExtractorOutput', () => {
test('parses a clean JSON array', () => {
const raw = '[{"claim_text":"Cities send messages","kind":"take","holder":"brain","weight":0.65}]';
const out = parseExtractorOutput(raw);
expect(out).toHaveLength(1);
expect(out[0]!.claim_text).toBe('Cities send messages');
expect(out[0]!.kind).toBe('take');
expect(out[0]!.weight).toBe(0.65);
});
test('strips markdown code fence wrapping', () => {
const raw = '```json\n[{"claim_text":"X","kind":"bet","holder":"world","weight":0.8}]\n```';
const out = parseExtractorOutput(raw);
expect(out).toHaveLength(1);
});
test('accepts a single object as a one-element array', () => {
const raw = '{"claim_text":"Y","kind":"hunch","holder":"brain","weight":0.4}';
const out = parseExtractorOutput(raw);
expect(out).toHaveLength(1);
expect(out[0]!.kind).toBe('hunch');
});
test('skips leading prose before the JSON', () => {
const raw = 'Here are the takes:\n\n[{"claim_text":"Z","kind":"take","holder":"brain","weight":0.5}]';
const out = parseExtractorOutput(raw);
expect(out).toHaveLength(1);
});
test('returns [] on empty input', () => {
expect(parseExtractorOutput('')).toEqual([]);
expect(parseExtractorOutput(' ')).toEqual([]);
});
test('returns [] on malformed JSON without throwing', () => {
expect(parseExtractorOutput('[not valid json')).toEqual([]);
expect(parseExtractorOutput('completely unrelated prose')).toEqual([]);
});
test('drops rows without claim_text and rows over 500 chars', () => {
const longClaim = 'x'.repeat(600);
const raw = JSON.stringify([
{ kind: 'take', holder: 'brain', weight: 0.5 }, // no claim_text
{ claim_text: longClaim, kind: 'take', holder: 'brain', weight: 0.5 },
{ claim_text: 'valid', kind: 'take', holder: 'brain', weight: 0.5 },
]);
expect(parseExtractorOutput(raw)).toHaveLength(1);
});
test('coerces unknown kind to "take" and clamps weight to [0,1]', () => {
const raw = JSON.stringify([
{ claim_text: 'a', kind: 'unknown_kind', holder: 'brain', weight: 2.5 },
{ claim_text: 'b', kind: 'take', holder: 'brain', weight: -0.5 },
]);
const out = parseExtractorOutput(raw);
expect(out[0]!.kind).toBe('take');
expect(out[0]!.weight).toBe(1);
expect(out[1]!.weight).toBe(0);
});
test('preserves optional domain field', () => {
const raw = '[{"claim_text":"X","kind":"take","holder":"brain","weight":0.5,"domain":"macro"}]';
const out = parseExtractorOutput(raw);
expect(out[0]!.domain).toBe('macro');
});
});
// ─── contentHash ────────────────────────────────────────────────────
describe('contentHash', () => {
test('produces deterministic SHA-256 hex', () => {
const h1 = contentHash('hello world');
const h2 = contentHash('hello world');
expect(h1).toBe(h2);
expect(h1).toHaveLength(64);
expect(h1).toMatch(/^[0-9a-f]+$/);
});
test('different input produces different hash', () => {
expect(contentHash('a')).not.toBe(contentHash('b'));
});
});
// ─── hasCompleteFence ───────────────────────────────────────────────
describe('hasCompleteFence', () => {
test('detects a well-formed fence', () => {
const body = `# Page
<!-- gbrain:takes:begin -->
| # | claim | kind | who | weight | since | source |
|---|-------|------|-----|--------|-------|--------|
| 1 | X | take | brain | 0.5 | 2026-01 | |
<!-- gbrain:takes:end -->
prose continues
`;
expect(hasCompleteFence(body)).toBe(true);
});
test('returns false when fence is incomplete (begin only)', () => {
expect(hasCompleteFence('<!-- gbrain:takes:begin -->\n| #')).toBe(false);
});
test('returns false when no fence at all', () => {
expect(hasCompleteFence('just some prose')).toBe(false);
});
test('detects fence with triple-dash variant', () => {
expect(hasCompleteFence('<!--- gbrain:takes:begin -->\n| # |\n<!--- gbrain:takes:end -->')).toBe(true);
});
});
// ─── extractExistingTakesForDedup ───────────────────────────────────
describe('extractExistingTakesForDedup', () => {
test('returns [] when no fence present', () => {
expect(extractExistingTakesForDedup('plain prose')).toEqual([]);
});
test('parses active rows from a well-formed fence', () => {
const body = `<!-- gbrain:takes:begin -->
| # | claim | kind | who | weight | since | source |
|---|-------|------|-----|--------|-------|--------|
| 1 | Cities send messages | take | brain | 0.65 | 2026-01 | essay |
| 2 | Y will happen | bet | garry | 0.8 | 2026-01 | |
<!-- gbrain:takes:end -->`;
const out = extractExistingTakesForDedup(body);
expect(out).toHaveLength(2);
expect(out[0]!.claim).toBe('Cities send messages');
expect(out[0]!.kind).toBe('take');
expect(out[1]!.weight).toBe(0.8);
});
test('skips strikethrough rows', () => {
const body = `<!-- gbrain:takes:begin -->
| # | claim | kind | who | weight |
|---|-------|------|-----|--------|
| 1 | ~~stale claim~~ | take | brain | 0.5 |
| 2 | active claim | take | brain | 0.5 |
<!-- gbrain:takes:end -->`;
const out = extractExistingTakesForDedup(body);
expect(out).toHaveLength(1);
expect(out[0]!.claim).toBe('active claim');
});
});
// ─── Phase integration ──────────────────────────────────────────────
describe('runPhaseProposeTakes — phase integration', () => {
test('happy path: scans pages, extracts proposals, writes via INSERT', async () => {
const pages = [buildPage({ slug: 'wiki/concepts/network-effects', body: 'Marketplaces with cold-start liquidity always win.' })];
const { engine, captured } = buildMockEngine({ pages });
const extractor: ProposeTakesExtractor = async () => [
{ claim_text: 'Marketplaces with cold-start liquidity win', kind: 'bet', holder: 'brain', weight: 0.7, domain: 'market' },
];
const result = await runPhaseProposeTakes(buildCtx(engine), { extractor });
expect(result.status).toBe('ok');
const details = result.details as Record<string, unknown>;
expect(details.pages_scanned).toBe(1);
expect(details.cache_misses).toBe(1);
expect(details.cache_hits).toBe(0);
expect(details.proposals_inserted).toBe(1);
const inserts = captured.filter(c => c.sql.includes('INSERT INTO take_proposals'));
expect(inserts).toHaveLength(1);
expect(inserts[0]!.params[5]).toBe('Marketplaces with cold-start liquidity win'); // claim_text
expect(inserts[0]!.params[6]).toBe('bet'); // kind
expect(inserts[0]!.params[9]).toBe('market'); // domain
});
test('cache hit: page already in take_proposals is skipped', async () => {
const body = 'A page that was already processed.';
const pages = [buildPage({ slug: 'wiki/old-page', body })];
const ch = contentHash(body);
const existing = new Set([`default|wiki/old-page|${ch}|${PROPOSE_TAKES_PROMPT_VERSION}`]);
const { engine, captured } = buildMockEngine({ pages, existingProposals: existing });
let extractorCalled = false;
const extractor: ProposeTakesExtractor = async () => {
extractorCalled = true;
return [];
};
const result = await runPhaseProposeTakes(buildCtx(engine), { extractor });
expect(extractorCalled).toBe(false);
const details = result.details as Record<string, unknown>;
expect(details.cache_hits).toBe(1);
expect(details.proposals_inserted).toBe(0);
expect(captured.filter(c => c.sql.includes('INSERT'))).toHaveLength(0);
});
test('passes existing fence rows to extractor as dedup context (F2 fix)', async () => {
const body = `# Page
<!-- gbrain:takes:begin -->
| # | claim | kind | who | weight | since | source |
|---|-------|------|-----|--------|-------|--------|
| 1 | Already captured claim | take | brain | 0.5 | 2026-01 | |
<!-- gbrain:takes:end -->
New prose appended here.`;
const pages = [buildPage({ slug: 'wiki/existing', body })];
const { engine } = buildMockEngine({ pages });
let receivedExistingTakes: unknown;
const extractor: ProposeTakesExtractor = async ({ existingTakes }) => {
receivedExistingTakes = existingTakes;
return [];
};
await runPhaseProposeTakes(buildCtx(engine), { extractor });
expect(Array.isArray(receivedExistingTakes)).toBe(true);
expect((receivedExistingTakes as Array<{ claim: string }>)[0]?.claim).toBe('Already captured claim');
});
test('extractor throw on a single page logs warning + phase continues', async () => {
const pages = [
buildPage({ slug: 'wiki/a', body: 'page A prose' }),
buildPage({ slug: 'wiki/b', body: 'page B prose' }),
];
const { engine } = buildMockEngine({ pages });
let callCount = 0;
const extractor: ProposeTakesExtractor = async () => {
callCount++;
if (callCount === 1) throw new Error('LLM timeout');
return [{ claim_text: 'second page claim', kind: 'take', holder: 'brain', weight: 0.5 }];
};
const result = await runPhaseProposeTakes(buildCtx(engine), { extractor });
expect(result.status).toBe('ok');
const details = result.details as Record<string, unknown>;
expect(details.pages_scanned).toBe(2);
expect(details.proposals_inserted).toBe(1);
expect((details.warnings as string[]).length).toBeGreaterThan(0);
expect((details.warnings as string[])[0]).toContain('LLM timeout');
});
test('pages with empty compiled_truth are skipped silently (no extractor call)', async () => {
const pages = [
buildPage({ slug: 'wiki/empty', body: '' }),
buildPage({ slug: 'wiki/whitespace', body: ' \n ' }),
buildPage({ slug: 'wiki/real', body: 'has prose' }),
];
const { engine } = buildMockEngine({ pages });
let extractorCalls = 0;
const extractor: ProposeTakesExtractor = async () => {
extractorCalls++;
return [];
};
await runPhaseProposeTakes(buildCtx(engine), { extractor });
expect(extractorCalls).toBe(1);
});
test('skipPagesWithFence:true bypasses pages that already have a complete fence', async () => {
const pages = [
buildPage({
slug: 'wiki/fenced',
body: `<!-- gbrain:takes:begin -->\n| # | claim | kind | who | weight |\n|---|---|---|---|---|\n| 1 | x | take | brain | 0.5 |\n<!-- gbrain:takes:end -->\n\nprose`,
}),
buildPage({ slug: 'wiki/unfenced', body: 'plain prose only' }),
];
const { engine } = buildMockEngine({ pages });
let extractorCalls = 0;
const extractor: ProposeTakesExtractor = async () => {
extractorCalls++;
return [];
};
await runPhaseProposeTakes(buildCtx(engine), { extractor, skipPagesWithFence: true });
expect(extractorCalls).toBe(1);
});
test('proposal_run_id is stable across all proposals from one phase invocation', async () => {
const pages = [
buildPage({ slug: 'wiki/a', body: 'page a' }),
buildPage({ slug: 'wiki/b', body: 'page b' }),
];
const { engine, captured } = buildMockEngine({ pages });
const extractor: ProposeTakesExtractor = async () => [
{ claim_text: 'x', kind: 'take', holder: 'brain', weight: 0.5 },
];
await runPhaseProposeTakes(buildCtx(engine), { extractor });
const inserts = captured.filter(c => c.sql.includes('INSERT INTO take_proposals'));
expect(inserts).toHaveLength(2);
const runIdA = inserts[0]!.params[4];
const runIdB = inserts[1]!.params[4];
expect(runIdA).toBe(runIdB);
expect(typeof runIdA).toBe('string');
expect((runIdA as string).startsWith('propose-')).toBe(true);
});
});