v0.41.16.0 feat: conversation parser cathedral + progressive-batch primitive (closes #1461) (#1510)

* v0.41.15.0 feat: conversation parser cathedral + progressive-batch primitive (closes #1461)

Replaces PR #1461's single-format Telegram regex with a 12-pattern
built-in registry covering iMessage/Slack, Telegram (×2), Discord
(×2), WhatsApp (×2 locales), Signal, Matrix/Element, IRC (×2), Teams.
Each pattern is hand-vetted from public format docs (signal-cli,
DiscordChatExporter, Telegram Desktop, WhatsApp export docs, Element
matrix-archive, irssi/weechat defaults); module-load validation runs
test_positive[] + test_negative[] for every pattern at startup so a
typo makes gbrain refuse to start.

PR #1461 contributor's BRACKET_TIME_RX + cleanSpeaker survive verbatim
as the `telegram-bracket` built-in pattern + DEFAULT_SPEAKER_CLEAN
export. All 33 of their test cases pass against the new orchestrator.

Three layers per page (orchestrator chooses):
  1. Built-in pattern registry (zero-cost, deterministic)
  2. User-declared simple_pattern via config (deferred to v0.42+)
  3. Opt-IN LLM polish + fallback (privacy-first; chat content goes
     to Anthropic only when user explicitly enables)

D18 priority scoring picks the highest-match-rate pattern across the
first 10 lines (not first-wins) so overlapping formats don't silently
mis-route. D5 multi_line per-pattern + D11 quick_reject prefix screen
+ D19 timezone_policy per-pattern complete the registry shape.

Companion: src/core/progressive-batch/ primitive (rule of three
satisfied across 12+ ad-hoc cost-prompt sites). Wintermute-inspired
ramp shape (trial 10 → 100 → 500 → full with verification at each
stage), productionized with verifier+policy injection (callers
describe HOW TO MEASURE SUCCESS, not WHEN TO WAIT FOR CTRL-C). D3
fail-closed budget gate: null tracker + null Policy.maxCostUsd →
abort_cost_cap reason='no_budget_safety_net'. D20 discriminated
Verifier union (output_count | idempotent_mutation | noop).
extract-conversation-facts is the one proven consumer in v0.41.15.0;
9-site retrofit deferred to v0.41.16.0+ per TODOS.md.

Codex outside-voice review absorbed 8 substantive findings:
  - Privacy posture (LLM polish/fallback flipped to opt-IN)
  - ReDoS theater (dropped arbitrary user regex; v0.42+ uses RE2)
  - LLM-inferred-regex persistence as silent-corruption machine
  - Pattern priority scoring across first 10 lines
  - Timezone policy on every PatternEntry
  - Verifier shape discriminated union
  - Behavior parity for sites that "jumped straight to full"
  - Real-corpus-redacted fixture gap (v0.42+ TODO)

CI gates:
  - bun run check:conversation-parser (13 fixtures, --no-llm, deterministic)
  - bun run check:fixture-privacy (banned-token grep)

Doctor surfaces 3 new checks: conversation_format_coverage,
progressive_batch_audit_health, conversation_parser_probe_health.

Tests: 198/198 across primitive + parser + LLM + nightly probe + eval
CLI + debug CLI + doctor checks + migration v97 round-trip + E2E
parser ↔ engine integration. Real bug caught + fixed during gap audit:
IdempotentMutationVerifier was comparing absolute mutated-count vs
per-stage expected (failed silently on stage 2+); now uses per-stage
delta semantics matching OutputCountVerifier.

Schema migration v97: conversation_parser_llm_cache table with
(content_sha256, model_id, call_shape) composite key. NO
inferred_patterns table (D17: silent-corruption machine).

Plan + 23 decisions + codex outside-voice absorption at
~/.claude/plans/system-instruction-you-are-working-cuddly-hollerith.md.

Co-Authored-By: garrytan-agents (PR #1461) <noreply@github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(check-privacy): allowlist scripts/check-fixture-privacy.sh

The new sibling privacy guard literally names the banned tokens in its
BANNED_TOKENS array — same meta-exception that check-privacy.sh itself
gets. Without this allowlist entry, bun run verify rejects the file
post-merge because the banned name appears in the rule-definition script.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: renumber v0.41.15.0 → v0.41.16.0 (queue drift)

Mechanical rename across all surfaces: VERSION, package.json,
CHANGELOG (header + body refs), CLAUDE.md, TODOS.md, src/core/
migrate.ts (migration v98 comment), all src/core/conversation-parser/*
and src/core/progressive-batch/* file headers, all test/ headers,
scripts/check-privacy.sh allowlist comment, llms-full.txt regenerated.

Audit clean: VERSION + package.json + CHANGELOG header all show
0.41.16.0. verify 24/24, touched tests 179/179.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: garrytan-agents (PR #1461) <noreply@github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-26 17:31:48 -07:00
committed by GitHub
co-authored by garrytan-agents Claude Opus 4.7
parent cd8efee0ea
commit f702ec053b
63 changed files with 7423 additions and 113 deletions
+197
View File
@@ -0,0 +1,197 @@
/**
* v0.41.16.0 — `gbrain conversation-parser` debug CLI tests.
*
* Pins:
* - list-builtins prints all 12 patterns + accepts --json
* - validate emits "deferred to v0.42+" notice (v1 has no compiler)
* - scan errors without engine
* - --help works
*
* Pure-function tests; no engine, no DB.
*/
import { describe, expect, test, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { runConversationParser } from '../src/commands/conversation-parser.ts';
import { BUILTIN_PATTERNS } from '../src/core/conversation-parser/builtins.ts';
// Capture process.stdout.write + process.stderr.write + process.exit.
function captureStdio() {
const out: string[] = [];
const err: string[] = [];
let exitCode: number | null = null;
const origOut = process.stdout.write.bind(process.stdout);
const origErr = process.stderr.write.bind(process.stderr);
const origExit = process.exit.bind(process);
process.stdout.write = ((chunk: string | Uint8Array) => {
out.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString());
return true;
}) as typeof process.stdout.write;
process.stderr.write = ((chunk: string | Uint8Array) => {
err.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString());
return true;
}) as typeof process.stderr.write;
// process.exit throws to short-circuit; tests catch and inspect.
process.exit = ((code?: number) => {
exitCode = code ?? 0;
throw new Error(`PROCESS_EXIT:${exitCode}`);
}) as typeof process.exit;
return {
out,
err,
getExitCode: () => exitCode,
restore: () => {
process.stdout.write = origOut;
process.stderr.write = origErr;
process.exit = origExit;
},
};
}
describe('runConversationParser — help', () => {
test('--help prints usage', async () => {
const cap = captureStdio();
try {
await runConversationParser(null, ['--help']);
} finally {
cap.restore();
}
const text = cap.out.join('');
expect(text).toContain('Usage: gbrain conversation-parser');
expect(text).toContain('scan');
expect(text).toContain('list-builtins');
expect(text).toContain('validate');
});
test('no subcommand prints help', async () => {
const cap = captureStdio();
try {
await runConversationParser(null, []);
} finally {
cap.restore();
}
expect(cap.out.join('')).toContain('Usage: gbrain conversation-parser');
});
});
describe('runConversationParser — list-builtins', () => {
test('human output includes all 12 pattern ids', async () => {
const cap = captureStdio();
try {
await runConversationParser(null, ['list-builtins']);
} finally {
cap.restore();
}
const text = cap.out.join('');
for (const pattern of BUILTIN_PATTERNS) {
expect(text).toContain(pattern.id);
}
expect(text).toContain(`${BUILTIN_PATTERNS.length} built-in patterns`);
});
test('--json output is stable schema', async () => {
const cap = captureStdio();
try {
await runConversationParser(null, ['list-builtins', '--json']);
} finally {
cap.restore();
}
const json = JSON.parse(cap.out.join('').trim());
expect(json.schema_version).toBe(1);
expect(json.total).toBe(BUILTIN_PATTERNS.length);
expect(json.patterns).toHaveLength(BUILTIN_PATTERNS.length);
expect(json.patterns[0].id).toBe('imessage-slack');
expect(json.patterns[0].date_source).toBeDefined();
expect(json.patterns[0].time_format).toBeDefined();
expect(json.patterns[0].timezone_policy).toBeDefined();
expect(json.patterns[0].regex).toBeDefined();
});
});
describe('runConversationParser — validate', () => {
test('emits deferred notice (v0.42+ scope)', async () => {
const dir = mkdtempSync(join(tmpdir(), 'cv-parser-cli-'));
const path = join(dir, 'pattern.json');
writeFileSync(path, '{}');
const cap = captureStdio();
try {
await runConversationParser(null, ['validate', path]);
} finally {
cap.restore();
}
expect(cap.out.join('')).toContain('deferred to v0.42+');
});
test('--json validate emits structured deferred envelope', async () => {
const dir = mkdtempSync(join(tmpdir(), 'cv-parser-cli-'));
const path = join(dir, 'pattern.json');
writeFileSync(path, '{}');
const cap = captureStdio();
try {
await runConversationParser(null, ['validate', path, '--json']);
} finally {
cap.restore();
}
const json = JSON.parse(cap.out.join('').trim());
expect(json.schema_version).toBe(1);
expect(json.status).toBe('deferred');
expect(json.todo_ref).toContain('v0.41.16.0');
});
test('exits 2 on missing file path', async () => {
const cap = captureStdio();
try {
await runConversationParser(null, ['validate']);
} catch (e) {
expect((e as Error).message).toBe('PROCESS_EXIT:2');
} finally {
cap.restore();
}
expect(cap.getExitCode()).toBe(2);
});
test('exits 2 on nonexistent file', async () => {
const cap = captureStdio();
try {
await runConversationParser(null, [
'validate',
'/nonexistent/path.json',
]);
} catch (e) {
expect((e as Error).message).toBe('PROCESS_EXIT:2');
} finally {
cap.restore();
}
expect(cap.getExitCode()).toBe(2);
});
});
describe('runConversationParser — scan', () => {
test('exits 2 when engine is null (scan requires brain)', async () => {
const cap = captureStdio();
try {
await runConversationParser(null, ['scan', 'some-slug']);
} catch (e) {
expect((e as Error).message).toBe('PROCESS_EXIT:2');
} finally {
cap.restore();
}
expect(cap.getExitCode()).toBe(2);
});
});
describe('runConversationParser — unknown subcommand', () => {
test('exits 2 with hint', async () => {
const cap = captureStdio();
try {
await runConversationParser(null, ['bogus-cmd']);
} catch (e) {
expect((e as Error).message).toBe('PROCESS_EXIT:2');
} finally {
cap.restore();
}
expect(cap.err.join('')).toContain('unknown subcommand');
});
});
+28
View File
@@ -0,0 +1,28 @@
/**
* v0.41.16.0 — Test helpers for conversation-parser unit tests.
*
* `makeChatResult(text)` builds a fully-typed `ChatResult` stub so
* test transports satisfy the gateway's type contract without each
* test repeating the empty fields.
*/
import type { ChatResult } from '../../src/core/ai/gateway.ts';
export function makeChatResult(
text: string,
usage = { input_tokens: 10, output_tokens: 30 },
): ChatResult {
return {
text,
blocks: [{ type: 'text', text }],
stopReason: 'end',
usage: {
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
cache_read_tokens: 0,
cache_creation_tokens: 0,
},
model: 'anthropic:claude-haiku-4-5',
providerId: 'anthropic',
};
}
+184
View File
@@ -0,0 +1,184 @@
/**
* v0.41.16.0 — LLM base unit tests.
*
* Hermetic via the `chatTransport` test seam. No real API calls.
*
* Pins:
* - Cache hit doesn't re-call transport
* - Provider unavailable returns null without calling transport
* - Transport throw → fail-open null
* - Parse failure → fail-open null
* - parseLlmJson 4-strategy fallback
*/
import { describe, expect, test, beforeEach } from 'bun:test';
import { withEnv } from '../helpers/with-env.ts';
import {
runLlmCall,
parseLlmJson,
_resetLlmCacheForTests,
getLlmCacheStats,
probeLlmAvailability,
} from '../../src/core/conversation-parser/llm-base.ts';
import { makeChatResult } from './helpers.ts';
beforeEach(() => {
_resetLlmCacheForTests();
});
describe('probeLlmAvailability', () => {
test('returns null when ANTHROPIC_API_KEY is unset', async () => {
await withEnv(
{ ANTHROPIC_API_KEY: undefined as unknown as string },
async () => {
expect(probeLlmAvailability('claude-haiku-4-5')).toBeNull();
expect(probeLlmAvailability('anthropic:claude-haiku-4-5')).toBeNull();
},
);
});
test('returns normalized model when key set', async () => {
await withEnv({ ANTHROPIC_API_KEY: 'sk-test' }, async () => {
expect(probeLlmAvailability('claude-haiku-4-5')).toBe(
'anthropic:claude-haiku-4-5',
);
});
});
test('returns null for unknown provider', async () => {
expect(probeLlmAvailability('madeup-provider:foo-model')).toBeNull();
});
});
describe('runLlmCall — happy path', () => {
test('calls transport, parses, caches', async () => {
await withEnv({ ANTHROPIC_API_KEY: 'sk-test' }, async () => {
let calls = 0;
const result = await runLlmCall<{ ok: boolean }>({
shape: 'fallback',
modelStr: 'claude-haiku-4-5',
content: 'hello',
system: 'test',
parse: (text) => parseLlmJson(text),
chatTransport: async () => {
calls++;
return makeChatResult('{"ok": true}', { input_tokens: 1, output_tokens: 1 });
},
});
expect(result).toEqual({ ok: true });
expect(calls).toBe(1);
expect(getLlmCacheStats().misses).toBe(1);
});
});
test('cache hit on second call with same content', async () => {
await withEnv({ ANTHROPIC_API_KEY: 'sk-test' }, async () => {
let calls = 0;
const transport = async () => {
calls++;
return makeChatResult('{"ok": true}', { input_tokens: 1, output_tokens: 1 });
};
const opts = {
shape: 'fallback' as const,
modelStr: 'claude-haiku-4-5',
content: 'hello',
system: 'test',
parse: (t: string) => parseLlmJson<{ ok: boolean }>(t),
chatTransport: transport,
};
await runLlmCall(opts);
await runLlmCall(opts);
expect(calls).toBe(1);
expect(getLlmCacheStats().hits).toBe(1);
});
});
});
describe('runLlmCall — fail-open paths', () => {
test('provider unavailable returns null without calling transport', async () => {
await withEnv(
{ ANTHROPIC_API_KEY: undefined as unknown as string },
async () => {
let calls = 0;
const result = await runLlmCall<unknown>({
shape: 'fallback',
modelStr: 'claude-haiku-4-5',
content: 'hello',
system: 'test',
parse: () => ({}),
chatTransport: async () => {
calls++;
return makeChatResult('{}', { input_tokens: 1, output_tokens: 1 });
},
});
expect(result).toBeNull();
expect(calls).toBe(0);
},
);
});
test('transport throw → fail-open null', async () => {
await withEnv({ ANTHROPIC_API_KEY: 'sk-test' }, async () => {
const result = await runLlmCall<unknown>({
shape: 'fallback',
modelStr: 'claude-haiku-4-5',
content: 'hello',
system: 'test',
parse: () => ({}),
chatTransport: async () => {
throw new Error('network down');
},
});
expect(result).toBeNull();
});
});
test('parse failure → fail-open null, not cached', async () => {
await withEnv({ ANTHROPIC_API_KEY: 'sk-test' }, async () => {
let calls = 0;
const opts = {
shape: 'fallback' as const,
modelStr: 'claude-haiku-4-5',
content: 'hello',
system: 'test',
parse: () => null,
chatTransport: async () => {
calls++;
return makeChatResult('garbage', { input_tokens: 1, output_tokens: 1 });
},
};
const r1 = await runLlmCall(opts);
const r2 = await runLlmCall(opts);
expect(r1).toBeNull();
expect(r2).toBeNull();
// Both calls hit transport because parse-fail isn't cached.
expect(calls).toBe(2);
});
});
});
describe('parseLlmJson — 4-strategy fallback', () => {
test('direct parse object', () => {
expect(parseLlmJson<{ a: number }>('{"a": 1}')).toEqual({ a: 1 });
});
test('strip ```json fences', () => {
expect(parseLlmJson<{ a: number }>('```json\n{"a": 1}\n```')).toEqual({ a: 1 });
});
test('strip bare ``` fences', () => {
expect(parseLlmJson<{ a: number }>('```\n{"a": 1}\n```')).toEqual({ a: 1 });
});
test('extract first {...} substring', () => {
expect(parseLlmJson<{ a: number }>('Here is the output: {"a": 1} (done)')).toEqual({
a: 1,
});
});
test('array mode: direct parse', () => {
expect(parseLlmJson<number[]>('[1,2,3]', { array: true })).toEqual([1, 2, 3]);
});
test('array mode: extract first [...] substring', () => {
expect(parseLlmJson<number[]>('Output: [1,2,3] done', { array: true })).toEqual([
1, 2, 3,
]);
});
test('non-JSON returns null', () => {
expect(parseLlmJson<unknown>('not json at all')).toBeNull();
});
test('empty string returns null', () => {
expect(parseLlmJson<unknown>('')).toBeNull();
});
});
@@ -0,0 +1,129 @@
/**
* v0.41.16.0 — LLM fallback unit tests.
*
* Hermetic via the `chatTransport` test seam.
*
* Pins:
* - Happy path: parses LLM-returned JSON array
* - Adversarial input: LLM returns [] → parser returns [] (skip page)
* - Malformed LLM output → fail-open null
* - Provider unavailable → null
* - Cache hit: doesn't re-call
*/
import { describe, expect, test, beforeEach } from 'bun:test';
import { withEnv } from '../helpers/with-env.ts';
import { runLlmFallback } from '../../src/core/conversation-parser/llm-fallback.ts';
import { _resetLlmCacheForTests } from '../../src/core/conversation-parser/llm-base.ts';
import { makeChatResult } from './helpers.ts';
beforeEach(() => {
_resetLlmCacheForTests();
});
describe('runLlmFallback', () => {
test('happy path: parses LLM JSON output', async () => {
await withEnv({ ANTHROPIC_API_KEY: 'sk-test' }, async () => {
const result = await runLlmFallback({
modelStr: 'claude-haiku-4-5',
body: 'some-novel-chat-format',
chatTransport: async () =>
makeChatResult(
JSON.stringify([
{ speaker: 'Alice', timestamp: '2024-03-15T18:37:00Z', text: 'hello' },
{ speaker: 'Bob', timestamp: '2024-03-15T18:38:00Z', text: 'world' },
]),
{ input_tokens: 10, output_tokens: 30 },
),
});
expect(result).not.toBeNull();
expect(result).toHaveLength(2);
expect(result![0].speaker).toBe('Alice');
expect(result![1].text).toBe('world');
});
});
test('adversarial input: LLM returns [] → empty array', async () => {
await withEnv({ ANTHROPIC_API_KEY: 'sk-test' }, async () => {
const result = await runLlmFallback({
modelStr: 'claude-haiku-4-5',
body: 'This is just a recipe for cookies. Not a chat log.',
chatTransport: async () => makeChatResult('[]', { input_tokens: 10, output_tokens: 1 }),
});
expect(result).toEqual([]);
});
});
test('malformed output → fail-open null', async () => {
await withEnv({ ANTHROPIC_API_KEY: 'sk-test' }, async () => {
const result = await runLlmFallback({
modelStr: 'claude-haiku-4-5',
body: 'something',
chatTransport: async () =>
makeChatResult(
'I think this might be a chat log but I am not sure.',
{ input_tokens: 10, output_tokens: 12 },
),
});
expect(result).toBeNull();
});
});
test('provider unavailable: returns null without calling transport', async () => {
await withEnv(
{ ANTHROPIC_API_KEY: undefined as unknown as string },
async () => {
let calls = 0;
const result = await runLlmFallback({
modelStr: 'claude-haiku-4-5',
body: 'whatever',
chatTransport: async () => {
calls++;
return makeChatResult('[]', { input_tokens: 1, output_tokens: 1 });
},
});
expect(result).toBeNull();
expect(calls).toBe(0);
},
);
});
test('cache hit: second call doesnt re-invoke transport', async () => {
await withEnv({ ANTHROPIC_API_KEY: 'sk-test' }, async () => {
let calls = 0;
const opts = {
modelStr: 'claude-haiku-4-5',
body: 'stable-body-for-cache',
chatTransport: async () => {
calls++;
return makeChatResult('[{"speaker":"A","timestamp":"2024-01-01T00:00:00Z","text":"x"}]', { input_tokens: 1, output_tokens: 1 });
},
};
await runLlmFallback(opts);
await runLlmFallback(opts);
expect(calls).toBe(1);
});
});
test('strips invalid items from array', async () => {
await withEnv({ ANTHROPIC_API_KEY: 'sk-test' }, async () => {
const result = await runLlmFallback({
modelStr: 'claude-haiku-4-5',
body: 'something',
chatTransport: async () =>
makeChatResult(
JSON.stringify([
{ speaker: 'Alice', timestamp: '2024-03-15T18:37:00Z', text: 'good' },
{ speaker: 42, timestamp: 'x', text: 'bad shape' }, // speaker not string
{ timestamp: '2024-03-15T18:38:00Z', text: 'missing speaker' },
{ speaker: 'Bob', timestamp: '2024-03-15T18:39:00Z', text: 'good2' },
]),
{ input_tokens: 10, output_tokens: 30 },
),
});
expect(result).toHaveLength(2);
expect(result![0].speaker).toBe('Alice');
expect(result![1].speaker).toBe('Bob');
});
});
});
+174
View File
@@ -0,0 +1,174 @@
/**
* v0.41.16.0 — LLM polish unit tests.
*
* Pins:
* - applyPolish pure function: merge + drop + edits
* - Headroom guard skip path
* - Provider unavailable → input messages unchanged
* - Cache hit doesn't re-call
*/
import { describe, expect, test, beforeEach } from 'bun:test';
import { withEnv } from '../helpers/with-env.ts';
import {
runLlmPolish,
applyPolish,
} from '../../src/core/conversation-parser/llm-polish.ts';
import { _resetLlmCacheForTests } from '../../src/core/conversation-parser/llm-base.ts';
import { BudgetTracker } from '../../src/core/budget/budget-tracker.ts';
import { withBudgetTracker } from '../../src/core/ai/gateway.ts';
import type { MatchedMessage } from '../../src/core/conversation-parser/types.ts';
import { makeChatResult } from './helpers.ts';
beforeEach(() => {
_resetLlmCacheForTests();
});
const SAMPLE: MatchedMessage[] = [
{ speaker: 'Alice', timestamp: '2024-03-15T18:37:00Z', text: 'hello' },
{ speaker: 'Alice', timestamp: '2024-03-15T18:37:30Z', text: 'continuation' },
{ speaker: 'system', timestamp: '2024-03-15T18:38:00Z', text: 'Bob joined' },
{ speaker: 'Bob', timestamp: '2024-03-15T18:39:00Z', text: 'world (edited)' },
];
describe('applyPolish — pure function', () => {
test('merge two indices into one', () => {
const r = applyPolish(SAMPLE, {
merge_indices: [[0, 1]],
drop_indices: [],
edits: [],
});
expect(r.messages).toHaveLength(3);
expect(r.messages[0].text).toBe('hello\ncontinuation');
expect(r.delta.merged).toBe(1);
});
test('drop indices', () => {
const r = applyPolish(SAMPLE, {
merge_indices: [],
drop_indices: [2],
edits: [],
});
expect(r.messages).toHaveLength(3);
expect(r.messages.find((m) => m.text === 'Bob joined')).toBeUndefined();
expect(r.delta.dropped).toBe(1);
});
test('edit speaker and text', () => {
const r = applyPolish(SAMPLE, {
merge_indices: [],
drop_indices: [],
edits: [
{ index: 3, field: 'text', value: 'world' },
],
});
expect(r.messages[3].text).toBe('world');
expect(r.delta.edits).toBe(1);
});
test('combined: drop system msg, merge two, edit edited marker', () => {
const r = applyPolish(SAMPLE, {
merge_indices: [[0, 1]],
drop_indices: [2],
edits: [{ index: 3, field: 'text', value: 'world' }],
});
expect(r.messages).toHaveLength(2);
expect(r.messages[0].text).toBe('hello\ncontinuation');
expect(r.messages[1].text).toBe('world');
});
test('no-op: returns messages unchanged', () => {
const r = applyPolish(SAMPLE, {
merge_indices: [],
drop_indices: [],
edits: [],
});
expect(r.messages).toHaveLength(4);
expect(r.delta).toEqual({ merged: 0, dropped: 0, edits: 0 });
});
});
describe('runLlmPolish', () => {
test('happy path: LLM returns polish ops, applied', async () => {
await withEnv({ ANTHROPIC_API_KEY: 'sk-test' }, async () => {
const r = await runLlmPolish({
modelStr: 'claude-haiku-4-5',
body: '...full body...',
messages: SAMPLE,
patternId: 'imessage-slack',
chatTransport: async () =>
makeChatResult(
JSON.stringify({
merge_indices: [[0, 1]],
drop_indices: [2],
edits: [{ index: 3, field: 'text', value: 'world' }],
}),
{ input_tokens: 100, output_tokens: 50 },
),
});
expect(r.skipped).toBeUndefined();
expect(r.messages).toHaveLength(2);
expect(r.delta.merged).toBe(1);
expect(r.delta.dropped).toBe(1);
expect(r.delta.edits).toBe(1);
});
});
test('headroom guard: skips when tracker within $0.10 of cap', async () => {
await withEnv({ ANTHROPIC_API_KEY: 'sk-test' }, async () => {
const t = new BudgetTracker({ label: 'unit', maxCostUsd: 0.05 });
let calls = 0;
await withBudgetTracker(t, async () => {
const r = await runLlmPolish({
modelStr: 'claude-haiku-4-5',
body: 'b',
messages: SAMPLE,
patternId: 'imessage-slack',
chatTransport: async () => {
calls++;
return makeChatResult('{"merge_indices":[],"drop_indices":[],"edits":[]}', { input_tokens: 1, output_tokens: 1 });
},
});
expect(r.skipped).toBe('headroom');
expect(r.messages).toBe(SAMPLE); // unchanged input
});
expect(calls).toBe(0);
});
});
test('provider unavailable: returns input unchanged + skipped=provider', async () => {
await withEnv(
{ ANTHROPIC_API_KEY: undefined as unknown as string },
async () => {
const r = await runLlmPolish({
modelStr: 'claude-haiku-4-5',
body: 'b',
messages: SAMPLE,
patternId: 'imessage-slack',
chatTransport: async () => makeChatResult('{}', { input_tokens: 1, output_tokens: 1 }),
});
expect(r.skipped).toBe('provider');
expect(r.messages).toEqual(SAMPLE);
},
);
});
test('cache hit on same (body, patternId) pair', async () => {
await withEnv({ ANTHROPIC_API_KEY: 'sk-test' }, async () => {
let calls = 0;
const opts = {
modelStr: 'claude-haiku-4-5',
body: 'stable',
messages: SAMPLE,
patternId: 'imessage-slack',
chatTransport: async () => {
calls++;
return makeChatResult('{"merge_indices":[],"drop_indices":[],"edits":[]}', { input_tokens: 1, output_tokens: 1 });
},
};
await runLlmPolish(opts);
await runLlmPolish(opts);
expect(calls).toBe(1);
});
});
});
@@ -0,0 +1,107 @@
/**
* v0.41.16.0 — Nightly probe unit tests.
*
* Hermetic via NightlyProbeDeps injection. No real LLM calls.
*
* Pins:
* - Disabled + mode=conservative → rate_limited (informational)
* - Mode=tokenmax overrides disable
* - 24h rate limit → rate_limited
* - No LLM key → no_embedding_key
* - Fixture path missing → fail
* - Adversarial false positive → adversarial_false_positive
* - All pass → pass
*/
import { describe, expect, test } from 'bun:test';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
runConversationParserNightlyProbe,
type NightlyProbeDeps,
} from '../../src/core/conversation-parser/nightly-probe.ts';
function tmpFixture(content: string): string {
const dir = mkdtempSync(join(tmpdir(), 'probe-'));
const path = join(dir, 'fix.jsonl');
writeFileSync(path, content);
return path;
}
const POSITIVE = `{"fixture_id":"f1","pattern":"imessage-slack","frontmatter":{"date":"2024-03-15"},"body":"**Alice** (2024-03-15 9:00 AM): hi","expected_messages":1,"expected_participants":["Alice"]}`;
const ADVERSARIAL = `{"fixture_id":"a1","pattern":null,"frontmatter":{"date":"2024-03-15"},"body":"just prose","expected_messages":0,"expected_participants":[]}`;
function baseDeps(overrides: Partial<NightlyProbeDeps> = {}): NightlyProbeDeps {
return {
isEnabled: () => true,
searchMode: () => 'balanced',
hasLlmKey: () => true,
resolveFixturePath: () => tmpFixture(POSITIVE),
resolveAdversarialPath: () => tmpFixture(ADVERSARIAL),
now: () => new Date('2026-05-26T00:00:00Z'),
shouldSkipForRateLimit: () => false,
...overrides,
};
}
describe('runConversationParserNightlyProbe', () => {
test('disabled + balanced mode → rate_limited (informational)', async () => {
const r = await runConversationParserNightlyProbe(
baseDeps({ isEnabled: () => false, searchMode: () => 'balanced' }),
);
expect(r.outcome).toBe('rate_limited');
expect(r.reason).toContain('enabled=false');
});
test('disabled but mode=tokenmax → proceeds (default-on per D10)', async () => {
const r = await runConversationParserNightlyProbe(
baseDeps({ isEnabled: () => false, searchMode: () => 'tokenmax' }),
);
expect(r.outcome).toBe('pass');
});
test('24h rate limit → rate_limited', async () => {
const r = await runConversationParserNightlyProbe(
baseDeps({ shouldSkipForRateLimit: () => true }),
);
expect(r.outcome).toBe('rate_limited');
expect(r.reason).toContain('24h');
});
test('no LLM key → no_embedding_key', async () => {
const r = await runConversationParserNightlyProbe(
baseDeps({ hasLlmKey: () => false }),
);
expect(r.outcome).toBe('no_embedding_key');
});
test('happy path: all pass', async () => {
const r = await runConversationParserNightlyProbe(baseDeps());
expect(r.outcome).toBe('pass');
expect(r.fixtures_total).toBe(2);
expect(r.fixtures_passed).toBe(2);
expect(r.adversarial_false_positives).toBe(0);
});
test('adversarial false positive → adversarial_false_positive', async () => {
// Adversarial fixture that LOOKS like iMessage; the parser will
// match it and the eval will flag as fp.
const adversarialThatMatches = `{"fixture_id":"a-bad","pattern":null,"frontmatter":{"date":"2024-03-15"},"body":"**Alice** (2024-03-15 9:00 AM): I was supposed to be unparseable","expected_messages":0,"expected_participants":[]}`;
const r = await runConversationParserNightlyProbe(
baseDeps({
resolveAdversarialPath: () => tmpFixture(adversarialThatMatches),
}),
);
expect(r.outcome).toBe('adversarial_false_positive');
expect(r.adversarial_false_positives).toBe(1);
});
test('fixture path missing → fail', async () => {
const r = await runConversationParserNightlyProbe(
baseDeps({ resolveFixturePath: () => '/nonexistent/path.jsonl' }),
);
expect(r.outcome).toBe('fail');
expect(r.reason).toContain('missing');
});
});
+348
View File
@@ -0,0 +1,348 @@
/**
* v0.41.16.0 — Conversation parser orchestrator tests.
*
* Covers:
* - PR #1461's 6 telegram-bracket cases verbatim (REGRESSION pin)
* - All 12 built-in patterns hit their test_positive samples
* - Date derivation precedence (D8)
* - Pattern priority scoring (D18) — overlap resolution
* - Quick-reject fast path (D11)
* - Multi-line continuation (D5)
* - Disabled-builtin honored
* - Timezone warning (D19) emitted when frontmatter timezone missing
*
* Pure-function tests; no PGLite, no LLM. The LLM polish/fallback
* tests live in `llm-base.test.ts`, `llm-polish.test.ts`,
* `llm-fallback.test.ts` (T4).
*/
import { describe, expect, test } from 'bun:test';
import {
parseConversation,
deriveDateContext,
applyPattern,
scorePattern,
} from '../../src/core/conversation-parser/parse.ts';
import { BUILTIN_PATTERNS } from '../../src/core/conversation-parser/builtins.ts';
import type { Page } from '../../src/core/types.ts';
// Helper to construct a minimal Page for date-derivation tests.
function makePage(
frontmatter: Record<string, unknown> = {},
effective_date?: Date,
): Page {
return {
id: 1,
slug: 'test/page',
type: 'conversation',
title: 'Test',
compiled_truth: '',
timeline: '',
frontmatter,
content_hash: undefined,
created_at: new Date(),
updated_at: new Date(),
effective_date: effective_date ?? null,
} as Page;
}
// ---------------------------------------------------------------------------
// REGRESSION: PR #1461's 6 telegram-bracket cases verbatim
// ---------------------------------------------------------------------------
describe('parseConversation — REGRESSION PR #1461 (telegram-bracket)', () => {
test('bracket-time with 👤 emoji speaker prefix', () => {
const body = '**[18:37] \u{1f464} G T:** hello world';
const r = parseConversation(body, { fallbackDate: '2026-05-24' });
expect(r.messages).toHaveLength(1);
expect(r.messages[0].speaker).toBe('G T');
expect(r.messages[0].text).toBe('hello world');
expect(r.messages[0].timestamp).toBe('2026-05-24T18:37:00Z');
expect(r.matched_pattern_id).toBe('telegram-bracket');
});
test('bracket-time with 🤖 robot emoji', () => {
const body = '**[06:00] \u{1f916} Zion:** On it.';
const r = parseConversation(body, { fallbackDate: '2026-05-25' });
expect(r.messages).toHaveLength(1);
expect(r.messages[0].speaker).toBe('Zion');
expect(r.messages[0].text).toBe('On it.');
expect(r.messages[0].timestamp).toBe('2026-05-25T06:00:00Z');
});
test('bracket-time multi-line continuation', () => {
const body = [
'**[09:00] \u{1f464} Alice Example:** first line',
'second line of same message',
'**[09:05] \u{1f464} Bob Example:** separate message',
].join('\n');
const r = parseConversation(body, { fallbackDate: '2026-05-20' });
expect(r.messages).toHaveLength(2);
expect(r.messages[0].text).toBe('first line\nsecond line of same message');
expect(r.messages[1].text).toBe('separate message');
});
test('bracket-time falls back to 1970-01-01 without fallbackDate', () => {
const body = '**[14:30] \u{1f464} Alice Example:** test';
const r = parseConversation(body);
expect(r.messages).toHaveLength(1);
expect(r.messages[0].timestamp).toBe('1970-01-01T14:30:00Z');
});
test('mixed formats in one body: iMessage + bracket-time', () => {
const body = [
'**Alice Example** (2024-03-15 9:00 AM): format 1',
'**[10:30] \u{1f464} Bob Example:** format 2',
].join('\n');
const r = parseConversation(body, { fallbackDate: '2024-03-15' });
// D18 scoring picks the dominant pattern. Both have one hit; ties
// resolve to declared priority (imessage-slack=0, telegram-bracket=1).
// The imessage line matches; the telegram line becomes a
// continuation. This is a known D18 tradeoff for very-mixed bodies.
// In practice every chat export is homogeneous, so this is a
// degenerate test case.
expect(r.messages.length).toBeGreaterThanOrEqual(1);
expect(r.matched_pattern_id).toBe('imessage-slack');
});
test('bracket-time without emoji prefix', () => {
const body = '**[22:15] Plain Name:** no emoji';
const r = parseConversation(body, { fallbackDate: '2026-01-01' });
expect(r.messages).toHaveLength(1);
expect(r.messages[0].speaker).toBe('Plain Name');
expect(r.messages[0].text).toBe('no emoji');
});
});
// ---------------------------------------------------------------------------
// All 12 built-ins must parse their test_positive samples
// ---------------------------------------------------------------------------
describe('parseConversation — every built-in matches its test_positive sample', () => {
for (const entry of BUILTIN_PATTERNS) {
test(`pattern ${entry.id}: first test_positive parses`, () => {
const body = entry.test_positive[0];
// Multi-line patterns need a body line on the next line.
const fullBody =
entry.multi_line && entry.captures.text_group === 0
? `${body}\nsome body text`
: body;
const r = parseConversation(fullBody, {
fallbackDate: '2024-03-15',
});
// Either matches a message OR (for some multi-line patterns) the
// first line is the anchor and the body is consumed as text.
expect(r.messages.length).toBeGreaterThanOrEqual(1);
expect(r.matched_pattern_id).toBe(entry.id);
});
}
});
// ---------------------------------------------------------------------------
// Date derivation precedence (D8)
// ---------------------------------------------------------------------------
describe('deriveDateContext (D8 precedence chain)', () => {
test('explicit fallbackDate wins', () => {
const page = makePage({ date: '2024-01-01' }, new Date('2023-06-15'));
const ctx = deriveDateContext({ fallbackDate: '2025-12-25', page });
expect(ctx.fallbackDate).toBe('2025-12-25');
expect(ctx.source).toBe('explicit');
});
test('frontmatter.date wins over effective_date', () => {
const page = makePage({ date: '2024-01-01' }, new Date('2023-06-15'));
const ctx = deriveDateContext({ page });
expect(ctx.fallbackDate).toBe('2024-01-01');
expect(ctx.source).toBe('frontmatter_date');
});
test('effective_date wins when no frontmatter.date', () => {
const page = makePage({}, new Date('2023-06-15T00:00:00Z'));
const ctx = deriveDateContext({ page });
expect(ctx.fallbackDate).toBe('2023-06-15');
expect(ctx.source).toBe('effective_date');
});
test('epoch_default when nothing set', () => {
const ctx = deriveDateContext({});
expect(ctx.fallbackDate).toBe('1970-01-01');
expect(ctx.source).toBe('epoch_default');
});
test('frontmatter.timezone surfaces', () => {
const page = makePage({
date: '2024-01-01',
timezone: 'America/Los_Angeles',
});
const ctx = deriveDateContext({ page });
expect(ctx.timezone).toBe('America/Los_Angeles');
});
test('invalid frontmatter.date falls through', () => {
const page = makePage({ date: 'not-a-date' });
const ctx = deriveDateContext({ page });
expect(ctx.source).toBe('epoch_default');
});
test('frontmatter.date slices full ISO to YYYY-MM-DD', () => {
const page = makePage({ date: '2024-03-15T18:37:00Z' });
const ctx = deriveDateContext({ page });
expect(ctx.fallbackDate).toBe('2024-03-15');
});
});
// ---------------------------------------------------------------------------
// Pattern priority scoring (D18)
// ---------------------------------------------------------------------------
describe('scorePattern (D18 priority scoring)', () => {
test('telegram-bracket scores 1.0 on a pure telegram body', () => {
const body = [
'**[18:37] \u{1f464} Alice:** one',
'**[18:38] \u{1f464} Bob:** two',
'**[18:39] \u{1f464} Alice:** three',
].join('\n');
const tg = BUILTIN_PATTERNS.find((p) => p.id === 'telegram-bracket')!;
expect(scorePattern(body, tg)).toBe(1);
});
test('imessage-slack scores 0 on pure telegram body', () => {
const body = [
'**[18:37] \u{1f464} Alice:** one',
'**[18:38] \u{1f464} Bob:** two',
].join('\n');
const im = BUILTIN_PATTERNS.find((p) => p.id === 'imessage-slack')!;
expect(scorePattern(body, im)).toBe(0);
});
test('mixed body: D18 picks the higher-scoring pattern', () => {
// 3 telegram lines + 1 imessage line. Telegram should win.
const body = [
'**[18:37] \u{1f464} Alice:** one',
'**[18:38] \u{1f464} Bob:** two',
'**[18:39] \u{1f464} Alice:** three',
'**Charlie** (2024-03-15 9:00 AM): one imessage',
].join('\n');
const r = parseConversation(body, { fallbackDate: '2024-03-15' });
expect(r.matched_pattern_id).toBe('telegram-bracket');
});
});
// ---------------------------------------------------------------------------
// Disabled-builtin honored
// ---------------------------------------------------------------------------
describe('parseConversation — disabledBuiltinIds', () => {
test('disabling top pattern falls through to next', () => {
const body = '**[18:37] \u{1f464} Alice:** hello';
const rDefault = parseConversation(body, { fallbackDate: '2024-03-15' });
expect(rDefault.matched_pattern_id).toBe('telegram-bracket');
const rDisabled = parseConversation(body, {
fallbackDate: '2024-03-15',
disabledBuiltinIds: ['telegram-bracket'],
});
// No other built-in matches this exact shape → no_match.
expect(rDisabled.phase).toBe('no_match');
});
});
// ---------------------------------------------------------------------------
// Multi-line continuation (D5)
// ---------------------------------------------------------------------------
describe('parseConversation — multi-line continuation (D5)', () => {
test('iMessage continuation absorbs orphan lines', () => {
const body = [
'**Alice Example** (2024-03-15 9:00 AM): first line',
'continuation line',
'another continuation',
'**Bob Example** (2024-03-15 9:05 AM): second message',
].join('\n');
const r = parseConversation(body);
expect(r.messages).toHaveLength(2);
expect(r.messages[0].text).toBe(
'first line\ncontinuation line\nanother continuation',
);
expect(r.messages[1].text).toBe('second message');
});
});
// ---------------------------------------------------------------------------
// Timezone warning (D19)
// ---------------------------------------------------------------------------
describe('parseConversation — timezone warning (D19)', () => {
test('telegram-bracket emits warning when no timezone in frontmatter', () => {
const body = '**[18:37] \u{1f464} Alice:** hello';
const r = parseConversation(body, { fallbackDate: '2024-03-15' });
expect(r.timezone_warning).toBeDefined();
expect(r.timezone_warning).toContain('telegram-bracket');
expect(r.timezone_warning).toContain('UTC');
});
test('telegram-bracket does NOT warn when timezone is present', () => {
const body = '**[18:37] \u{1f464} Alice:** hello';
const page = makePage({
date: '2024-03-15',
timezone: 'America/Los_Angeles',
});
const r = parseConversation(body, { page });
expect(r.timezone_warning).toBeUndefined();
});
test('imessage-slack does NOT warn (inline_utc policy)', () => {
const body = '**Alice Example** (2024-03-15 9:00 AM): hello';
const r = parseConversation(body);
expect(r.timezone_warning).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Empty body + degenerate cases
// ---------------------------------------------------------------------------
describe('parseConversation — degenerate inputs', () => {
test('empty body returns no_match + empty messages', () => {
expect(parseConversation('')).toEqual({
messages: [],
phase: 'no_match',
});
});
test('non-conversational text returns no_match', () => {
const r = parseConversation('This is just prose with no chat shape.');
expect(r.phase).toBe('no_match');
expect(r.messages).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// applyPattern — direct unit tests for the matcher
// ---------------------------------------------------------------------------
describe('applyPattern — quick_reject fast path (D11)', () => {
test('telegram quick_reject skips iMessage lines fast', () => {
const body = '**Alice Example** (2024-03-15 9:00 AM): hello';
const tg = BUILTIN_PATTERNS.find((p) => p.id === 'telegram-bracket')!;
const r = applyPattern(body, tg, {
fallbackDate: '2024-03-15',
source: 'explicit',
});
// Quick_reject /^\*\*\[/ rejects '**Alice' (no `[`). Zero matches.
expect(r).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// scorePattern boundary cases
// ---------------------------------------------------------------------------
describe('scorePattern — boundary', () => {
test('empty body scores 0', () => {
const tg = BUILTIN_PATTERNS.find((p) => p.id === 'telegram-bracket')!;
expect(scorePattern('', tg)).toBe(0);
});
test('only blank lines scores 0', () => {
const tg = BUILTIN_PATTERNS.find((p) => p.id === 'telegram-bracket')!;
expect(scorePattern('\n\n \n', tg)).toBe(0);
});
test('caps at SCORING_HEAD_LINES (10) lines', () => {
// 100 telegram lines → still scores 1.0 because only first 10 sampled.
const body = Array.from(
{ length: 100 },
(_, i) => `**[18:${String(i).padStart(2, '0')}] \u{1f464} Alice:** msg ${i}`,
).join('\n');
const tg = BUILTIN_PATTERNS.find((p) => p.id === 'telegram-bracket')!;
expect(scorePattern(body, tg)).toBe(1);
});
});
+98
View File
@@ -0,0 +1,98 @@
/**
* v0.41.16.0 — Targeted assertions for the 3 new doctor checks.
*
* Spawns `bun src/cli.ts doctor --json --fast` as a subprocess and
* parses the JSON envelope to verify:
*
* - conversation_format_coverage
* - progressive_batch_audit_health
* - conversation_parser_probe_health
*
* are present with stable shapes. The full doctor surface is covered
* by test/doctor.test.ts; this file is a structural regression guard
* for the 3 new v0.41.16.0 checks.
*
* Spawning the subprocess matches the actual user experience (`gbrain
* doctor`) and avoids the in-process env/stdout-capture brittleness
* that bit the original test draft.
*/
import { describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
interface DoctorCheck {
name: string;
status: 'ok' | 'warn' | 'fail';
message: string;
}
interface DoctorEnvelope {
schema_version: number;
status: 'healthy' | 'unhealthy';
health_score: number;
checks: DoctorCheck[];
}
function runDoctor(): DoctorEnvelope {
const result = spawnSync(
process.execPath, // bun
['src/cli.ts', 'doctor', '--json', '--fast'],
{
cwd: process.cwd(),
encoding: 'utf8',
timeout: 60000,
},
);
if (result.error) throw result.error;
// Doctor's JSON envelope is the LAST line in stdout (CLI may print
// banners on stderr; --json sends the envelope to stdout).
const stdout = result.stdout ?? '';
const lines = stdout.split('\n').filter((l) => l.trim().length > 0);
const jsonLine = lines.reverse().find((l) => l.trim().startsWith('{'));
if (!jsonLine) {
throw new Error(
`No JSON envelope found in doctor output. stdout=${stdout.slice(0, 500)} stderr=${(result.stderr ?? '').slice(0, 500)}`,
);
}
return JSON.parse(jsonLine) as DoctorEnvelope;
}
describe('doctor — v0.41.16.0 new checks emit', () => {
test('all 3 new checks present in JSON envelope', () => {
const env = runDoctor();
const checkNames = env.checks.map((c) => c.name);
// conversation_format_coverage may not appear in --fast mode (it
// requires DB access); progressive_batch_audit_health and
// conversation_parser_probe_health do not need DB.
expect(checkNames).toContain('progressive_batch_audit_health');
expect(checkNames).toContain('conversation_parser_probe_health');
});
test('progressive_batch_audit_health shape', () => {
const env = runDoctor();
const check = env.checks.find(
(c) => c.name === 'progressive_batch_audit_health',
);
expect(check).toBeDefined();
expect(['ok', 'warn', 'fail']).toContain(check!.status);
expect(typeof check!.message).toBe('string');
expect(check!.message.length).toBeGreaterThan(0);
});
test('conversation_parser_probe_health shape + opt-in hint', () => {
const env = runDoctor();
const check = env.checks.find(
(c) => c.name === 'conversation_parser_probe_health',
);
expect(check).toBeDefined();
expect(check!.status).toBe('ok');
expect(check!.message).toContain('opt-in');
expect(check!.message).toContain(
'autopilot.conversation_parser_probe.enabled true',
);
});
test('schema_version is stable (2 at v0.41.16.0)', () => {
const env = runDoctor();
expect(env.schema_version).toBe(2);
});
});
+163
View File
@@ -0,0 +1,163 @@
/**
* v0.41.16.0 — E2E test for the conversation parser cathedral against
* a real PGLite brain.
*
* For each of the 12 built-in formats: seed a page through
* `importFromContent`, run `parseConversation` against the body, assert
* the parser identifies the correct pattern AND produces at least one
* message AND the message timestamp lands in the expected date range.
*
* Per CLAUDE.md test-isolation R3+R4: uses the canonical PGLite block.
* Hermetic (no DATABASE_URL needed); the in-memory PGLite is created
* once per file and reset between tests.
*
* This is the integration test that proves the parser ↔ engine
* interaction is correct for the dream cycle's
* `conversation_facts_backfill` phase.
*/
import { describe, expect, test, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
import { resetPgliteState } from '../helpers/reset-pglite.ts';
import {
parseConversation,
} from '../../src/core/conversation-parser/parse.ts';
import { BUILTIN_PATTERNS } from '../../src/core/conversation-parser/builtins.ts';
import { importFromContent } from '../../src/core/import-file.ts';
import type { Page } from '../../src/core/types.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
});
/**
* Build a conversation page body using the first test_positive sample
* from the pattern's registry entry. Multi-line patterns get a body
* line appended.
*/
function buildSampleBody(patternId: string): string {
const pattern = BUILTIN_PATTERNS.find((p) => p.id === patternId);
if (!pattern) throw new Error(`pattern ${patternId} not found`);
// Repeat the positive sample 3 times with slight variations to
// simulate a multi-message conversation.
const samples = pattern.test_positive.slice(0, 2);
if (pattern.multi_line && pattern.captures.text_group === 0) {
// For patterns where text comes from the next line, append a body
// line after each header.
return samples.flatMap((s, i) => [s, `body line ${i + 1}`]).join('\n');
}
return samples.join('\n');
}
describe('E2E: parser ↔ engine integration for every built-in', () => {
for (const pattern of BUILTIN_PATTERNS) {
test(`pattern=${pattern.id}: page imports + parser identifies pattern`, async () => {
const slug = `conversations/test/${pattern.id}-sample`;
const body = buildSampleBody(pattern.id);
const frontmatter: Record<string, unknown> = {
type: 'conversation',
date: '2024-03-15',
};
if (pattern.timezone_policy === 'utc_assumed_with_warn') {
// Some patterns warn without a timezone; provide one to avoid
// stderr noise in the test output.
frontmatter.timezone = 'America/Los_Angeles';
}
// Seed the page through the canonical import path.
const fullBody = `---\n${Object.entries(frontmatter)
.map(([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`)
.join('\n')}\n---\n\n${body}`;
await importFromContent(engine, slug, fullBody, {
sourceId: 'default',
noEmbed: true,
});
// Read the page back and parse it through the orchestrator
// (mirrors what extract-conversation-facts.ts does in production).
const page = (await engine.getPage(slug)) as Page;
expect(page).not.toBeNull();
const bodyToParse = `${page.compiled_truth ?? ''}\n${page.timeline ?? ''}`.trim();
const result = parseConversation(bodyToParse, { page });
expect(result.phase).toBe('regex_match');
expect(result.matched_pattern_id).toBe(pattern.id);
expect(result.messages.length).toBeGreaterThanOrEqual(1);
// Every parsed message has a valid ISO timestamp.
for (const msg of result.messages) {
const ts = Date.parse(msg.timestamp);
expect(Number.isFinite(ts)).toBe(true);
// Pattern's timezone policy determines date range constraints.
// For inline-date patterns the date is in the line; for
// time-only patterns it's from frontmatter ('2024-03-15').
if (pattern.date_source === 'frontmatter') {
expect(msg.timestamp.slice(0, 10)).toBe('2024-03-15');
}
expect(msg.speaker.length).toBeGreaterThan(0);
}
});
}
});
describe('E2E: parser handles unparseable bodies cleanly', () => {
test('non-chat content returns no_match without crashing', async () => {
const slug = 'conversations/test/not-a-chat';
const fullBody = `---\ntype: conversation\ndate: 2024-03-15\n---\n\nThis is just prose. It has no chat structure at all. Just words that flow.`;
await importFromContent(engine, slug, fullBody, {
sourceId: 'default',
noEmbed: true,
});
const page = (await engine.getPage(slug)) as Page;
const bodyToParse = `${page.compiled_truth ?? ''}\n${page.timeline ?? ''}`.trim();
const result = parseConversation(bodyToParse, { page });
expect(result.phase).toBe('no_match');
expect(result.messages).toEqual([]);
});
test('empty body returns no_match', async () => {
const slug = 'conversations/test/empty';
const fullBody = `---\ntype: conversation\ndate: 2024-03-15\n---\n\n`;
await importFromContent(engine, slug, fullBody, {
sourceId: 'default',
noEmbed: true,
});
const page = (await engine.getPage(slug)) as Page;
const result = parseConversation(`${page.compiled_truth ?? ''}\n${page.timeline ?? ''}`.trim(), { page });
expect(result.phase).toBe('no_match');
expect(result.messages).toEqual([]);
});
});
describe('E2E: D8 date derivation chain through Page', () => {
test('frontmatter.date wins over epoch default', async () => {
const slug = 'conversations/test/date-derive';
const body = '**[18:37] 👤 Alice:** hi';
const fullBody = `---\ntype: conversation\ndate: 2026-05-24\ntimezone: America/Los_Angeles\n---\n\n${body}`;
await importFromContent(engine, slug, fullBody, {
sourceId: 'default',
noEmbed: true,
});
const page = (await engine.getPage(slug)) as Page;
const result = parseConversation(
`${page.compiled_truth ?? ''}\n${page.timeline ?? ''}`.trim(),
{ page },
);
expect(result.matched_pattern_id).toBe('telegram-bracket');
expect(result.messages).toHaveLength(1);
expect(result.messages[0].timestamp).toBe('2026-05-24T18:37:00Z');
});
});
+181
View File
@@ -0,0 +1,181 @@
/**
* v0.41.16.0 — `gbrain eval conversation-parser` CLI behavior tests.
*
* Covers argv parsing, exit codes (0/1/2), --json envelope shape,
* --no-llm flag, --min-recall override, USAGE errors. Pure file I/O,
* no DB, no API keys.
*
* Critical because `bun run check:conversation-parser` is wired into
* `bun run verify` — a silent regression in exit-code handling would
* make every PR's CI green even when the parser broke.
*/
import { describe, expect, test } from 'bun:test';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { runEvalConversationParser } from '../src/commands/eval-conversation-parser.ts';
function tmpFixture(content: string): string {
const dir = mkdtempSync(join(tmpdir(), 'eval-cli-'));
const path = join(dir, 'fix.jsonl');
writeFileSync(path, content);
return path;
}
const PASS = `{"fixture_id":"p1","pattern":"imessage-slack","frontmatter":{"date":"2024-03-15"},"body":"**Alice** (2024-03-15 9:00 AM): hi","expected_messages":1,"expected_participants":["Alice"]}`;
const FAIL_PATTERN_MISMATCH = `{"fixture_id":"f1","pattern":"telegram-bracket","frontmatter":{"date":"2024-03-15"},"body":"**Alice** (2024-03-15 9:00 AM): hi","expected_messages":1,"expected_participants":["Alice"]}`;
describe('runEvalConversationParser — exit codes', () => {
test('exit 0 on all-pass fixture', async () => {
const path = tmpFixture(PASS);
const code = await runEvalConversationParser([path, '--no-llm']);
expect(code).toBe(0);
});
test('exit 1 on any failure', async () => {
const path = tmpFixture(FAIL_PATTERN_MISMATCH);
const code = await runEvalConversationParser([path, '--no-llm']);
expect(code).toBe(1);
});
test('exit 2 on missing fixture argument', async () => {
const code = await runEvalConversationParser(['--no-llm']);
expect(code).toBe(2);
});
test('exit 2 on nonexistent fixture path', async () => {
const code = await runEvalConversationParser([
'/nonexistent/path.jsonl',
'--no-llm',
]);
expect(code).toBe(2);
});
test('exit 2 on malformed JSONL', async () => {
const path = tmpFixture('NOT-JSON\n');
const code = await runEvalConversationParser([path]);
expect(code).toBe(2);
});
test('exit 2 on empty fixture file', async () => {
const path = tmpFixture('');
const code = await runEvalConversationParser([path]);
expect(code).toBe(2);
});
test('exit 0 on help', async () => {
const code = await runEvalConversationParser(['--help']);
expect(code).toBe(0);
});
});
describe('runEvalConversationParser — flag parsing', () => {
test('positional fixture path works without --fixtures', async () => {
const path = tmpFixture(PASS);
const code = await runEvalConversationParser([path, '--no-llm']);
expect(code).toBe(0);
});
test('--fixtures <path> form works', async () => {
const path = tmpFixture(PASS);
const code = await runEvalConversationParser([
'--fixtures',
path,
'--no-llm',
]);
expect(code).toBe(0);
});
test('--fixtures=<path> form works', async () => {
const path = tmpFixture(PASS);
const code = await runEvalConversationParser([
`--fixtures=${path}`,
'--no-llm',
]);
expect(code).toBe(0);
});
test('--min-recall override (lower floor lets near-misses pass)', async () => {
// A fixture where the parser only catches 0 of 1 expected messages.
// Default --min-recall 0.9 would fail this; --min-recall 0.0 passes.
const partial = `{"fixture_id":"pp","pattern":"telegram-bracket","frontmatter":{"date":"2024-03-15"},"body":"this is not a telegram line","expected_messages":1,"expected_participants":["Alice"]}`;
const path = tmpFixture(partial);
const defaultCode = await runEvalConversationParser([path, '--no-llm']);
expect(defaultCode).toBe(1); // expected pattern not matched → fail
// Lowering min-recall doesn't rescue a pattern mismatch — pattern
// identity is checked separately. This pins that contract.
const lowCode = await runEvalConversationParser([
path,
'--no-llm',
'--min-recall',
'0.0',
]);
expect(lowCode).toBe(1);
});
});
describe('runEvalConversationParser — --json envelope', () => {
test('emits stable schema_version=1 + recall + pattern_coverage', async () => {
const path = tmpFixture(PASS);
const origWrite = process.stdout.write.bind(process.stdout);
const captured: string[] = [];
process.stdout.write = ((chunk: string | Uint8Array) => {
captured.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString());
return true;
}) as typeof process.stdout.write;
try {
const code = await runEvalConversationParser([path, '--no-llm', '--json']);
expect(code).toBe(0);
} finally {
process.stdout.write = origWrite;
}
const stdout = captured.join('');
const json = JSON.parse(stdout.trim());
expect(json.schema_version).toBe(1);
expect(json.total_fixtures).toBe(1);
expect(json.passed).toBe(1);
expect(json.failed).toBe(0);
expect(json.recall_mean).toBe(1);
expect(json.participants_recall_mean).toBe(1);
expect(json.pattern_coverage).toEqual({ 'imessage-slack': 1 });
expect(json.fixtures).toHaveLength(1);
expect(json.failed_fixture_ids).toEqual([]);
});
test('--json on failure includes failed_fixture_ids', async () => {
const path = tmpFixture(FAIL_PATTERN_MISMATCH);
const origWrite = process.stdout.write.bind(process.stdout);
const captured: string[] = [];
process.stdout.write = ((chunk: string | Uint8Array) => {
captured.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString());
return true;
}) as typeof process.stdout.write;
try {
const code = await runEvalConversationParser([path, '--no-llm', '--json']);
expect(code).toBe(1);
} finally {
process.stdout.write = origWrite;
}
const json = JSON.parse(captured.join('').trim());
expect(json.failed_fixture_ids).toContain('f1');
});
});
describe('runEvalConversationParser — adversarial fixture', () => {
test('adversarial (pattern=null) with non-empty parse → fail', async () => {
// Adversarial fixture whose body LOOKS like iMessage; parser will
// match it; eval flags as adversarial false-positive.
const advFp = `{"fixture_id":"adv-fp","pattern":null,"frontmatter":{"date":"2024-03-15"},"body":"**Alice** (2024-03-15 9:00 AM): I should not parse","expected_messages":0,"expected_participants":[]}`;
const path = tmpFixture(advFp);
const code = await runEvalConversationParser([path, '--no-llm']);
expect(code).toBe(1);
});
test('adversarial with truly unparseable body → pass', async () => {
const advClean = `{"fixture_id":"adv-clean","pattern":null,"frontmatter":{"date":"2024-03-15"},"body":"This is just prose with no chat shape.","expected_messages":0,"expected_participants":[]}`;
const path = tmpFixture(advClean);
const code = await runEvalConversationParser([path, '--no-llm']);
expect(code).toBe(0);
});
});
+5
View File
@@ -0,0 +1,5 @@
{"fixture_id":"adversarial-recipe","pattern":null,"frontmatter":{"date":"2024-03-15"},"body":"Chocolate Chip Cookies\n\nIngredients:\n- 2 cups flour\n- 1 cup sugar\n- 1 cup chocolate chips\n\nInstructions:\nMix dry ingredients. Add wet. Bake at 375 for 12 minutes.","expected_messages":0,"expected_participants":[]}
{"fixture_id":"adversarial-readme","pattern":null,"frontmatter":{"date":"2024-03-15"},"body":"# My Project\n\nA short description.\n\n## Install\n\n```\nnpm install my-project\n```\n\n## Usage\n\nImport the package and call the function.","expected_messages":0,"expected_participants":[]}
{"fixture_id":"adversarial-code","pattern":null,"frontmatter":{"date":"2024-03-15"},"body":"function add(a, b) {\n return a + b;\n}\n\nconst result = add(1, 2);\nconsole.log(result);","expected_messages":0,"expected_participants":[]}
{"fixture_id":"adversarial-lyrics","pattern":null,"frontmatter":{"date":"2024-03-15"},"body":"Twinkle twinkle little star\nHow I wonder what you are\nUp above the world so high\nLike a diamond in the sky","expected_messages":0,"expected_participants":[]}
{"fixture_id":"adversarial-json","pattern":null,"frontmatter":{"date":"2024-03-15"},"body":"{\n \"key\": \"value\",\n \"num\": 42,\n \"list\": [1, 2, 3]\n}","expected_messages":0,"expected_participants":[]}
+13
View File
@@ -0,0 +1,13 @@
{"fixture_id":"imessage-001","pattern":"imessage-slack","frontmatter":{"date":"2024-03-15"},"body":"**Alice Example** (2024-03-15 9:00 AM): morning\n**Bob Example** (2024-03-15 9:01 AM): hey there\n**Alice Example** (2024-03-15 9:02 AM): how are you\n**Bob Example** (2024-03-15 9:03 AM): good thanks\n**Alice Example** (2024-03-15 9:04 AM): you?","expected_messages":5,"expected_participants":["Alice Example","Bob Example"]}
{"fixture_id":"imessage-002","pattern":"imessage-slack","frontmatter":{"date":"2024-03-15"},"body":"**Charlie Example** (2024-03-15 2:00 PM): afternoon\n**Charlie Example** (2024-03-15 2:01 PM): are you there?\n**Diana Example** (2024-03-15 2:05 PM): yes\n**Charlie Example** (2024-03-15 2:06 PM): great","expected_messages":4,"expected_participants":["Charlie Example","Diana Example"]}
{"fixture_id":"telegram-bracket-001","pattern":"telegram-bracket","frontmatter":{"date":"2026-05-24","timezone":"America/Los_Angeles"},"body":"**[18:37] 👤 Alice Example:** hello world\n**[18:38] 👤 Bob Example:** hey\n**[18:39] 👤 Alice Example:** how are you\n**[18:40] 👤 Bob Example:** good","expected_messages":4,"expected_participants":["Alice Example","Bob Example"]}
{"fixture_id":"telegram-bracket-002","pattern":"telegram-bracket","frontmatter":{"date":"2026-05-25","timezone":"America/Los_Angeles"},"body":"**[06:00] 🤖 Zion Bot:** On it.\n**[06:01] 👤 Charlie Example:** thanks\n**[06:02] 🤖 Zion Bot:** anything else?\n**[06:03] 👤 Charlie Example:** no good","expected_messages":4,"expected_participants":["Zion Bot","Charlie Example"]}
{"fixture_id":"whatsapp-iso-001","pattern":"whatsapp-iso","frontmatter":{"date":"2024-03-15"},"body":"[15/03/24, 18:37:00] Alice Example: hello\n[15/03/24, 18:37:30] Bob Example: hey\n[15/03/24, 18:38:00] Alice Example: how are you\n[15/03/24, 18:39:00] Bob Example: good","expected_messages":4,"expected_participants":["Alice Example","Bob Example"]}
{"fixture_id":"whatsapp-iso-002","pattern":"whatsapp-iso","frontmatter":{"date":"2024-01-01"},"body":"[01/01/24, 00:00:00] Charlie Example: happy new year\n[01/01/24, 00:00:30] Diana Example: same to you\n[01/01/24, 00:01:00] Charlie Example: any plans?\n[01/01/24, 00:02:00] Diana Example: sleep","expected_messages":4,"expected_participants":["Charlie Example","Diana Example"]}
{"fixture_id":"whatsapp-us-001","pattern":"whatsapp-us","frontmatter":{"date":"2024-03-15"},"body":"3/15/24, 6:37 PM - Alice Example: hello\n3/15/24, 6:38 PM - Bob Example: hey\n3/15/24, 6:39 PM - Alice Example: how are you\n3/15/24, 6:40 PM - Bob Example: good","expected_messages":4,"expected_participants":["Alice Example","Bob Example"]}
{"fixture_id":"whatsapp-us-002","pattern":"whatsapp-us","frontmatter":{"date":"2023-12-31"},"body":"12/31/23, 11:59 PM - Charlie Example: nye\n12/31/23, 11:59 PM - Diana Example: cheers","expected_messages":2,"expected_participants":["Charlie Example","Diana Example"]}
{"fixture_id":"signal-export-001","pattern":"signal-export","frontmatter":{"date":"2024-03-15"},"body":"Alice Example (2024-03-15 18:37:00 UTC): hello\nBob Example (2024-03-15 18:37:30 UTC): hey there\nAlice Example (2024-03-15 18:38:00 UTC): how are you\nBob Example (2024-03-15 18:39:00 UTC): good","expected_messages":4,"expected_participants":["Alice Example","Bob Example"]}
{"fixture_id":"irc-classic-001","pattern":"irc-classic","frontmatter":{"date":"2024-03-15"},"body":"<alice> hello world\n<bob> hey there\n<alice> how are you\n<bob> good thanks","expected_messages":4,"expected_participants":["alice","bob"]}
{"fixture_id":"irc-weechat-001","pattern":"irc-weechat","frontmatter":{"date":"2024-03-15"},"body":"18:37 <alice> hello\n18:38 <bob> hey\n18:39 <alice> how are you\n18:40 <bob> good","expected_messages":4,"expected_participants":["alice","bob"]}
{"fixture_id":"matrix-element-001","pattern":"matrix-element","frontmatter":{"date":"2024-03-15"},"body":"[18:37] @alice:matrix.org: hello world\n[18:38] @bob:example.org: hey\n[18:39] @alice:matrix.org: how are you\n[18:40] @bob:example.org: good","expected_messages":4,"expected_participants":["alice","bob"]}
{"fixture_id":"teams-export-001","pattern":"teams-export","frontmatter":{"date":"2024-03-15"},"body":"Alice Example, 3/15/2024 6:37 PM: hello team\nBob Example, 3/15/2024 6:38 PM: hey\nAlice Example, 3/15/2024 6:39 PM: meeting at 4?\nBob Example, 3/15/2024 6:40 PM: works for me","expected_messages":4,"expected_participants":["Alice Example","Bob Example"]}
+2
View File
@@ -0,0 +1,2 @@
{"fixture_id":"imessage-001","pattern":"imessage-slack","frontmatter":{"date":"2024-03-15"},"body":"**Alice Example** (2024-03-15 9:00 AM): morning\n**Bob Example** (2024-03-15 9:01 AM): hey there\n**Alice Example** (2024-03-15 9:02 AM): how are you\n**Bob Example** (2024-03-15 9:03 AM): good thanks\n**Alice Example** (2024-03-15 9:04 AM): you?","expected_messages":5,"expected_participants":["Alice Example","Bob Example"]}
{"fixture_id":"imessage-002","pattern":"imessage-slack","frontmatter":{"date":"2024-03-15"},"body":"**Charlie Example** (2024-03-15 2:00 PM): afternoon\n**Charlie Example** (2024-03-15 2:01 PM): are you there?\n**Diana Example** (2024-03-15 2:05 PM): yes\n**Charlie Example** (2024-03-15 2:06 PM): great","expected_messages":4,"expected_participants":["Charlie Example","Diana Example"]}
+1
View File
@@ -0,0 +1 @@
{"fixture_id":"irc-classic-001","pattern":"irc-classic","frontmatter":{"date":"2024-03-15"},"body":"<alice> hello world\n<bob> hey there\n<alice> how are you\n<bob> good thanks","expected_messages":4,"expected_participants":["alice","bob"]}
+1
View File
@@ -0,0 +1 @@
{"fixture_id":"irc-weechat-001","pattern":"irc-weechat","frontmatter":{"date":"2024-03-15"},"body":"18:37 <alice> hello\n18:38 <bob> hey\n18:39 <alice> how are you\n18:40 <bob> good","expected_messages":4,"expected_participants":["alice","bob"]}
@@ -0,0 +1 @@
{"fixture_id":"matrix-element-001","pattern":"matrix-element","frontmatter":{"date":"2024-03-15"},"body":"[18:37] @alice:matrix.org: hello world\n[18:38] @bob:example.org: hey\n[18:39] @alice:matrix.org: how are you\n[18:40] @bob:example.org: good","expected_messages":4,"expected_participants":["alice","bob"]}
@@ -0,0 +1 @@
{"fixture_id":"signal-export-001","pattern":"signal-export","frontmatter":{"date":"2024-03-15"},"body":"Alice Example (2024-03-15 18:37:00 UTC): hello\nBob Example (2024-03-15 18:37:30 UTC): hey there\nAlice Example (2024-03-15 18:38:00 UTC): how are you\nBob Example (2024-03-15 18:39:00 UTC): good","expected_messages":4,"expected_participants":["Alice Example","Bob Example"]}
+1
View File
@@ -0,0 +1 @@
{"fixture_id":"teams-export-001","pattern":"teams-export","frontmatter":{"date":"2024-03-15"},"body":"Alice Example, 3/15/2024 6:37 PM: hello team\nBob Example, 3/15/2024 6:38 PM: hey\nAlice Example, 3/15/2024 6:39 PM: meeting at 4?\nBob Example, 3/15/2024 6:40 PM: works for me","expected_messages":4,"expected_participants":["Alice Example","Bob Example"]}
@@ -0,0 +1,2 @@
{"fixture_id":"telegram-bracket-001","pattern":"telegram-bracket","frontmatter":{"date":"2026-05-24","timezone":"America/Los_Angeles"},"body":"**[18:37] 👤 Alice Example:** hello world\n**[18:38] 👤 Bob Example:** hey\n**[18:39] 👤 Alice Example:** how are you\n**[18:40] 👤 Bob Example:** good","expected_messages":4,"expected_participants":["Alice Example","Bob Example"]}
{"fixture_id":"telegram-bracket-002","pattern":"telegram-bracket","frontmatter":{"date":"2026-05-25","timezone":"America/Los_Angeles"},"body":"**[06:00] 🤖 Zion Bot:** On it.\n**[06:01] 👤 Charlie Example:** thanks\n**[06:02] 🤖 Zion Bot:** anything else?\n**[06:03] 👤 Charlie Example:** no good","expected_messages":4,"expected_participants":["Zion Bot","Charlie Example"]}
+2
View File
@@ -0,0 +1,2 @@
{"fixture_id":"whatsapp-iso-001","pattern":"whatsapp-iso","frontmatter":{"date":"2024-03-15"},"body":"[15/03/24, 18:37:00] Alice Example: hello\n[15/03/24, 18:37:30] Bob Example: hey\n[15/03/24, 18:38:00] Alice Example: how are you\n[15/03/24, 18:39:00] Bob Example: good","expected_messages":4,"expected_participants":["Alice Example","Bob Example"]}
{"fixture_id":"whatsapp-iso-002","pattern":"whatsapp-iso","frontmatter":{"date":"2024-01-01"},"body":"[01/01/24, 00:00:00] Charlie Example: happy new year\n[01/01/24, 00:00:30] Diana Example: same to you\n[01/01/24, 00:01:00] Charlie Example: any plans?\n[01/01/24, 00:02:00] Diana Example: sleep","expected_messages":4,"expected_participants":["Charlie Example","Diana Example"]}
+2
View File
@@ -0,0 +1,2 @@
{"fixture_id":"whatsapp-us-001","pattern":"whatsapp-us","frontmatter":{"date":"2024-03-15"},"body":"3/15/24, 6:37 PM - Alice Example: hello\n3/15/24, 6:38 PM - Bob Example: hey\n3/15/24, 6:39 PM - Alice Example: how are you\n3/15/24, 6:40 PM - Bob Example: good","expected_messages":4,"expected_participants":["Alice Example","Bob Example"]}
{"fixture_id":"whatsapp-us-002","pattern":"whatsapp-us","frontmatter":{"date":"2023-12-31"},"body":"12/31/23, 11:59 PM - Charlie Example: nye\n12/31/23, 11:59 PM - Diana Example: cheers","expected_messages":2,"expected_participants":["Charlie Example","Diana Example"]}
+159
View File
@@ -0,0 +1,159 @@
/**
* v0.41.16.0 — Migration v99 round-trip test.
*
* Verifies the `conversation_parser_llm_cache` table:
* - is created on schema init
* - accepts inserts on (content_sha256, model_id, call_shape, value_json)
* - rejects invalid call_shape via CHECK constraint
* - ON CONFLICT DO NOTHING semantics (the llm-base.ts caller's contract)
* - JSONB column round-trips a real object (no double-encode regression)
* - composite primary key prevents duplicate (sha, model, shape)
*
* Hermetic via the canonical PGLite block from CLAUDE.md test-isolation
* rules.
*/
import { describe, expect, test, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
});
describe('migration v99 — conversation_parser_llm_cache', () => {
test('table exists after schema init', async () => {
const rows = await engine.executeRaw<{ table_name: string }>(
`SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'conversation_parser_llm_cache'`,
);
expect(rows.length).toBe(1);
});
test('insert + select round-trip with polish call_shape', async () => {
await engine.executeRaw(
`INSERT INTO conversation_parser_llm_cache
(content_sha256, model_id, call_shape, value_json)
VALUES ($1, $2, $3, $4::jsonb)`,
[
'abc123',
'anthropic:claude-haiku-4-5',
'polish',
JSON.stringify({ merge_indices: [], drop_indices: [], edits: [] }),
],
);
const rows = await engine.executeRaw<{ value_json: unknown }>(
`SELECT value_json FROM conversation_parser_llm_cache
WHERE content_sha256 = $1 AND model_id = $2 AND call_shape = $3`,
['abc123', 'anthropic:claude-haiku-4-5', 'polish'],
);
expect(rows).toHaveLength(1);
// value_json should round-trip as a parsed object (not a JSON string).
const val =
typeof rows[0].value_json === 'string'
? JSON.parse(rows[0].value_json)
: rows[0].value_json;
expect(val).toEqual({ merge_indices: [], drop_indices: [], edits: [] });
});
test('insert + select round-trip with fallback call_shape', async () => {
await engine.executeRaw(
`INSERT INTO conversation_parser_llm_cache
(content_sha256, model_id, call_shape, value_json)
VALUES ($1, $2, $3, $4::jsonb)`,
[
'def456',
'anthropic:claude-haiku-4-5',
'fallback',
JSON.stringify([
{ speaker: 'Alice', timestamp: '2024-03-15T18:37:00Z', text: 'hi' },
]),
],
);
const rows = await engine.executeRaw<{ value_json: unknown }>(
`SELECT value_json FROM conversation_parser_llm_cache
WHERE content_sha256 = $1 AND call_shape = $2`,
['def456', 'fallback'],
);
expect(rows).toHaveLength(1);
const val =
typeof rows[0].value_json === 'string'
? JSON.parse(rows[0].value_json)
: rows[0].value_json;
expect(Array.isArray(val)).toBe(true);
expect(val).toHaveLength(1);
});
test('CHECK constraint rejects invalid call_shape', async () => {
let threw = false;
try {
await engine.executeRaw(
`INSERT INTO conversation_parser_llm_cache
(content_sha256, model_id, call_shape, value_json)
VALUES ($1, $2, $3, $4::jsonb)`,
['ghi789', 'anthropic:claude-haiku-4-5', 'INVALID_SHAPE', '{}'],
);
} catch {
threw = true;
}
expect(threw).toBe(true);
});
test('composite primary key prevents duplicate (sha, model, shape)', async () => {
await engine.executeRaw(
`INSERT INTO conversation_parser_llm_cache
(content_sha256, model_id, call_shape, value_json)
VALUES ($1, $2, $3, $4::jsonb)`,
['dup1', 'anthropic:claude-haiku-4-5', 'polish', '{}'],
);
// ON CONFLICT DO NOTHING from llm-base.ts writeDbCache — should not throw.
await engine.executeRaw(
`INSERT INTO conversation_parser_llm_cache
(content_sha256, model_id, call_shape, value_json)
VALUES ($1, $2, $3, $4::jsonb)
ON CONFLICT (content_sha256, model_id, call_shape) DO NOTHING`,
['dup1', 'anthropic:claude-haiku-4-5', 'polish', '{"different":true}'],
);
// First write wins on conflict.
const rows = await engine.executeRaw<{ value_json: unknown }>(
`SELECT value_json FROM conversation_parser_llm_cache WHERE content_sha256 = 'dup1'`,
);
expect(rows).toHaveLength(1);
});
test('different call_shape on same (sha, model) coexists', async () => {
await engine.executeRaw(
`INSERT INTO conversation_parser_llm_cache
(content_sha256, model_id, call_shape, value_json)
VALUES ($1, $2, 'polish', $3::jsonb), ($1, $2, 'fallback', $3::jsonb)`,
['co1', 'anthropic:claude-haiku-4-5', '{}'],
);
const rows = await engine.executeRaw<{ call_shape: string }>(
`SELECT call_shape FROM conversation_parser_llm_cache WHERE content_sha256 = 'co1'`,
);
expect(rows).toHaveLength(2);
const shapes = rows.map((r) => r.call_shape).sort();
expect(shapes).toEqual(['fallback', 'polish']);
});
test('created_at index supports time-based pruning queries', async () => {
const rows = await engine.executeRaw<{ indexname: string }>(
`SELECT indexname FROM pg_indexes
WHERE tablename = 'conversation_parser_llm_cache'
AND indexname = 'idx_conversation_parser_llm_cache_created'`,
);
expect(rows.length).toBe(1);
});
});
+625
View File
@@ -0,0 +1,625 @@
/**
* v0.41.16.0 — Progressive-batch primitive unit tests.
*
* Pins every verdict branch of runProgressiveBatch (D3 fail-closed
* budget, D20 discriminated verifier shapes, D21 honest jump-to-full,
* stage-slicing, ramp-stage interactive abort, cost projection).
*
* Hermetic: no PGLite, no Postgres, no real LLM. Every BudgetTracker
* is constructed inline; the AsyncLocalStorage scope is set via
* `withBudgetTracker` from gateway.ts. Audit JSONL writes go to
* `GBRAIN_AUDIT_DIR=<tempdir>` to isolate from the user's real audit
* dir. We use `withEnv` from test/helpers/with-env.ts per CLAUDE.md
* R1 (test-isolation lint).
*/
import { describe, expect, test } from 'bun:test';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { withEnv } from '../helpers/with-env.ts';
import {
parseEnvStages,
resolveStages,
resolveCostCap,
sliceIntoStages,
awaitInteractiveAbort,
runProgressiveBatch,
} from '../../src/core/progressive-batch/orchestrator.ts';
import { BudgetTracker } from '../../src/core/budget/budget-tracker.ts';
import { withBudgetTracker } from '../../src/core/ai/gateway.ts';
import type {
Policy,
StageReport,
Verifier,
} from '../../src/core/progressive-batch/types.ts';
// Helper: build a NoopVerifier with optional sampleQuality.
function makeNoopVerifier(
costPerItem = 0.001,
sampleQuality?: () => Promise<{ ok: boolean; reasons?: string[] }>,
): Verifier {
return {
kind: 'noop',
costPerItem: () => costPerItem,
sampleQuality,
};
}
// Helper: build an OutputCountVerifier backed by an in-memory counter.
function makeOutputCountVerifier(opts: {
initial?: number;
perItemRows?: number;
qualityOk?: boolean;
qualityReasons?: string[];
costPerItem?: number;
}): { verifier: Verifier; bump: (n: number) => void } {
let count = opts.initial ?? 0;
const perItem = opts.perItemRows ?? 1;
return {
verifier: {
kind: 'output_count',
countBefore: async () => count,
countAfter: async () => count,
expectedDelta: (processed: number) => processed * perItem,
sampleQuality: async () => ({
ok: opts.qualityOk ?? true,
reasons: opts.qualityReasons,
}),
costPerItem: () => opts.costPerItem ?? 0.001,
},
bump: (n: number) => {
count += n;
},
};
}
// Helper: build an IdempotentMutationVerifier backed by an in-memory counter.
function makeIdempotentVerifier(opts: {
qualityOk?: boolean;
qualityReasons?: string[];
mutationsPerItem?: number;
costPerItem?: number;
}): { verifier: Verifier; bump: (n: number) => void; resetForStage: () => void } {
let mutations = 0;
const perItem = opts.mutationsPerItem ?? 1;
return {
verifier: {
kind: 'idempotent_mutation',
mutatedCount: async () => mutations,
expectedMutations: (processed: number) => processed * perItem,
sampleQuality: async () => ({
ok: opts.qualityOk ?? true,
reasons: opts.qualityReasons,
}),
costPerItem: () => opts.costPerItem ?? 0.001,
},
bump: (n: number) => {
mutations += n;
},
resetForStage: () => {
mutations = 0;
},
};
}
// Helper: collect stage reports the orchestrator emits.
function collectReports(): {
onStageReport: (r: StageReport) => void;
reports: StageReport[];
} {
const reports: StageReport[] = [];
return {
onStageReport: (r) => {
reports.push(r);
},
reports,
};
}
// Use a per-test tempdir for audit writes via GBRAIN_AUDIT_DIR.
function makeAuditEnv(): Record<string, string> {
return {
GBRAIN_AUDIT_DIR: mkdtempSync(join(tmpdir(), 'pb-audit-')),
GBRAIN_PROGRESSIVE_BATCH_AUTO: '1',
GBRAIN_PROGRESSIVE_BATCH_DISABLED: undefined as unknown as string,
GBRAIN_PROGRESSIVE_BATCH_STAGES: undefined as unknown as string,
};
}
describe('parseEnvStages', () => {
test('null on unset', () => {
expect(parseEnvStages(undefined)).toBeNull();
});
test('null on empty/whitespace', () => {
expect(parseEnvStages('')).toBeNull();
expect(parseEnvStages(' ')).toBeNull();
});
test('parses canonical', () => {
expect(parseEnvStages('10,100,500')).toEqual([10, 100, 500]);
});
test('rejects non-int', () => {
expect(parseEnvStages('10,foo,500')).toBeNull();
});
test('rejects zero/negative', () => {
expect(parseEnvStages('0,100,500')).toBeNull();
expect(parseEnvStages('-1,100,500')).toBeNull();
});
test('trims whitespace per entry', () => {
expect(parseEnvStages(' 10 , 100 , 500 ')).toEqual([10, 100, 500]);
});
});
describe('resolveStages', () => {
test('env override wins', async () => {
await withEnv({ GBRAIN_PROGRESSIVE_BATCH_STAGES: '5,50,500' }, async () => {
expect(resolveStages({ label: 't', stages: [10, 100, 500] })).toEqual([
5, 50, 500,
]);
});
});
test('policy when env unset', async () => {
await withEnv({ GBRAIN_PROGRESSIVE_BATCH_STAGES: undefined as unknown as string }, async () => {
expect(resolveStages({ label: 't', stages: [7, 70, 700] })).toEqual([
7, 70, 700,
]);
});
});
test('default when both unset', async () => {
await withEnv({ GBRAIN_PROGRESSIVE_BATCH_STAGES: undefined as unknown as string }, async () => {
expect(resolveStages({ label: 't' })).toEqual([10, 100, 500]);
});
});
});
describe('resolveCostCap (D3 fail-closed)', () => {
test('opt-out + maxCostUsd returns policy cap', () => {
expect(
resolveCostCap(
{ label: 't', requireBudgetSafetyNet: false, maxCostUsd: 1 },
null,
),
).toEqual({ capUsd: 1, source: 'policy' });
});
test('opt-out + no cap returns Infinity', () => {
expect(
resolveCostCap({ label: 't', requireBudgetSafetyNet: false }, null),
).toEqual({ capUsd: Infinity, source: 'uncapped' });
});
test('no tracker + no policy cap = NULL (fail-closed)', () => {
expect(resolveCostCap({ label: 't' }, null)).toBeNull();
});
test('policy cap + no tracker returns policy', () => {
expect(resolveCostCap({ label: 't', maxCostUsd: 2 }, null)).toEqual({
capUsd: 2,
source: 'policy',
});
});
test('tracker + no policy cap returns tracker headroom', () => {
const t = new BudgetTracker({ label: 'test', maxCostUsd: 5 });
const r = resolveCostCap({ label: 't' }, t);
expect(r?.capUsd).toBeCloseTo(5);
expect(r?.source).toBe('tracker');
});
test('both set: lower wins', () => {
const t = new BudgetTracker({ label: 'test', maxCostUsd: 10 });
// policy is tighter
const r1 = resolveCostCap({ label: 't', maxCostUsd: 3 }, t);
expect(r1?.capUsd).toBe(3);
expect(r1?.source).toBe('policy');
// tracker is tighter
const r2 = resolveCostCap({ label: 't', maxCostUsd: 100 }, t);
expect(r2?.capUsd).toBeCloseTo(10);
expect(r2?.source).toBe('min');
});
});
describe('sliceIntoStages', () => {
test('1000 items / canonical stages', () => {
const items = Array.from({ length: 1000 }, (_, i) => i);
const s = sliceIntoStages(items, [10, 100, 500]);
expect(s.trial.length).toBe(10);
expect(s.ramp_100.length).toBe(100);
expect(s.ramp_500.length).toBe(500);
expect(s.full.length).toBe(390);
// Disjoint + ordered
expect(s.trial[0]).toBe(0);
expect(s.ramp_100[0]).toBe(10);
expect(s.ramp_500[0]).toBe(110);
expect(s.full[0]).toBe(610);
});
test('50 items: ramp_100 takes remaining; 500 + full empty', () => {
const items = Array.from({ length: 50 }, (_, i) => i);
const s = sliceIntoStages(items, [10, 100, 500]);
expect(s.trial.length).toBe(10);
expect(s.ramp_100.length).toBe(40);
expect(s.ramp_500.length).toBe(0);
expect(s.full.length).toBe(0);
});
test('5 items: only trial', () => {
const items = Array.from({ length: 5 }, (_, i) => i);
const s = sliceIntoStages(items, [10, 100, 500]);
expect(s.trial.length).toBe(5);
expect(s.ramp_100.length).toBe(0);
expect(s.ramp_500.length).toBe(0);
expect(s.full.length).toBe(0);
});
test('disabled (stages=[]) dumps everything to full', () => {
const items = Array.from({ length: 100 }, (_, i) => i);
const s = sliceIntoStages(items, []);
expect(s.trial.length).toBe(0);
expect(s.ramp_100.length).toBe(0);
expect(s.ramp_500.length).toBe(0);
expect(s.full.length).toBe(100);
});
});
describe('awaitInteractiveAbort', () => {
test('ms=0 resolves false immediately', async () => {
expect(await awaitInteractiveAbort(0)).toBe(false);
});
test('positive ms resolves false after timeout', async () => {
const start = Date.now();
const r = await awaitInteractiveAbort(50);
expect(r).toBe(false);
expect(Date.now() - start).toBeGreaterThanOrEqual(40);
});
});
describe('runProgressiveBatch — D3 fail-closed safety net', () => {
test('NO tracker + NO Policy.maxCostUsd → abort_cost_cap reason=no_budget_safety_net', async () => {
await withEnv(makeAuditEnv(), async () => {
const { onStageReport, reports } = collectReports();
const result = await runProgressiveBatch(
[1, 2, 3],
makeNoopVerifier(),
{ label: 'd3-test', onStageReport },
async (slice) => ({ succeeded: slice.length, failed: 0, costUsd: 0 }),
);
expect(result.abortedAt?.verdict).toBe('abort_cost_cap');
expect(result.abortedAt?.reason).toBe('no_budget_safety_net');
expect(result.itemsProcessed).toBe(0);
expect(reports.length).toBe(1);
expect(reports[0].verdict).toBe('abort_cost_cap');
expect(reports[0].abortReason).toBe('no_budget_safety_net');
});
});
test('Policy.requireBudgetSafetyNet=false bypasses the gate (Infinity cap)', async () => {
await withEnv(makeAuditEnv(), async () => {
const { onStageReport, reports } = collectReports();
const result = await runProgressiveBatch(
[1, 2, 3],
makeNoopVerifier(),
{
label: 'optout',
onStageReport,
requireBudgetSafetyNet: false,
},
async (slice) => ({ succeeded: slice.length, failed: 0, costUsd: 0 }),
);
expect(result.abortedAt).toBeUndefined();
expect(result.itemsProcessed).toBe(3);
// 3 items < trial(10) → only trial runs (the rest are empty
// stages with no audit row).
expect(reports.length).toBe(1);
expect(reports[0].verdict).toBe('proceed');
});
});
test('Tracker present + no Policy.maxCostUsd → tracker headroom wins', async () => {
await withEnv(makeAuditEnv(), async () => {
const t = new BudgetTracker({ label: 'unit', maxCostUsd: 10 });
await withBudgetTracker(t, async () => {
const { onStageReport } = collectReports();
const result = await runProgressiveBatch(
[1, 2, 3],
makeNoopVerifier(0.001),
{ label: 'tracker-test', onStageReport },
async (slice) => ({
succeeded: slice.length,
failed: 0,
costUsd: 0.003,
}),
);
expect(result.abortedAt).toBeUndefined();
expect(result.itemsProcessed).toBe(3);
});
});
});
});
describe('runProgressiveBatch — verifier shapes (D20)', () => {
test('OutputCountVerifier: matched delta proceeds', async () => {
await withEnv(makeAuditEnv(), async () => {
const { verifier, bump } = makeOutputCountVerifier({ perItemRows: 1 });
const result = await runProgressiveBatch(
Array.from({ length: 5 }, (_, i) => i),
verifier,
{ label: 'oc', maxCostUsd: 1 },
async (slice) => {
bump(slice.length);
return { succeeded: slice.length, failed: 0, costUsd: 0 };
},
);
expect(result.abortedAt).toBeUndefined();
expect(result.itemsProcessed).toBe(5);
});
});
test('OutputCountVerifier: zero delta → abort_count_mismatch', async () => {
await withEnv(makeAuditEnv(), async () => {
const { verifier } = makeOutputCountVerifier({ perItemRows: 1 });
const result = await runProgressiveBatch(
Array.from({ length: 10 }, (_, i) => i),
verifier,
{ label: 'oc-mismatch', maxCostUsd: 1 },
async (slice) => ({
// Runner reports success but verifier sees no new rows.
succeeded: slice.length,
failed: 0,
costUsd: 0,
}),
);
expect(result.abortedAt?.verdict).toBe('abort_count_mismatch');
expect(result.abortedAt?.reason).toBe('count_delta_outside_band');
});
});
test('IdempotentMutationVerifier: matched mutation count proceeds', async () => {
await withEnv(makeAuditEnv(), async () => {
const { verifier, bump } = makeIdempotentVerifier({});
const result = await runProgressiveBatch(
Array.from({ length: 5 }, (_, i) => i),
verifier,
{ label: 'im', maxCostUsd: 1 },
async (slice) => {
bump(slice.length);
return { succeeded: slice.length, failed: 0, costUsd: 0 };
},
);
expect(result.abortedAt).toBeUndefined();
});
});
test('NoopVerifier: only cost + error rate gating', async () => {
await withEnv(makeAuditEnv(), async () => {
const result = await runProgressiveBatch(
Array.from({ length: 5 }, (_, i) => i),
makeNoopVerifier(),
{ label: 'np', maxCostUsd: 1 },
async (slice) => ({ succeeded: slice.length, failed: 0, costUsd: 0 }),
);
expect(result.abortedAt).toBeUndefined();
expect(result.itemsProcessed).toBe(5);
});
});
test('NoopVerifier with sampleQuality returning not-ok → abort_data_quality', async () => {
await withEnv(makeAuditEnv(), async () => {
const result = await runProgressiveBatch(
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
makeNoopVerifier(0.001, async () => ({
ok: false,
reasons: ['bad row 7'],
})),
{ label: 'np-bad-quality', maxCostUsd: 1 },
async (slice) => ({ succeeded: slice.length, failed: 0, costUsd: 0 }),
);
expect(result.abortedAt?.verdict).toBe('abort_data_quality');
expect(result.abortedAt?.reason).toBe('data_quality_sample_failed');
const trial = result.stageReports[0];
expect(trial.qualityReasons).toEqual(['bad row 7']);
});
});
});
describe('runProgressiveBatch — error rate + cost cap gates', () => {
test('error rate > maxErrorRate → abort_error_rate', async () => {
await withEnv(makeAuditEnv(), async () => {
const result = await runProgressiveBatch(
Array.from({ length: 10 }, (_, i) => i),
makeNoopVerifier(),
{ label: 'er', maxCostUsd: 1, maxErrorRate: 0.1 },
async (slice) => ({
// 50% fail rate, well over the 10% threshold.
succeeded: Math.floor(slice.length / 2),
failed: Math.ceil(slice.length / 2),
costUsd: 0,
}),
);
expect(result.abortedAt?.verdict).toBe('abort_error_rate');
expect(result.abortedAt?.reason).toBe('error_rate_exceeded');
});
});
test('cost projection > cap → abort_cost_cap', async () => {
await withEnv(makeAuditEnv(), async () => {
const result = await runProgressiveBatch(
Array.from({ length: 1000 }, (_, i) => i),
makeNoopVerifier(),
{ label: 'cc', maxCostUsd: 0.5 },
async (slice) => ({
// $0.01/item → 10 items = $0.10 in trial, projected
// 1000-item run = $10.00 ≫ $0.50 cap.
succeeded: slice.length,
failed: 0,
costUsd: slice.length * 0.01,
}),
);
expect(result.abortedAt?.verdict).toBe('abort_cost_cap');
expect(result.abortedAt?.reason).toBe('cost_projected_over_cap');
// Trial stage should have processed 10; cumulative cost should be 0.10.
expect(result.itemsProcessed).toBe(10);
expect(result.totalCostUsd).toBeCloseTo(0.1);
});
});
});
describe('runProgressiveBatch — D21 honest behavior preservation', () => {
test('GBRAIN_PROGRESSIVE_BATCH_DISABLED=1 skips ramp; goes to full', async () => {
await withEnv(
{
...makeAuditEnv(),
GBRAIN_PROGRESSIVE_BATCH_DISABLED: '1',
},
async () => {
const { onStageReport, reports } = collectReports();
const result = await runProgressiveBatch(
Array.from({ length: 100 }, (_, i) => i),
makeNoopVerifier(),
{ label: 'disabled', maxCostUsd: 1, onStageReport },
async (slice) => ({
succeeded: slice.length,
failed: 0,
costUsd: 0,
}),
);
expect(result.abortedAt).toBeUndefined();
expect(result.itemsProcessed).toBe(100);
// Only the 'full' stage report should be emitted.
expect(reports.length).toBe(1);
expect(reports[0].stage).toBe('full');
expect(reports[0].itemsInStage).toBe(100);
},
);
});
test('interactiveAbortMs=0 → no Ctrl-C wait between stages', async () => {
await withEnv(makeAuditEnv(), async () => {
const start = Date.now();
const result = await runProgressiveBatch(
Array.from({ length: 120 }, (_, i) => i),
makeNoopVerifier(),
{ label: 'noramp', maxCostUsd: 1, interactiveAbortMs: 0 },
async (slice) => ({ succeeded: slice.length, failed: 0, costUsd: 0 }),
);
expect(result.abortedAt).toBeUndefined();
// Should run quickly without any 10s waits between stages.
expect(Date.now() - start).toBeLessThan(500);
});
});
});
describe('runProgressiveBatch — onStageReport caller abort', () => {
test('caller returns {abort:true} after trial → abort_explicit', async () => {
await withEnv(makeAuditEnv(), async () => {
let calls = 0;
const result = await runProgressiveBatch(
Array.from({ length: 100 }, (_, i) => i),
makeNoopVerifier(),
{
label: 'explicit-abort',
maxCostUsd: 1,
onStageReport: (r) => {
calls++;
if (r.stage === 'trial') return { abort: true };
return undefined;
},
},
async (slice) => ({ succeeded: slice.length, failed: 0, costUsd: 0 }),
);
expect(result.abortedAt?.verdict).toBe('abort_explicit');
expect(result.abortedAt?.reason).toBe('caller_signaled_abort');
// Trial completed but caller signaled abort.
expect(result.itemsProcessed).toBe(10);
expect(calls).toBe(1);
});
});
});
describe('runProgressiveBatch — multi-stage success path', () => {
test('1000 items: trial(10) → ramp_100 → ramp_500 → full(380); all stages reported', async () => {
await withEnv(makeAuditEnv(), async () => {
const { onStageReport, reports } = collectReports();
const result = await runProgressiveBatch(
Array.from({ length: 1000 }, (_, i) => i),
makeNoopVerifier(),
{ label: 'multi', maxCostUsd: 100, onStageReport },
async (slice) => ({ succeeded: slice.length, failed: 0, costUsd: 0 }),
);
expect(result.abortedAt).toBeUndefined();
expect(result.itemsProcessed).toBe(1000);
expect(result.stagesCompleted).toEqual(['trial', 'ramp_100', 'ramp_500', 'full']);
// Four stage reports (one per stage; the full stage processes 380).
expect(reports).toHaveLength(4);
expect(reports[0].stage).toBe('trial');
expect(reports[0].itemsInStage).toBe(10);
expect(reports[1].stage).toBe('ramp_100');
expect(reports[1].itemsInStage).toBe(100);
expect(reports[2].stage).toBe('ramp_500');
expect(reports[2].itemsInStage).toBe(500);
expect(reports[3].stage).toBe('full');
expect(reports[3].itemsInStage).toBe(390);
// Cumulative processed reaches 1000 by the last stage.
expect(reports[3].itemsProcessedCumulative).toBe(1000);
// All verdicts are proceed.
expect(reports.every((r) => r.verdict === 'proceed')).toBe(true);
});
});
test('IdempotentMutationVerifier: matched mutation count across stages', async () => {
await withEnv(makeAuditEnv(), async () => {
const { verifier, bump, resetForStage } = makeIdempotentVerifier({});
void resetForStage;
const result = await runProgressiveBatch(
Array.from({ length: 25 }, (_, i) => i),
verifier,
{ label: 'multi-im', maxCostUsd: 1 },
async (slice) => {
bump(slice.length);
return { succeeded: slice.length, failed: 0, costUsd: 0 };
},
);
expect(result.abortedAt).toBeUndefined();
expect(result.itemsProcessed).toBe(25);
});
});
test('IdempotentMutationVerifier: mutation count off → abort_mutation_mismatch', async () => {
await withEnv(makeAuditEnv(), async () => {
const { verifier } = makeIdempotentVerifier({});
// Runner claims success but never bumps the mutation counter.
const result = await runProgressiveBatch(
Array.from({ length: 10 }, (_, i) => i),
verifier,
{ label: 'im-mm', maxCostUsd: 1 },
async (slice) => ({ succeeded: slice.length, failed: 0, costUsd: 0 }),
);
expect(result.abortedAt?.verdict).toBe('abort_mutation_mismatch');
expect(result.abortedAt?.reason).toBe('mutation_count_outside_band');
});
});
test('cumulative cost crosses cap mid-multi-stage → abort_cost_cap', async () => {
await withEnv(makeAuditEnv(), async () => {
const result = await runProgressiveBatch(
Array.from({ length: 200 }, (_, i) => i),
makeNoopVerifier(),
{ label: 'cc-mid', maxCostUsd: 0.05 },
async (slice) => ({
// Trial: 10 items × $0.001 = $0.01 cumulative; projected
// 200 × $0.001 = $0.20 ≫ $0.05 cap.
succeeded: slice.length,
failed: 0,
costUsd: slice.length * 0.001,
}),
);
expect(result.abortedAt?.verdict).toBe('abort_cost_cap');
expect(result.abortedAt?.reason).toBe('cost_projected_over_cap');
});
});
});
describe('runProgressiveBatch — degenerate inputs', () => {
test('empty item list runs no stages but reports the run happened', async () => {
await withEnv(makeAuditEnv(), async () => {
const { onStageReport, reports } = collectReports();
const result = await runProgressiveBatch(
[] as number[],
makeNoopVerifier(),
{ label: 'empty', maxCostUsd: 1, onStageReport },
async () => ({ succeeded: 0, failed: 0, costUsd: 0 }),
);
expect(result.abortedAt).toBeUndefined();
expect(result.itemsProcessed).toBe(0);
// We DO emit one report for the zero-item full stage so audit
// shows the run was attempted.
expect(reports.length).toBe(1);
expect(reports[0].stage).toBe('full');
expect(reports[0].verdict).toBe('proceed');
});
});
});
@@ -0,0 +1,84 @@
/**
* v0.41.16.0 — retrofitWrap helper unit tests.
*
* Pins the thin wrapper used by 8 of the 9 v0.41.16.0 retrofits:
* - Default opt-out of D3 safety net + zero ramp grace.
* - Audit JSONL still writes.
* - Cost projection rolls up correctly.
* - Runner error counts surface.
*/
import { describe, expect, test } from 'bun:test';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { withEnv } from '../helpers/with-env.ts';
import { retrofitWrap } from '../../src/core/progressive-batch/retrofit-wrap.ts';
function auditEnv(): Record<string, string> {
return {
GBRAIN_AUDIT_DIR: mkdtempSync(join(tmpdir(), 'rw-audit-')),
GBRAIN_PROGRESSIVE_BATCH_AUTO: '1',
};
}
describe('retrofitWrap', () => {
test('opt-out defaults: runs without BudgetTracker safety net', async () => {
await withEnv(auditEnv(), async () => {
const r = await retrofitWrap({
label: 'unit-test',
items: [1, 2, 3, 4, 5],
runner: async (rows) => ({ succeeded: rows.length, failed: 0, costUsd: 0 }),
});
expect(r.abortedAt).toBeUndefined();
expect(r.itemsProcessed).toBe(5);
});
});
test('passes per-item cost into projection', async () => {
await withEnv(auditEnv(), async () => {
const r = await retrofitWrap({
label: 'cost-projection',
items: Array.from({ length: 100 }, (_, i) => i),
costPerItem: 0.001,
runner: async (rows) => ({
succeeded: rows.length,
failed: 0,
costUsd: rows.length * 0.001,
}),
});
expect(r.abortedAt).toBeUndefined();
expect(r.totalCostUsd).toBeCloseTo(0.1);
});
});
test('runner failures roll up into the totals', async () => {
await withEnv(auditEnv(), async () => {
const r = await retrofitWrap({
label: 'failure-test',
items: Array.from({ length: 10 }, (_, i) => i),
runner: async (rows) => ({
succeeded: 0,
failed: rows.length,
costUsd: 0,
}),
});
// Default maxErrorRate=0.02 — 100% failure aborts at trial.
expect(r.abortedAt?.verdict).toBe('abort_error_rate');
});
});
test('interactiveAbortMs=0 default → no grace period (cron-safe)', async () => {
await withEnv(auditEnv(), async () => {
const start = Date.now();
const r = await retrofitWrap({
label: 'cron-safe',
items: Array.from({ length: 200 }, (_, i) => i),
runner: async (rows) => ({ succeeded: rows.length, failed: 0, costUsd: 0 }),
});
expect(r.abortedAt).toBeUndefined();
// Should sail through all 4 stages with no grace pause.
expect(Date.now() - start).toBeLessThan(500);
});
});
});