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>
This commit is contained in:
Garry Tan
2026-05-17 16:03:42 -07:00
co-authored by Claude Opus 4.7
parent af6fe9d57b
commit 0fdcc54dde
3 changed files with 825 additions and 0 deletions
+28
View File
@@ -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<CyclePhase> = 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',
]);
+412
View File
@@ -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 `<!-- gbrain:takes:begin -->` 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/<slug>' | 'companies/<slug>' | '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<ProposedTake[]>;
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 `<!-- gbrain:takes:begin -->`
* 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 /<!---?\s*gbrain:takes:begin[\s\S]*?gbrain:takes:end\s*-->/.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*gbrain:takes:begin\s*-->([\s\S]*?)<!---?\s*gbrain:takes:end\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<ProposeTakesExtractor>[0],
): Promise<ProposedTake[]> {
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<string, unknown>;
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<string, unknown>; 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,
};
+385
View File
@@ -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<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);
});
});