From 0fdcc54dde059ba925db25d0cc49341494eabd44 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 17 May 2026 16:03:42 -0700 Subject: [PATCH] cycle: propose_takes phase + take_proposals queue write path (T3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` 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) --- src/core/cycle.ts | 28 +++ src/core/cycle/propose-takes.ts | 412 ++++++++++++++++++++++++++++++++ test/propose-takes.test.ts | 385 +++++++++++++++++++++++++++++ 3 files changed, 825 insertions(+) create mode 100644 src/core/cycle/propose-takes.ts create mode 100644 test/propose-takes.test.ts diff --git a/src/core/cycle.ts b/src/core/cycle.ts index 04653a104..479738412 100644 --- a/src/core/cycle.ts +++ b/src/core/cycle.ts @@ -57,6 +57,14 @@ export type CyclePhase = | 'lint' | 'backlinks' | 'sync' | 'synthesize' | 'extract' | 'extract_facts' | 'resolve_symbol_edges' | 'patterns' | 'recompute_emotional_weight' | 'consolidate' + // v0.36.0.0 Hindsight calibration wave: + // - propose_takes: LLM scans markdown prose, proposes gradeable claims + // to a review queue. User accepts/rejects via `gbrain takes propose`. + // - grade_takes: walks unresolved takes, retrieves evidence, asks a + // judge model to verdict them. Auto-resolve OFF by default (D17). + // - calibration_profile: aggregates the resolved subset into 2-4 + // narrative pattern statements + active bias tags. Voice-gated. + | 'propose_takes' | 'grade_takes' | 'calibration_profile' | 'embed' | 'orphans' | 'purge'; export const ALL_PHASES: CyclePhase[] = [ @@ -88,6 +96,20 @@ export const ALL_PHASES: CyclePhase[] = [ // stay as audit trail. Placed AFTER patterns (graph-fresh) and BEFORE // embed (so the new takes get embedded same-cycle). 'consolidate', + // v0.36.0.0 Hindsight calibration wave. Ordering rationale: + // - propose_takes AFTER consolidate so the proposal LLM sees the + // freshly-consolidated takes when deciding what's NOT yet captured + // (F2 fence-dedup). + // - grade_takes AFTER propose so newly-accepted proposals from the + // queue are eligible for grading on the next cycle (manual accept + // can land between cycle runs; auto-accept is intentionally NOT a + // thing — user always reviews). + // - calibration_profile AFTER grade so the profile reads fresh + // resolutions. Voice-gated narrative; cheap (Haiku judge). + // Budget caps live in src/core/cycle/budget-meter.ts via BaseCyclePhase. + 'propose_takes', + 'grade_takes', + 'calibration_profile', 'embed', 'orphans', // v0.26.5: hard-deletes soft-deleted pages and expired archived sources past @@ -118,6 +140,12 @@ const NEEDS_LOCK_PHASES: ReadonlySet = new Set([ // v0.29 — writes pages.emotional_weight column. 'recompute_emotional_weight', 'consolidate', + // v0.36.0.0 — propose_takes / grade_takes / calibration_profile all + // mutate DB state (take_proposals, take_grade_cache, calibration_profiles) + // so they coordinate via the cycle lock. + 'propose_takes', + 'grade_takes', + 'calibration_profile', 'embed', 'purge', ]); diff --git a/src/core/cycle/propose-takes.ts b/src/core/cycle/propose-takes.ts new file mode 100644 index 000000000..5c5f90b09 --- /dev/null +++ b/src/core/cycle/propose-takes.ts @@ -0,0 +1,412 @@ +/** + * v0.36.0.0 (T3) — propose_takes cycle phase. + * + * Scans markdown pages updated since last run, sends each page's prose to + * a tuned LLM extractor, writes the extracted gradeable claims to the + * `take_proposals` queue. User accepts/rejects via `gbrain takes propose`. + * + * Idempotency contract (D17 schema spec): + * The unique index on (source_id, page_slug, content_hash, prompt_version) + * 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. + * + * F2 fence dedup: + * The phase reads the page's existing `` fence + * (when present) and passes the canonical take rows to the extractor as + * "things you have already captured." This prevents duplicate proposals + * when a user adds prose to a page that already has takes. + * + * Auto-resolve posture: + * propose_takes only WRITES proposals to the queue. Nothing here mutates + * the canonical takes table. Operator opt-in via `gbrain takes propose + * --accept N` is the only path from queue to canonical fence (D17). + * + * Prompt tuning status (v0.36.0.0 ship state): + * The default extractor prompt is a placeholder ("v0.36.0.0-stub"). The + * real prompt is tuned via T19's 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 is opt-in via config flag and produces best- + * effort candidates that the user reviews manually. + * + * The extractor LLM call is INJECTED via opts.extractor for tests, so the + * phase can run hermetically in unit tests without touching the gateway. + */ + +import { randomUUID, createHash } from 'node:crypto'; +import { BaseCyclePhase, type ScopedReadOpts, type BasePhaseOpts } from './base-phase.ts'; +import { chat as gatewayChat } from '../ai/gateway.ts'; +import { GBrainError } from '../types.ts'; +import type { Page, PageFilters } from '../types.ts'; +import type { OperationContext } from '../operations.ts'; +import type { BrainEngine } from '../engine.ts'; +import type { PhaseStatus, CyclePhase } from '../cycle.ts'; + +/** + * Bump when the extractor prompt or the JSON output shape changes. Old + * verdicts in `take_proposals` (composite key includes prompt_version) stay + * valid as audit history; new runs re-spend LLM tokens on every page. + */ +export const PROPOSE_TAKES_PROMPT_VERSION = 'v0.36.0.0-stub'; + +/** + * Stub extractor prompt. v0.36.0.0 ship-state placeholder — T19 corpus + * build replaces this with a tuned prompt (Hindsight-style, adapted for + * gbrain's kind/holder/weight take schema rather than Hindsight's + * conviction/domain shape). + * + * The stub returns an empty array reliably so the phase wires up cleanly + * end-to-end without producing noise during the pre-tuned window. Operators + * opting in early get a queue that fills only when they explicitly invoke + * with a non-stub prompt. + */ +export const EXTRACT_TAKES_PROMPT = `[v0.36.0.0-stub] Extract gradeable claims (predictions, recommendations, +interpretive judgments that could turn out wrong) from the prose below. + +Output ONLY a JSON array of objects. Each object has fields: +- claim_text (string, <=200 chars) the claim verbatim or close paraphrase +- kind ('fact' | 'take' | 'bet' | 'hunch') +- holder ('world' | 'people/' | 'companies/' | 'brain') +- weight (number 0..1, 0.05 increments preferred) +- domain (optional short tag, e.g. 'tactics' / 'macro' / 'hiring') + +Do NOT include evidence, citations, examples, or restatements of an earlier claim. +If no gradeable claims are present, return []. + +EXISTING FENCE ROWS (these are already captured — do NOT propose duplicates): +{EXISTING_TAKES_JSON} + +PAGE PROSE: +{PAGE_BODY} +`; + +/** One proposed take, as the extractor produces it. */ +export interface ProposedTake { + claim_text: string; + kind: 'fact' | 'take' | 'bet' | 'hunch'; + holder: string; + weight: number; + domain?: string; +} + +/** Extractor function signature — injected for tests; production calls gateway. */ +export type ProposeTakesExtractor = (input: { + pagePath: string; + pageBody: string; + existingTakes: Array<{ claim: string; kind: string; holder: string; weight: number }>; + modelHint?: string; +}) => Promise; + +export interface ProposeTakesOpts extends BasePhaseOpts { + /** Brain repo root for fs-source page walking. Optional — defaults to engine pages. */ + repoPath?: string; + /** Limit pages processed in this cycle (for triage / quick smoke). Default: 100. */ + pageLimit?: number; + /** Inject the LLM call for tests; production uses gateway.chat. */ + extractor?: ProposeTakesExtractor; + /** Override prompt_version (tests). */ + promptVersion?: string; + /** Override model id (tests + config). */ + model?: string; + /** Skip pages that already have a complete takes fence. Default: true. */ + skipPagesWithFence?: boolean; +} + +export interface ProposeTakesResult { + pages_scanned: number; + cache_hits: number; + cache_misses: number; + proposals_inserted: number; + budget_exhausted: boolean; + warnings: string[]; +} + +/** + * Compute the content_hash key for the idempotency cache. SHA-256 of the + * page body suffices — page slug + prompt_version are separate columns in + * the composite unique index. + */ +export function contentHash(pageBody: string): string { + return createHash('sha256').update(pageBody).digest('hex'); +} + +/** + * Detect whether a page already has a complete `` + * fence. We DO propose against pages with fences (F2 dedup) but the operator + * may opt to skip-with-fence pages via skipPagesWithFence:true for a faster + * pass. The fence shape mirrors src/core/takes-fence.ts. + */ +export function hasCompleteFence(pageBody: string): boolean { + return //.test(pageBody); +} + +/** + * Parse the existing fence into rows so the extractor can dedupe. + * Returns [] when no fence is present. Best-effort — malformed fences + * surface to the operator via the existing v0.28 fence parser, not here. + */ +export function extractExistingTakesForDedup(pageBody: string): Array<{ + claim: string; + kind: string; + holder: string; + weight: number; +}> { + const fenceMatch = pageBody.match(/([\s\S]*?)/); + if (!fenceMatch) return []; + const body = fenceMatch[1] ?? ''; + const rows: Array<{ claim: string; kind: string; holder: string; weight: number }> = []; + for (const line of body.split('\n')) { + const cells = line.split('|').map(c => c.trim()).filter((_, i, arr) => i > 0 && i < arr.length - 1); + // Skip header + separator rows. + if (cells.length < 4) continue; + if (cells[0] === '#' || cells[0]?.match(/^-+$/)) continue; + const claim = cells[1] ?? ''; + if (!claim || claim.startsWith('~~')) continue; // strikethrough = inactive, doesn't count for dedup + const kind = cells[2] ?? 'take'; + const holder = cells[3] ?? 'brain'; + const weight = Number.parseFloat(cells[4] ?? '0.5'); + rows.push({ + claim: claim.replace(/^~~|~~$/g, ''), + kind, + holder, + weight: Number.isFinite(weight) ? weight : 0.5, + }); + } + return rows; +} + +/** + * Production extractor — calls gateway.chat with the EXTRACT_TAKES_PROMPT + * and parses the JSON array output. Returns [] on parse failure (logged as + * warning, not thrown — one bad page must not abort the phase). + * + * Stub-prompt note: the v0.36.0.0 ship-state prompt is a placeholder. Real + * extractor lands when T19 corpus build produces the tuned prompt. Until + * then, the production extractor returns whatever the stub LLM produces — + * empirically often a sparse list or []. + */ +export async function defaultExtractor( + input: Parameters[0], +): Promise { + const prompt = EXTRACT_TAKES_PROMPT + .replace('{EXISTING_TAKES_JSON}', JSON.stringify(input.existingTakes, null, 2)) + .replace('{PAGE_BODY}', input.pageBody); + + const result = await gatewayChat({ + messages: [{ role: 'user', content: prompt }], + ...(input.modelHint ? { model: input.modelHint } : {}), + maxTokens: 2048, + }); + + // ChatResult.text is already the concatenated text content. + return parseExtractorOutput(result.text); +} + +/** + * Parse extractor output into ProposedTake[]. Handles common LLM output + * sins (markdown fence wrapping, leading/trailing prose, single-object + * instead of array). Returns [] on any unrecoverable parse error rather + * than throwing. + */ +export function parseExtractorOutput(raw: string): ProposedTake[] { + if (!raw || raw.trim().length === 0) return []; + let text = raw.trim(); + // Strip markdown code fence wrapper. + const fenced = text.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/); + if (fenced) text = (fenced[1] ?? '').trim(); + // First-array-or-object substring extraction (defends against leading prose). + const firstArr = text.indexOf('['); + const firstObj = text.indexOf('{'); + if (firstArr === -1 && firstObj === -1) return []; + const start = firstArr !== -1 && (firstObj === -1 || firstArr < firstObj) ? firstArr : firstObj; + let parsed: unknown; + try { + parsed = JSON.parse(text.slice(start)); + } catch { + return []; + } + const arr = Array.isArray(parsed) ? parsed : [parsed]; + const out: ProposedTake[] = []; + for (const raw of arr) { + if (typeof raw !== 'object' || raw === null) continue; + const r = raw as Record; + const claim_text = typeof r.claim_text === 'string' ? r.claim_text.trim() : ''; + if (!claim_text || claim_text.length > 500) continue; + const kind = ['fact', 'take', 'bet', 'hunch'].includes(r.kind as string) + ? (r.kind as ProposedTake['kind']) + : 'take'; + const holder = typeof r.holder === 'string' && r.holder.length > 0 ? r.holder : 'brain'; + const weightRaw = typeof r.weight === 'number' ? r.weight : 0.5; + const weight = Math.max(0, Math.min(1, weightRaw)); + const domain = typeof r.domain === 'string' && r.domain.length > 0 ? r.domain : undefined; + out.push({ claim_text, kind, holder, weight, domain }); + } + return out; +} + +/** + * BaseCyclePhase subclass. Walks pages, checks idempotency cache, calls + * extractor, writes proposals. + */ +class ProposeTakesPhase extends BaseCyclePhase { + readonly name = 'propose_takes' as CyclePhase; + protected readonly budgetUsdKey = 'cycle.propose_takes.budget_usd'; + protected readonly budgetUsdDefault = 5.0; + + protected override mapErrorCode(err: unknown): string { + if (err instanceof GBrainError) return err.problem; + if (err instanceof Error) { + if (err.message.includes('content_hash')) return 'CALIBRATION_PROPOSAL_DEDUP_FAIL'; + if (err.message.includes('budget') || err.message.includes('Budget')) return 'CALIBRATION_GRADE_BUDGET_EXHAUSTED'; + } + return 'PROPOSE_TAKES_UNKNOWN'; + } + + protected async process( + engine: BrainEngine, + scope: ScopedReadOpts, + _ctx: OperationContext, + opts: ProposeTakesOpts, + ): Promise<{ summary: string; details: Record; status?: PhaseStatus }> { + const extractor = opts.extractor ?? defaultExtractor; + const promptVersion = opts.promptVersion ?? PROPOSE_TAKES_PROMPT_VERSION; + const pageLimit = opts.pageLimit ?? 100; + const skipPagesWithFence = opts.skipPagesWithFence ?? false; + const proposalRunId = `propose-${new Date().toISOString().slice(0, 19).replace(/[-:T]/g, '')}-${randomUUID().slice(0, 8)}`; + + const result: ProposeTakesResult = { + pages_scanned: 0, + cache_hits: 0, + cache_misses: 0, + proposals_inserted: 0, + budget_exhausted: false, + warnings: [], + }; + + // Load pages eligible for proposal. Source-scoped per BaseCyclePhase. + const pageFilters: PageFilters = { + ...scope, + limit: pageLimit, + sort: 'updated_desc', + }; + const pages: Page[] = await engine.listPages(pageFilters); + + if (opts.reporter) { + opts.reporter.start('propose_takes.pages' as never, pages.length); + } + + for (const page of pages) { + result.pages_scanned += 1; + this.tick(opts); + + // Skip pages that have NO prose body (e.g. metadata-only entity stubs). + const body = page.compiled_truth ?? ''; + if (body.trim().length === 0) continue; + if (skipPagesWithFence && hasCompleteFence(body)) continue; + + const ch = contentHash(body); + const existingTakes = extractExistingTakesForDedup(body); + + // Idempotency check. If a row exists for (source_id, page_slug, content_hash, + // prompt_version), this page was already processed — skip and count as cache hit. + const sourceId = page.source_id ?? scope.sourceId ?? 'default'; + const cached = await engine.executeRaw<{ id: number }>( + `SELECT id FROM take_proposals + WHERE source_id = $1 AND page_slug = $2 AND content_hash = $3 AND prompt_version = $4 + LIMIT 1`, + [sourceId, page.slug, ch, promptVersion], + ); + if (cached.length > 0) { + result.cache_hits += 1; + continue; + } + result.cache_misses += 1; + + // Budget pre-check before the LLM call. Estimate: ~1500 input tokens + 500 output. + const budget = this.checkBudget({ + modelId: opts.model ?? 'claude-sonnet-4-6', + estimatedInputTokens: 1500, + maxOutputTokens: 500, + }); + if (!budget.allowed) { + result.budget_exhausted = true; + result.warnings.push( + `budget exhausted at page ${result.pages_scanned}/${pages.length} (cumulative $${budget.cumulativeCostUsd.toFixed(4)} / cap $${budget.budgetUsd.toFixed(2)})`, + ); + break; + } + + // Call the extractor. Errors on a single page log a warning but do not abort. + let proposals: ProposedTake[]; + try { + proposals = await extractor({ + pagePath: page.slug, + pageBody: body, + existingTakes, + modelHint: opts.model, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + result.warnings.push(`extractor failed on ${page.slug}: ${msg}`); + continue; + } + + // Write proposals to take_proposals. Each row is a separate INSERT + // because the composite idempotency key is on the per-page tuple — a + // bulk UPSERT would collapse a same-page-multi-claim run into one row. + for (const p of proposals) { + await engine.executeRaw( + `INSERT INTO take_proposals + (source_id, page_slug, content_hash, prompt_version, proposal_run_id, + claim_text, kind, holder, weight, domain, dedup_against_fence_rows, model_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + ON CONFLICT (source_id, page_slug, content_hash, prompt_version) DO NOTHING`, + [ + sourceId, + page.slug, + ch, + promptVersion, + proposalRunId, + p.claim_text, + p.kind, + p.holder, + p.weight, + p.domain ?? null, + JSON.stringify(existingTakes), + opts.model ?? 'claude-sonnet-4-6', + ], + ); + result.proposals_inserted += 1; + } + } + + if (opts.reporter) opts.reporter.finish(); + + return { + summary: `propose_takes: scanned ${result.pages_scanned} pages, ${result.cache_hits} cached, ${result.proposals_inserted} new proposals (run ${proposalRunId})`, + details: { ...result, proposal_run_id: proposalRunId, prompt_version: promptVersion }, + status: result.budget_exhausted ? 'warn' : 'ok', + }; + } +} + +/** + * Public entry point — mirrors the v0.23 `runPhaseSynthesize` shape so the + * cycle orchestrator in cycle.ts can call it uniformly. + */ +export async function runPhaseProposeTakes( + ctx: OperationContext, + opts: ProposeTakesOpts = {}, +) { + return new ProposeTakesPhase().run(ctx, opts); +} + +/** Test-only access to the class for subclassing in tests. */ +export const __testing = { + ProposeTakesPhase, + parseExtractorOutput, + contentHash, + hasCompleteFence, + extractExistingTakesForDedup, +}; diff --git a/test/propose-takes.test.ts b/test/propose-takes.test.ts new file mode 100644 index 000000000..4c0d9b395 --- /dev/null +++ b/test/propose-takes.test.ts @@ -0,0 +1,385 @@ +/** + * 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; // composite-key strings already in take_proposals +}): { engine: BrainEngine; captured: CapturedSql[] } { + const captured: CapturedSql[] = []; + const existing = opts.existingProposals ?? new Set(); + + const engine = { + kind: 'pglite', + async listPages() { + return opts.pages; + }, + async executeRaw(sql: string, params?: unknown[]): Promise { + 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 + + +| # | claim | kind | who | weight | since | source | +|---|-------|------|-----|--------|-------|--------| +| 1 | X | take | brain | 0.5 | 2026-01 | | + + +prose continues +`; + expect(hasCompleteFence(body)).toBe(true); + }); + + test('returns false when fence is incomplete (begin only)', () => { + expect(hasCompleteFence('\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('\n| # |\n')).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 = ` +| # | 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 | | +`; + 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 = ` +| # | claim | kind | who | weight | +|---|-------|------|-----|--------| +| 1 | ~~stale claim~~ | take | brain | 0.5 | +| 2 | active claim | take | brain | 0.5 | +`; + 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; + 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; + 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 + + +| # | claim | kind | who | weight | since | source | +|---|-------|------|-----|--------|-------|--------| +| 1 | Already captured claim | take | brain | 0.5 | 2026-01 | | + + +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; + 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: `\n| # | claim | kind | who | weight |\n|---|---|---|---|---|\n| 1 | x | take | brain | 0.5 |\n\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); + }); +});