mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* Wave A: schema + receipts foundation for v0.42 extract operator surfaces
Foundation layer for the pack-driven extractables + receipt-as-brain-memory
+ operator-discoverability cathedral. Five atomic pieces ship together
because their schema + helpers + module dependencies are tight-coupled:
A1. Widen pack manifest's `extractable` from `boolean` to
`boolean | ExtractableSpec`. ExtractableSpec carries prompt_template,
fixture_corpus, eval_dimensions, benchmark_min_recall, and reserves
verifier_path for v0.43+ pack-shipped verifier code (REFUSE at
runtime in v0.42 per plan D-EXTRACT-37). Back-compat: every pre-v0.42
pack with `extractable: true` continues parsing unchanged. Three new
helpers: extractableSpecsFromPack(), getExtractableSpec(),
refuseVerifierPathInV042().
A2. New page type `extract_receipt` in ALL_PAGE_TYPES. Source-boost map
adds `extracts/` prefix at factor 0.3 — receipts surface in search
when extraction-relevant but never dominate user content (D-EXTRACT-42).
A3. New module src/core/extract/receipt-writer.ts (~190 LOC) exporting
writeReceipt(engine, input). Canonical slug shape
extracts/{date}/{kind}/{source_id}/{run_id_short}/round-{N} per
D-EXTRACT-17. Frontmatter belt+suspenders per D-EXTRACT-19: BOTH
type:extract_receipt AND dream_generated:true stamped on every
receipt, regardless of caller, so the eligibility predicate's
anti-loop guards reject the receipt page from any future extraction
sweep (single-flag bypass requires breaking two unrelated checks).
Idempotent on resume — same run_id+round overwrites cleanly.
A4. Migration v104 creates extract_rollup_7d table (per-day rollup of
extract events keyed on kind+source_id+day). Audit JSONL stays the
SOURCE OF TRUTH per F-OUT-19; this table is a best-effort cache for
doctor's <100ms read budget. Per-day rows mean the 7-day window
auto-evicts on every read. v100 was deliberately skipped on master
(renumbered out during a prior wave); v101/v102/v103 also taken;
v104 is the next clean slot.
A5. Doctor `extract_health` check reads extract_rollup_7d for last 7
days and emits per-kind aggregates: cost_7d_usd, eval_pass_count,
eval_fail_count, halt_count, round_completed_count, halt_rate.
3-state: OK when rollup empty (pre-v0.42 brain or fresh init), WARN
when any per-kind halt rate > 10% (top-3 named in message), WARN
when rollup_write_failures > 0 (audit JSONL is SoT but operator
deserves to know the DB cache is degraded). Pre-v104 brains stay
quiet — the missing-table error path is caught and treated as
OK so doctor doesn't warn during the upgrade window.
Tests added:
- test/extractable-spec-widening.test.ts (22 cases) — back-compat with
boolean shape, new struct parsing, verifier_path REFUSE contract.
- test/extract/receipt-writer.test.ts (12 cases) — slug shape, frontmatter
belt+suspenders, idempotent resume, body human-readability.
- test/doctor-extract-health.test.ts (8 cases) — empty rollup OK, halt
rate WARN, rollup_write_failures WARN, 7-day window inclusion at
boundary, multi-kind top-3 message ordering.
Plus the canonical bootstrap-coverage test passes with the new v104
migration cleanly applied through both engines.
Plan: ~/.claude/plans/system-instruction-you-are-working-stateless-dragonfly.md
Wave A scope. Wave B (hook receipts into existing extractors) follows.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Wave B: hook receipts + rollup row into the 5 shipped extractors
Each LLM-backed extractor surface now records its run in two places when
something actually happened:
1. An extract receipt PAGE at extracts/{date}/{kind}/{source_id}/{run_id_short}/round-{N}
(queryable via gbrain search, citable, surfaces in cross-modal
contradiction probes per the Wave A foundation). Only written when
`total_rows > 0` so no-op runs don't bloat the brain.
2. An UPSERT row in extract_rollup_7d (DB-backed best-effort cache
per F-OUT-19) so the doctor extract_health check from Wave A reads
per-kind aggregates without scanning JSONL.
New module src/core/extract/rollup-writer.ts (~120 LOC) exports
upsertExtractRollup() with PostgreSQL ON CONFLICT DO UPDATE on the
(kind, source_id, day) PK. Concurrency-safe per F-OUT-14 design.
Failure path is best-effort — bumps rollup_write_failures in the
table itself, stderr-warns once per (kind, day, error-class), and
NEVER fails the parent extraction operation. JSONL remains source
of truth.
Wired into 5 extractors:
- extract-conversation-facts (kind: facts.conversation) — both
success path AND BudgetExhausted halt path write receipt+rollup
so partial runs are still observable.
- extract_atoms cycle phase (kind: atoms)
- synthesize_concepts cycle phase (kind: concepts, source_id: default
because concepts are brain-global)
- propose_takes cycle phase (kind: takes.proposed) — scope-aware
source_id from the read scope.
- extract_facts cycle phase (kind: facts.fence) — deterministic
(no LLM cost) but still records reconcile activity so doctor sees
the cycle is alive.
Receipt frontmatter belt+suspenders (D-EXTRACT-19) reused from
Wave A: every receipt stamps BOTH `type: extract_receipt` AND
`dream_generated: true` so the eligibility predicate's anti-loop
guards reject the receipt page from any future extraction sweep.
Test surgery in test/propose-takes.test.ts — one existing assertion
tightened from "no INSERTs" to "no INSERT INTO take_proposals" so
the new rollup UPSERT doesn't falsely fail the cache-hit case test.
Run regression: 85/85 tests pass across extract-conversation-facts,
extract-atoms-synthesize-concepts, extract-facts-phase, propose-takes.
Plan: ~/.claude/plans/system-instruction-you-are-working-stateless-dragonfly.md
Wave B scope. Wave C (pack-author scaffolding + benchmark) follows.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Wave C+D: pack-author scaffolding + operator surfaces for v0.42 extract
Wave C: pack-author authoring loop
- scaffold-extractable mutation primitive declares a kind as extractable
on a pack manifest in one verb (wires through updateTypeOnPack from
the v0.41 mutate library); generates 5 placeholder fixtures + a
pack-supplied prompt template stub
- schema CLI wires gbrain schema scaffold-extractable <type> --pack <pack>
- extract benchmark CLI loads a pack's fixture corpus through strict
D-EXTRACT-21 path validation (rejects absolute paths, .. traversal,
null bytes, symlinks resolving outside pack root); v0.42 ships as a
stub reporter (LLM dispatch deferred to Wave E)
Wave D: operator surfaces
- extract status CLI reads extract_rollup_7d for the last 7 days,
sorts by (halt_rate desc, cost desc); kubectl-style right-aligned
table, top-5 + "more rows" hint by default, --verbose shows all;
stable schema_version: 1 JSON envelope for monitoring pipelines
- extract --explain <kind> CLI prints the active pack's resolution
chain: declaration source (pack-declared vs built-in cycle phase),
prompt_template + fixture_corpus paths with existence checks,
eval_dimensions, benchmark_min_recall, and the last 7d rollup
- extract.ts gains a lifecycle-grouped help text (Extraction /
Inspection / Status) per the original D3 plan goal
Tests:
- test/schema-pack/scaffold-extractable.test.ts (15 cases) including
explicit privacy-rule assertions guarding against real-name leakage
- test/extract/benchmark.test.ts (17 cases) covering path validation
rejections + JSONL fixture parsing
- test/extract/status.test.ts (15 cases) over pure aggregation +
formatting
Housekeeping:
- test/extract/receipt-writer.test.ts refactored to the canonical
PGLite block (beforeAll/afterAll/resetPgliteState in beforeEach)
per CLAUDE.md test-isolation R3+R4; runtime drops from ~30s of
99-migration replay per test to <6s for all 12 cases together
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* v0.42.0.0: extract operator surfaces + pack-driven extractables
Bump VERSION + package.json to 0.42.0.0. CHANGELOG entry covers the
three-wave shipped scope (receipts + rollup + doctor check; receipts
hooked into all 5 shipped extractors; pack-author scaffolding +
benchmark stub-reporter; status + --explain dashboards + lifecycle
help). CLAUDE.md Key Files gains a v0.42 cluster annotation. llms.txt
regenerated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* v0.41.23.0: re-tag from 0.42.0.0 (patch-channel slot, no scope change)
VERSION + package.json + CHANGELOG header + CLAUDE.md cluster annotation
all moved from 0.42.0.0 to 0.41.23.0. Body text updated in-place: every
"v0.42" / "v0.43+" reference inside this entry's release notes now reads
"v0.41.23" or "follow-up release" as appropriate.
Same scope shipping — the three-wave extract operator surface stays
intact. Just lands in the patch-channel queue (.20/.21/.23 free; .22 is
PR #1542's type-unification cathedral) instead of the minor-channel bump.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: add extract_receipt to gbrain-base.yaml page_types (CI parity gate)
CI shard 5 caught the drift: test/regressions/gbrain-base-equivalence.test.ts
asserts every ALL_PAGE_TYPES seed has a matching page_type entry in the
gbrain-base.yaml pack. Wave A added `extract_receipt` to ALL_PAGE_TYPES
but didn't seed it in the base pack manifest.
Adds the entry under the `annotation` primitive with `extracts/` path
prefix (matches the source-boost demote site) and `extractable: false`
(receipts are written by the framework, never extracted from). Comment
documents the belt+suspenders D-EXTRACT-19 invariant so future readers
understand why receipts carry both `dream_generated: true` AND
`type: extract_receipt`.
Closes the CI gate without changing runtime behavior — the pack-aware
read paths already had the prefix demote wired in src/core/search/source-boost.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: bump gbrain-base page-type count 24→25 in schema-cli test
CI shard 4 caught the second drift from the same root cause as the
prior parity-gate fix: v0.41.23's `extract_receipt` addition bumped
gbrain-base.yaml from 24 to 25 page types. The schema-cli smoke test
was pinned at 24 (the count after v0.41.11.0 added `conversation` +
`atom`); update to 25 and note v0.41.23's contribution alongside the
prior version stamp.
Verified hermetic: running test/schema-cli.test.ts with a clean
GBRAIN_HOME tempdir produces 12/12 pass (the local-machine 'schema
active' fail is from a real ~/.gbrain pinning gbrain-base-v2; not a
shipped-code issue, doesn't repro on CI).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(test): pack-locator stub leak between shard 6 test files
CI shard 6 caught three flaky failures in test/onboard-pack-upgrade-checks.test.ts:
- checkPackUpgradeAvailable > fires on gbrain-base brain with gbrain-base-v2
- checkPackUpgradeAvailable > manual_only routing via render.ts allowlist (D17)
- checkTypeProliferation > warns when distinct types exceed declared+5
Root cause: test/schema-pack-sync.test.ts calls
`__setPackLocatorForTests(...)` to stub the disk-loader, but doesn't
restore in afterAll. Bun's CI shard 6 loads multiple test files into
one process; when sync.test.ts runs before onboard-pack-upgrade-checks.test.ts,
the stubbed locator persists at module scope. `loadActivePack` for
gbrain-base / gbrain-base-v2 then returns null and:
- findPackSuccessors returns [] → status='ok' instead of 'warn' (F1+F2)
- declared falls back to 15 → fail threshold becomes 30, 32 > 30 → 'fail'
instead of 'warn' (F3)
Local single-file runs pass because the locator starts at its default.
Two-layer fix:
1. test/schema-pack-sync.test.ts afterAll calls
`_resetPackLocatorForTests()` to undo the mutation (the canonical
fix at the source).
2. test/onboard-pack-upgrade-checks.test.ts beforeEach calls the same
reset (defense-in-depth against any future test file in the shard
that forgets to restore).
Reproduced locally: running the three shard-6 schema-pack files together
fails 3 tests pre-fix and passes 30/30 post-fix. Full shard 6 sweep
(77 files, 1232 tests) now green; bun run verify still 28/28.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(test): pglite-engine — dim-agnostic chunk-embedding test data
CI shard 6 caught two flaky failures in test/pglite-engine.test.ts:
- PGLiteEngine: Chunks > getChunksWithEmbeddings returns embedding data
- PGLiteEngine: stale chunk pagination > countStaleChunks counts chunks
with NULL embedding only
Both failed with `expected 1280 dimensions, not 1536` at the upsert site.
Root cause: pglite-engine.ts:287 initSchema() reads embedding dim from
gw.getEmbeddingDimensions() if the gateway is configured (potentially
left in that state by another shard-6 test file in the same bun process),
falling back to DEFAULT_EMBEDDING_DIMENSIONS otherwise — which is 1280
since v0.36+ when the ZE default landed (zeroentropyai:zembed-1).
Pre-v0.36 defaults were OpenAI's 1536; my test data was pinned to that
stale literal.
The two outcomes that pass:
- gateway happens to be configured for 1536-dim (e.g. master shard 6
run 26515999465 — these tests passed at 20ms + 24ms with no
"dimensions" error)
- gateway happens to be configured for 1280-dim AND test data is 1280
The outcome that fails:
- gateway configured for 1280-dim AND test data hardcoded to 1536
Fix: capture the actual column width after initSchema (probe
pg_attribute.atttypmod for content_chunks.embedding) and use that
captured `CHUNK_EMBED_DIM` constant at the three Float32Array sites.
Test data now matches whatever width the column was created at,
regardless of which shard-6 file ran first.
Local repro: full shard 6 (77 files, 1232 tests, ~6min) green; this
file standalone (100 tests) green; bun run verify 28/28.
Broader pattern: 9 other test files use the same Float32Array(1536)
literal. None land in shard 6 today (so they don't flake), but the
fix shape here can be lifted into a shared helper if the bug class
surfaces elsewhere — filed as a v0.42+ follow-up rather than a
preemptive sweep, since each file's setup shape is slightly different.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
388 lines
15 KiB
TypeScript
388 lines
15 KiB
TypeScript
/**
|
|
* v0.36.1.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);
|
|
// v0.42: extract rollup row UPSERTs on every phase invocation (best-
|
|
// effort cache). Filter the assertion to take_proposals INSERTs only.
|
|
expect(captured.filter(c => c.sql.includes('INSERT INTO take_proposals'))).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);
|
|
});
|
|
});
|