mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* fix-wave: dream.* DB merge + batch retry + extract_atoms idempotency + ze-switch env-gate + doctor check Closes PRs #1414, #1416, #1421 (rebuilt from designs by @garrytan-agents with structural improvements from /plan-eng-review + codex outside-voice). Three production reliability fixes in one wave: 1. dream.* DB-config merge (closes PR #1416 silent-config gap) - loadConfigWithEngine() sparse-merge extends with 7 dream.* keys - File > DB > defaults precedence (no GBRAIN_DREAM_* env vars) - extract-atoms switches to loadConfigWithEngine() so DB-plane keys reach it 2. Batch retry on transient connection drops (closes PR #1416 ~30%-loss bug) - withRetry() pure primitive exported from src/commands/extract.ts - 6 flush() sites snapshot-before-clear with onRetry callback - Reuses isRetryableConnError from src/core/retry-matcher.ts - retry-matcher extended with GBrainError{problem:'No database connection'} 3. extract_atoms source-hash idempotency + page-based discovery (closes #1414) - One raw SQL with NOT EXISTS subquery replaces 6 listPages + N atom checks - sourceId threaded through every putPage call (codex caught real bug) - NULL content_hash filter + dream_generated exclusion + transcript-side idempotency - cycle.ts passes union of syncPagesAffected + synthesizeWrittenSlugs 4. ze-switch pre-apply + pre-resume env-override gate (closes PR #1421) - Gate fires FIRST in apply AND resume; zero setConfig calls on refusal - ASCII warning box (no Unicode per repo D10) - --ignore-env-override escape hatch for power users - ApplyResult extended with refused variant 5. doctor embedding_env_override check (defense-in-depth for #1421) - Cross-surface parity: buildChecks() + doctorReportRemote() - Uses Check.details (not Check.issues per codex schema review) Co-Authored-By: garrytan-agents <garrytan-agents@users.noreply.github.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.41.10.0) Adds 61 new tests across 5 new files pinning the fix-wave contracts: - test/extract-batch-retry.test.ts (16 cases) — withRetry primitive + snapshot contract - test/extract-atoms-page-discovery.test.ts (17 cases) — discovery SQL + dual-source idempotency - test/ze-switch-env-override.test.ts (17 cases) — env-gate apply + resume + ZERO-setConfig assertion - test/doctor-embedding-env-override.test.ts (7 cases) — cross-surface parity - test/e2e/extract-atoms-discovery-sql.test.ts (4 cases) — real-Postgres parity for raw SQL Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(test): pin gateway to 1536-dim in 2 PGLite tests that hardcode 1536-vector inserts CI shards 1 + 4 failed persistently (not flake — confirmed via retry) after the v0.41.6.0 merge with this error: error: expected 1280 dimensions, not 1536 file: "vector.c", routine: "CheckExpectedDim" Two test files insert 1536-dim Float32Array vectors into `content_chunks.embedding` / `facts.embedding`, but v0.41.5.0 flipped `DEFAULT_EMBEDDING_DIMENSIONS` from 1536 to 1280 (ZE Matryoshka default). On a fresh CI bun process where no prior test pre-configured the gateway, `initSchema()` sizes the vector column at vector(1280) and the inserts throw. Locally this is hidden when an earlier test file in the shard happens to have called `configureGateway({embedding_dimensions: 1536})` — that state leaks forward through bun's shared process. The v0.41.6.0 LPT shard re-balancing reordered files so these two ran cold, surfacing the latent bug. Fix follows the canonical hermetic pattern from test/consolidate-valid-until.test.ts:23-34: pin the gateway to 1536d in beforeAll, reset in afterAll. Test is now isolated from shard ordering. test/search-types-filter.test.ts — shard 1 fail test/operations-find-trajectory.test.ts — shard 4 (6 fails) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: empty commit to trigger CI * chore: trigger CI again * chore: renumber v0.41.10.0 -> v0.41.10.1 Per request — version slot moved to .1 micro tier to leave .0 available for unrelated wave landing on master. --------- Co-authored-by: garrytan-agents <garrytan-agents@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
320 lines
13 KiB
TypeScript
320 lines
13 KiB
TypeScript
// v0.41 T5+T6 — extract_atoms + synthesize_concepts minimal-viable bodies.
|
|
//
|
|
// Tests the LLM-driven extraction + synthesis paths with a stubbed
|
|
// chat function so no real Haiku/Sonnet calls fire in CI. Pins:
|
|
// - extract_atoms parses Haiku JSON output, writes atom-typed pages
|
|
// - parseAtomsResponse tolerates markdown fences + trailing prose
|
|
// - extract_atoms skips invalid atom_type values
|
|
// - extract_atoms budget cap halts mid-run
|
|
// - synthesize_concepts groups atoms by concept frontmatter ref
|
|
// - tier assignment by count (T1 ≥10, T2 ≥5, T3 ≥2)
|
|
// - T1/T2 use LLM narrative; T3 falls back deterministic
|
|
// - dry-run mode counts but doesn't write
|
|
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
|
|
import { runPhaseExtractAtoms, parseAtomsResponse } from '../../src/core/cycle/extract-atoms.ts';
|
|
import { runPhaseSynthesizeConcepts } from '../../src/core/cycle/synthesize-concepts.ts';
|
|
import { resetPgliteState } from '../helpers/reset-pglite.ts';
|
|
import type { ChatResult, ChatOpts } from '../../src/core/ai/gateway.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
}, 60000);
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await resetPgliteState(engine);
|
|
});
|
|
|
|
function stubChat(text: string, opts: { input_tokens?: number; output_tokens?: number } = {}): (o: ChatOpts) => Promise<ChatResult> {
|
|
return async (_o: ChatOpts) => ({
|
|
text,
|
|
blocks: [{ type: 'text', text }],
|
|
stopReason: 'end',
|
|
usage: {
|
|
input_tokens: opts.input_tokens ?? 500,
|
|
output_tokens: opts.output_tokens ?? 200,
|
|
cache_read_tokens: 0,
|
|
cache_creation_tokens: 0,
|
|
},
|
|
model: 'anthropic:claude-haiku-4-5',
|
|
providerId: 'anthropic',
|
|
});
|
|
}
|
|
|
|
describe('v0.41 T5: parseAtomsResponse', () => {
|
|
test('parses well-formed JSON array', () => {
|
|
const raw = `[{"title":"Test","atom_type":"insight","body":"body text"}]`;
|
|
const atoms = parseAtomsResponse(raw);
|
|
expect(atoms.length).toBe(1);
|
|
expect(atoms[0].title).toBe('Test');
|
|
expect(atoms[0].atom_type).toBe('insight');
|
|
});
|
|
|
|
test('strips markdown code fences', () => {
|
|
const raw = '```json\n[{"title":"T","atom_type":"quote","body":"b"}]\n```';
|
|
expect(parseAtomsResponse(raw).length).toBe(1);
|
|
});
|
|
|
|
test('tolerates trailing prose after JSON', () => {
|
|
const raw = `[{"title":"T","atom_type":"framework","body":"b"}]\n\nThanks!`;
|
|
expect(parseAtomsResponse(raw).length).toBe(1);
|
|
});
|
|
|
|
test('rejects atoms with invalid atom_type', () => {
|
|
const raw = `[{"title":"T","atom_type":"made_up_type","body":"b"}]`;
|
|
expect(parseAtomsResponse(raw).length).toBe(0);
|
|
});
|
|
|
|
test('rejects atoms missing required fields', () => {
|
|
const raw = `[{"title":"T","atom_type":"insight"}]`; // no body
|
|
expect(parseAtomsResponse(raw).length).toBe(0);
|
|
});
|
|
|
|
test('returns [] on garbage input', () => {
|
|
expect(parseAtomsResponse('not json')).toEqual([]);
|
|
expect(parseAtomsResponse('')).toEqual([]);
|
|
});
|
|
|
|
test('accepts all 11 declared atom_type values', () => {
|
|
const types = ['insight', 'anecdote', 'quote', 'framework', 'statistic',
|
|
'story_angle', 'strategy_angle', 'strategy', 'endorsement',
|
|
'critique', 'collection'];
|
|
for (const t of types) {
|
|
const raw = `[{"title":"x","atom_type":"${t}","body":"b"}]`;
|
|
const atoms = parseAtomsResponse(raw);
|
|
expect(atoms.length).toBe(1);
|
|
expect(atoms[0].atom_type as string).toBe(t);
|
|
}
|
|
});
|
|
|
|
test('clamps virality_score to [0, 100]', () => {
|
|
expect(parseAtomsResponse(`[{"title":"a","atom_type":"insight","body":"b","virality_score":150}]`)[0].virality_score).toBeUndefined();
|
|
expect(parseAtomsResponse(`[{"title":"a","atom_type":"insight","body":"b","virality_score":-5}]`)[0].virality_score).toBeUndefined();
|
|
expect(parseAtomsResponse(`[{"title":"a","atom_type":"insight","body":"b","virality_score":75}]`)[0].virality_score).toBe(75);
|
|
});
|
|
});
|
|
|
|
describe('v0.41 T5: runPhaseExtractAtoms via stubbed chat', () => {
|
|
test('no-op when no transcripts AND no pages provided', async () => {
|
|
// v0.41.2.1: _pages:[] suppresses page-discovery so this matches the
|
|
// pre-v0.41.2.1 "transcript-only no-op" path. Reason changed from
|
|
// 'no_transcripts' to 'no_work' to reflect the dual-source design.
|
|
const result = await runPhaseExtractAtoms(engine, { _transcripts: [], _pages: [] });
|
|
expect(result.status).toBe('skipped');
|
|
expect(result.details?.reason).toBe('no_work');
|
|
});
|
|
|
|
test('extracts atoms from transcript via stub chat', async () => {
|
|
const chat = stubChat(`[
|
|
{"title":"Renders vs physical proof","atom_type":"insight","body":"Enterprise buyers want tangible prototypes."},
|
|
{"title":"Founder lesson","atom_type":"anecdote","body":"Story about a founder."}
|
|
]`);
|
|
const result = await runPhaseExtractAtoms(engine, {
|
|
_transcripts: [{ filePath: '/fake/meeting.txt', content: 'content', contentHash: 'abc123def' }],
|
|
_pages: [], // suppress page discovery — transcript-only test
|
|
_chat: chat,
|
|
});
|
|
expect(result.status).toBe('ok');
|
|
expect(result.details?.atoms_extracted).toBe(2);
|
|
expect(result.details?.transcripts_processed).toBe(1);
|
|
|
|
// Verify pages were written
|
|
const rows = await engine.executeRaw<{ slug: string; type: string }>(
|
|
`SELECT slug, type FROM pages WHERE type = 'atom'`,
|
|
);
|
|
expect(rows.length).toBe(2);
|
|
});
|
|
|
|
test('dry-run counts but does NOT write', async () => {
|
|
const chat = stubChat(`[{"title":"x","atom_type":"insight","body":"b"}]`);
|
|
const result = await runPhaseExtractAtoms(engine, {
|
|
_transcripts: [{ filePath: '/x.txt', content: 'c', contentHash: 'h' }],
|
|
_pages: [],
|
|
_chat: chat,
|
|
dryRun: true,
|
|
});
|
|
expect(result.details?.atoms_extracted).toBe(1);
|
|
expect(result.details?.dry_run).toBe(true);
|
|
const rows = await engine.executeRaw<{ count: number }>(
|
|
`SELECT COUNT(*)::int AS count FROM pages WHERE type = 'atom'`,
|
|
);
|
|
expect(rows[0].count).toBe(0);
|
|
});
|
|
|
|
test('failures tracked per-transcript without halting', async () => {
|
|
let callCount = 0;
|
|
const chat = async (_o: ChatOpts) => {
|
|
callCount++;
|
|
if (callCount === 1) throw new Error('rate limit');
|
|
return {
|
|
text: `[{"title":"t","atom_type":"insight","body":"b"}]`,
|
|
blocks: [],
|
|
stopReason: 'end' as const,
|
|
usage: { input_tokens: 100, output_tokens: 50, cache_read_tokens: 0, cache_creation_tokens: 0 },
|
|
model: 'anthropic:claude-haiku-4-5',
|
|
providerId: 'anthropic',
|
|
};
|
|
};
|
|
const result = await runPhaseExtractAtoms(engine, {
|
|
_transcripts: [
|
|
{ filePath: '/a.txt', content: 'a', contentHash: 'ha' },
|
|
{ filePath: '/b.txt', content: 'b', contentHash: 'hb' },
|
|
],
|
|
_pages: [],
|
|
_chat: chat as typeof import('../../src/core/ai/gateway.ts').chat,
|
|
});
|
|
expect(result.status).toBe('warn');
|
|
expect(result.details?.atoms_extracted).toBe(1);
|
|
expect((result.details?.failures as unknown[]).length).toBe(1);
|
|
});
|
|
|
|
// v0.41.2.1 regression case (D9 #14 wording): with _pages:[] and same
|
|
// _transcripts, all PRE-EXISTING PhaseResult.details fields match
|
|
// pre-fix values byte-for-byte. The new fields (pages_processed,
|
|
// pages_total, pages_skipped_budget, duplicates_skipped) exist but
|
|
// are zeros. Closes the "transcript path silently regresses" risk.
|
|
test('legacy transcript-only fields unchanged when _pages:[] (regression guard)', async () => {
|
|
const chat = stubChat(`[{"title":"r","atom_type":"insight","body":"b"}]`);
|
|
const result = await runPhaseExtractAtoms(engine, {
|
|
_transcripts: [{ filePath: '/regression.txt', content: 'c', contentHash: 'rH' }],
|
|
_pages: [],
|
|
_chat: chat,
|
|
});
|
|
expect(result.status).toBe('ok');
|
|
// Pre-existing fields — must keep their pre-fix values verbatim
|
|
expect(result.details?.atoms_extracted).toBe(1);
|
|
expect(result.details?.transcripts_processed).toBe(1);
|
|
expect(result.details?.transcripts_total).toBe(1);
|
|
expect(result.details?.transcripts_skipped_budget).toBe(0);
|
|
expect(result.details?.failures).toEqual([]);
|
|
expect(result.details?.budget_usd).toBe(0.3);
|
|
expect(result.details?.source_id).toBe('default');
|
|
expect(result.details?.dry_run).toBe(false);
|
|
// New additive fields — zero when no page work
|
|
expect(result.details?.pages_processed).toBe(0);
|
|
expect(result.details?.pages_total).toBe(0);
|
|
expect(result.details?.pages_skipped_budget).toBe(0);
|
|
expect(result.details?.duplicates_skipped).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('v0.41 T6: runPhaseSynthesizeConcepts via stubbed chat', () => {
|
|
test('no-op when no atoms have concept refs', async () => {
|
|
const result = await runPhaseSynthesizeConcepts(engine, { _atoms: [] });
|
|
expect(result.status).toBe('skipped');
|
|
expect(result.details?.reason).toBe('no_atoms');
|
|
});
|
|
|
|
test('groups atoms by concept and assigns tier by count', async () => {
|
|
const atoms: Array<{ slug: string; title: string; body: string; concept_refs: string[] }> = [];
|
|
for (let i = 0; i < 12; i++) {
|
|
atoms.push({
|
|
slug: `atoms/2026-05-24/atom-${i}`,
|
|
title: `Atom ${i}`,
|
|
body: `Body of atom ${i}.`,
|
|
concept_refs: ['ai-agents'],
|
|
});
|
|
}
|
|
for (let i = 0; i < 6; i++) {
|
|
atoms.push({
|
|
slug: `atoms/2026-05-24/founder-${i}`,
|
|
title: `Founder ${i}`,
|
|
body: `Founder body ${i}.`,
|
|
concept_refs: ['founder-psychology'],
|
|
});
|
|
}
|
|
for (let i = 0; i < 3; i++) {
|
|
atoms.push({
|
|
slug: `atoms/2026-05-24/hw-${i}`,
|
|
title: `HW ${i}`,
|
|
body: `HW body ${i}.`,
|
|
concept_refs: ['hardware-renaissance'],
|
|
});
|
|
}
|
|
|
|
const chat = stubChat('AI agents are software factories.');
|
|
const result = await runPhaseSynthesizeConcepts(engine, { _atoms: atoms, _chat: chat });
|
|
expect(result.status).toBe('ok');
|
|
expect(result.details?.concepts_written).toBe(3);
|
|
const tiers = result.details?.tier_counts as Record<string, number>;
|
|
expect(tiers.T1).toBe(1); // ai-agents (12)
|
|
expect(tiers.T2).toBe(1); // founder-psychology (6)
|
|
expect(tiers.T3).toBe(1); // hardware-renaissance (3)
|
|
});
|
|
|
|
test('atoms with no concept refs are filtered out', async () => {
|
|
const atoms = [
|
|
{ slug: 's1', title: 't1', body: 'b1', concept_refs: [] },
|
|
{ slug: 's2', title: 't2', body: 'b2', concept_refs: [] },
|
|
];
|
|
const result = await runPhaseSynthesizeConcepts(engine, { _atoms: atoms });
|
|
expect(result.status).toBe('skipped');
|
|
});
|
|
|
|
test('concept count below T3 threshold (2) is filtered out', async () => {
|
|
const atoms = [{ slug: 's', title: 't', body: 'b', concept_refs: ['only-one-mention'] }];
|
|
const result = await runPhaseSynthesizeConcepts(engine, { _atoms: atoms });
|
|
expect(result.status).toBe('skipped');
|
|
expect(result.details?.reason).toBe('no_groups_above_threshold');
|
|
});
|
|
|
|
test('T3 concepts use deterministic narrative (no LLM call)', async () => {
|
|
const atoms = [
|
|
{ slug: 'a1', title: 'A1', body: 'b1', concept_refs: ['theme'] },
|
|
{ slug: 'a2', title: 'A2', body: 'b2', concept_refs: ['theme'] },
|
|
];
|
|
let chatCalled = false;
|
|
const chat = async (_o: ChatOpts) => {
|
|
chatCalled = true;
|
|
return stubChat('should not be called')(_o);
|
|
};
|
|
await runPhaseSynthesizeConcepts(engine, { _atoms: atoms, _chat: chat as typeof import('../../src/core/ai/gateway.ts').chat });
|
|
expect(chatCalled).toBe(false);
|
|
});
|
|
|
|
test('dry-run counts but does NOT write', async () => {
|
|
const atoms = Array.from({ length: 6 }, (_, i) => ({
|
|
slug: `s${i}`,
|
|
title: `T${i}`,
|
|
body: `b${i}`,
|
|
concept_refs: ['theme'],
|
|
}));
|
|
const chat = stubChat('synthesized narrative');
|
|
const result = await runPhaseSynthesizeConcepts(engine, {
|
|
_atoms: atoms,
|
|
_chat: chat,
|
|
dryRun: true,
|
|
});
|
|
expect(result.details?.concepts_written).toBe(1);
|
|
expect(result.details?.dry_run).toBe(true);
|
|
const rows = await engine.executeRaw<{ count: number }>(
|
|
`SELECT COUNT(*)::int AS count FROM pages WHERE type = 'concept' AND slug LIKE 'concepts/%'`,
|
|
);
|
|
expect(rows[0].count).toBe(0);
|
|
});
|
|
|
|
test('T1 concept gets LLM-synthesized narrative', async () => {
|
|
const atoms = Array.from({ length: 12 }, (_, i) => ({
|
|
slug: `a${i}`,
|
|
title: `T${i}`,
|
|
body: `b${i}`,
|
|
concept_refs: ['theme'],
|
|
}));
|
|
const chat = stubChat('Custom synthesized narrative from LLM.');
|
|
await runPhaseSynthesizeConcepts(engine, { _atoms: atoms, _chat: chat });
|
|
const rows = await engine.executeRaw<{ compiled_truth: string }>(
|
|
`SELECT compiled_truth FROM pages WHERE slug = 'concepts/theme'`,
|
|
);
|
|
expect(rows[0].compiled_truth).toContain('Custom synthesized narrative');
|
|
});
|
|
});
|