mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
The calibration narrative layer. Reads TakesScorecard, asks an LLM to
write 2-4 conversational pattern statements ("right on tactics, late on
macro by 18 months"), passes them through the voice gate, derives active
bias tags, writes the row to calibration_profiles. This is the read-side
that E1 (think anti-bias rewrite), E3 (contradictions join), E6
(dashboard), and E7 (real-time nudges) all consume.
Voice gate (D24 — single function, multiple surfaces):
ALL five calibration UX surfaces import the same gateVoice() function
from src/core/calibration/voice-gate.ts. Mode parameter
('pattern_statement' | 'nudge' | 'forecast_blurb' | 'dashboard_caption'
| 'morning_pulse') drives surface-specific tuning via the rubric the
gate ships to its Haiku judge. NO forked implementations — voice
rubric drift would defeat the gate.
Each mode's rubric explicitly forbids preachy / clinical / corporate
voice; a structural test pins this. Anchors the cross-cutting voice
rule from /plan-ceo-review D2-D8.
Fallback policy (D11):
Up to 2 generation attempts (configurable). On both rejects → fall back
to a hand-written template from src/core/calibration/templates.ts.
Templates are intentionally short and a little "robotic" — they're the
safety net, not the destination. voice_gate_passed=false +
voice_gate_attempts get persisted on the calibration_profiles row so
the operator can review the failing examples and tune the rubric over
time. Suppressing the surface silently is NEVER an option — that's how
voice quality silently degrades.
parseJudgeOutput defaults to 'academic' on parse failure (NEVER passes
pass-through) so a Haiku output garble falls through to the template
rather than letting unverified text reach the user.
calibration_profile phase:
Extends BaseCyclePhase. Cold-brain skip: <5 resolved takes → no row
written, no LLM call. Otherwise: scorecard via engine.getScorecard()
→ patterns via voice-gated generator → bias tags via separate
generator (best-effort; failure logs warning, phase continues).
The DB INSERT lands in the v67 calibration_profiles row with
source_id, holder, the patterns, voice gate audit fields, active bias
tags, and grade_completion (F1 fix — partial-grade state surfaces to
the dashboard "60% graded" badge).
Budget gate at $0.50/cycle default (mostly Haiku). Below-budget
before-LLM-call check returns status='warn' without writing the row.
Per-domain scorecards are a placeholder for v0.36.0.0 ship state —
the F12 batchGetTakesScorecards() engine method that powers per-domain
rendering lands in Lane C alongside the CLI/MCP surface.
Architecture:
parsePatternStatementsOutput is tolerant of LLM emitting numbered
lists / bulleted lines despite the prompt asking for plain lines.
Caps at 4 patterns + drops excessively long lines (>200 chars).
parseBiasTagsOutput lowercases input + drops non-kebab-case tokens
(defends against the LLM emitting "Over-Confident Geography" with
spaces or capitals). Caps at 4 tags.
Tests: 43 cases across two new test files.
voice-gate.test.ts (24): parseJudgeOutput (7), gateVoice happy path
(3), fallback path (5), mode parity (2), templates (7).
calibration-profile.test.ts (19): parsers (10), pickFallbackSlots
(3), phase integration (6 — cold-brain skip, happy path, voice gate
fallback, grade_completion plumbed through, bias-tags failure
non-fatal, source_id scope reaches INSERT).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
333 lines
12 KiB
TypeScript
333 lines
12 KiB
TypeScript
/**
|
|
* v0.36.0.0 (T6 / D24) — voice gate unit tests.
|
|
*
|
|
* Hermetic. No real LLM, no PGLite. Inject the judge + generator + template
|
|
* fallback per test.
|
|
*
|
|
* Tests cover:
|
|
* - D11 retry policy: 2 attempts then template fallback
|
|
* - happy path: first attempt passes, second attempt skipped
|
|
* - happy path: first rejected, second passes
|
|
* - both rejected → template fallback, audit fields populated
|
|
* - generator throws → counted as failed attempt + template fallback
|
|
* - parseJudgeOutput: fence-stripping, malformed input, parse failure
|
|
* falls to 'academic' (NOT pass-through)
|
|
* - mode parity: every VoiceGateMode has a default rubric
|
|
* - templates produce stable output for fixed slots
|
|
*/
|
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
import {
|
|
gateVoice,
|
|
parseJudgeOutput,
|
|
DEFAULT_RUBRICS,
|
|
type VoiceGateJudge,
|
|
type VoiceGateGenerator,
|
|
} from '../src/core/calibration/voice-gate.ts';
|
|
import {
|
|
VOICE_GATE_MODES,
|
|
patternStatementTemplate,
|
|
nudgeTemplate,
|
|
forecastBlurbTemplate,
|
|
dashboardCaptionTemplate,
|
|
morningPulseTemplate,
|
|
type PatternStatementSlots,
|
|
} from '../src/core/calibration/templates.ts';
|
|
|
|
const passJudge: VoiceGateJudge = async () => ({ verdict: 'conversational', reason: 'reads natural' });
|
|
const rejectJudge: VoiceGateJudge = async () => ({ verdict: 'academic', reason: 'too clinical' });
|
|
|
|
const defaultSlots: PatternStatementSlots = { domain: 'macro tech', nRight: 2, nWrong: 5, direction: 'over-confident' };
|
|
|
|
// ─── parseJudgeOutput ───────────────────────────────────────────────
|
|
|
|
describe('parseJudgeOutput', () => {
|
|
test('parses a clean verdict object', () => {
|
|
const out = parseJudgeOutput('{"verdict":"conversational","reason":"sounds like a friend"}');
|
|
expect(out.verdict).toBe('conversational');
|
|
expect(out.reason).toBe('sounds like a friend');
|
|
});
|
|
|
|
test('parses fence-wrapped JSON', () => {
|
|
const out = parseJudgeOutput('```json\n{"verdict":"academic","reason":"jargon"}\n```');
|
|
expect(out.verdict).toBe('academic');
|
|
});
|
|
|
|
test('parses leading-prose payload', () => {
|
|
const out = parseJudgeOutput('Here is my verdict: {"verdict":"academic","reason":"clinical"}');
|
|
expect(out.verdict).toBe('academic');
|
|
});
|
|
|
|
test('falls to academic on empty input (NEVER passes pass-through)', () => {
|
|
expect(parseJudgeOutput('').verdict).toBe('academic');
|
|
expect(parseJudgeOutput(' ').verdict).toBe('academic');
|
|
});
|
|
|
|
test('falls to academic on malformed JSON', () => {
|
|
expect(parseJudgeOutput('not json').verdict).toBe('academic');
|
|
expect(parseJudgeOutput('{not valid').verdict).toBe('academic');
|
|
});
|
|
|
|
test('coerces unknown verdict label to academic', () => {
|
|
expect(parseJudgeOutput('{"verdict":"meh","reason":"x"}').verdict).toBe('academic');
|
|
});
|
|
|
|
test('truncates reason at 80 chars', () => {
|
|
const long = 'x'.repeat(200);
|
|
const out = parseJudgeOutput(`{"verdict":"academic","reason":"${long}"}`);
|
|
expect(out.reason.length).toBe(80);
|
|
});
|
|
});
|
|
|
|
// ─── gateVoice ──────────────────────────────────────────────────────
|
|
|
|
describe('gateVoice — happy path', () => {
|
|
test('first attempt passes → returns LLM text, attempts=1, passed=true', async () => {
|
|
const generate: VoiceGateGenerator = async () => 'You got 2 of 7 macro calls right last year — clear pattern.';
|
|
const result = await gateVoice({
|
|
mode: 'pattern_statement',
|
|
generate,
|
|
judge: passJudge,
|
|
templateFallback: { fn: patternStatementTemplate, slots: defaultSlots },
|
|
});
|
|
expect(result.passed).toBe(true);
|
|
expect(result.attempts).toBe(1);
|
|
expect(result.text).toContain('macro calls right');
|
|
});
|
|
|
|
test('first rejected, second passes → attempts=2, passed=true', async () => {
|
|
let calls = 0;
|
|
const generate: VoiceGateGenerator = async () => {
|
|
calls++;
|
|
return calls === 1 ? 'Per analysis, results show...' : 'You got 2 of 7 right.';
|
|
};
|
|
let judgeCalls = 0;
|
|
const judge: VoiceGateJudge = async () => {
|
|
judgeCalls++;
|
|
return judgeCalls === 1
|
|
? { verdict: 'academic', reason: 'starts with "per analysis"' }
|
|
: { verdict: 'conversational', reason: 'second-person and concrete' };
|
|
};
|
|
const result = await gateVoice({
|
|
mode: 'pattern_statement',
|
|
generate,
|
|
judge,
|
|
templateFallback: { fn: patternStatementTemplate, slots: defaultSlots },
|
|
});
|
|
expect(result.passed).toBe(true);
|
|
expect(result.attempts).toBe(2);
|
|
expect(result.text).toBe('You got 2 of 7 right.');
|
|
});
|
|
|
|
test('feedback from failed attempt 1 reaches generator on attempt 2', async () => {
|
|
let receivedFeedback: string | undefined;
|
|
let calls = 0;
|
|
const generate: VoiceGateGenerator = async ({ attempt, feedback }) => {
|
|
calls++;
|
|
if (attempt === 2) receivedFeedback = feedback;
|
|
return `attempt ${calls}`;
|
|
};
|
|
let judgeCalls = 0;
|
|
const judge: VoiceGateJudge = async () => {
|
|
judgeCalls++;
|
|
return judgeCalls === 1
|
|
? { verdict: 'academic', reason: 'too short' }
|
|
: { verdict: 'conversational', reason: '' };
|
|
};
|
|
await gateVoice({
|
|
mode: 'nudge',
|
|
generate,
|
|
judge,
|
|
templateFallback: {
|
|
fn: nudgeTemplate,
|
|
slots: {
|
|
domain: 'macro',
|
|
conviction: 0.8,
|
|
nRecentMisses: 2,
|
|
nRecentTotal: 3,
|
|
hushPattern: 'over-confident-macro',
|
|
},
|
|
},
|
|
});
|
|
expect(receivedFeedback).toBe('too short');
|
|
});
|
|
});
|
|
|
|
describe('gateVoice — fallback path', () => {
|
|
test('both attempts rejected → template fallback, passed=false, attempts=2', async () => {
|
|
const generate: VoiceGateGenerator = async () => 'Per our analysis, the data indicates...';
|
|
const result = await gateVoice({
|
|
mode: 'pattern_statement',
|
|
generate,
|
|
judge: rejectJudge,
|
|
templateFallback: { fn: patternStatementTemplate, slots: defaultSlots },
|
|
});
|
|
expect(result.passed).toBe(false);
|
|
expect(result.attempts).toBe(2);
|
|
expect(result.text).toContain('macro tech');
|
|
expect(result.text).toContain('over-confident');
|
|
expect(result.lastReason).toBe('too clinical');
|
|
expect(result.templateSlots).toEqual(defaultSlots);
|
|
});
|
|
|
|
test('generator throws on both attempts → template fallback, NO judge calls', async () => {
|
|
let judgeCalls = 0;
|
|
const generate: VoiceGateGenerator = async () => {
|
|
throw new Error('LLM timeout');
|
|
};
|
|
const judge: VoiceGateJudge = async () => {
|
|
judgeCalls++;
|
|
return { verdict: 'conversational', reason: '' };
|
|
};
|
|
const result = await gateVoice({
|
|
mode: 'pattern_statement',
|
|
generate,
|
|
judge,
|
|
templateFallback: { fn: patternStatementTemplate, slots: defaultSlots },
|
|
});
|
|
expect(result.passed).toBe(false);
|
|
expect(judgeCalls).toBe(0);
|
|
expect(result.lastReason).toBe('LLM timeout');
|
|
});
|
|
|
|
test('empty generation counts as a failed attempt + falls through', async () => {
|
|
const generate: VoiceGateGenerator = async () => '';
|
|
const result = await gateVoice({
|
|
mode: 'pattern_statement',
|
|
generate,
|
|
judge: passJudge, // judge would pass but generation is empty
|
|
templateFallback: { fn: patternStatementTemplate, slots: defaultSlots },
|
|
});
|
|
expect(result.passed).toBe(false);
|
|
expect(result.lastReason).toBe('empty_generation');
|
|
});
|
|
|
|
test('parse_failed judge output is treated as academic → fallback fires', async () => {
|
|
const generate: VoiceGateGenerator = async () => 'Some candidate.';
|
|
// Inject a judge that simulates the parse-failure path: returns the
|
|
// 'academic' / 'parse_failed' verdict the production parser would emit
|
|
// when the Haiku call returns garbage.
|
|
const judge: VoiceGateJudge = async () => ({ verdict: 'academic', reason: 'parse_failed' });
|
|
const result = await gateVoice({
|
|
mode: 'pattern_statement',
|
|
generate,
|
|
judge,
|
|
templateFallback: { fn: patternStatementTemplate, slots: defaultSlots },
|
|
});
|
|
expect(result.passed).toBe(false);
|
|
expect(result.lastReason).toBe('parse_failed');
|
|
});
|
|
|
|
test('maxAttempts override changes the retry count', async () => {
|
|
let calls = 0;
|
|
const generate: VoiceGateGenerator = async () => {
|
|
calls++;
|
|
return `attempt ${calls}`;
|
|
};
|
|
await gateVoice({
|
|
mode: 'pattern_statement',
|
|
generate,
|
|
judge: rejectJudge,
|
|
maxAttempts: 4,
|
|
templateFallback: { fn: patternStatementTemplate, slots: defaultSlots },
|
|
});
|
|
expect(calls).toBe(4);
|
|
});
|
|
});
|
|
|
|
// ─── Mode parity ────────────────────────────────────────────────────
|
|
|
|
describe('VoiceGateMode parity', () => {
|
|
test('every mode has a default rubric', () => {
|
|
for (const mode of VOICE_GATE_MODES) {
|
|
expect(DEFAULT_RUBRICS[mode]).toBeDefined();
|
|
expect(DEFAULT_RUBRICS[mode].length).toBeGreaterThan(50);
|
|
}
|
|
});
|
|
|
|
test('every mode rubric explicitly forbids preachy/clinical voice', () => {
|
|
// Anchors the cross-cutting voice rule: each mode's rubric must
|
|
// mention something about NOT sounding academic / preachy / clinical.
|
|
for (const mode of VOICE_GATE_MODES) {
|
|
const rubric = DEFAULT_RUBRICS[mode].toLowerCase();
|
|
const hasGuard =
|
|
rubric.includes('preachy') ||
|
|
rubric.includes('clinical') ||
|
|
rubric.includes('jargon') ||
|
|
rubric.includes('marketing') ||
|
|
rubric.includes('corporate') ||
|
|
rubric.includes('condescending') ||
|
|
rubric.includes('doctor') ||
|
|
rubric.includes('hr');
|
|
expect(hasGuard).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Templates (deterministic) ──────────────────────────────────────
|
|
|
|
describe('voice-gate templates', () => {
|
|
test('patternStatementTemplate is deterministic for fixed slots', () => {
|
|
const out = patternStatementTemplate({
|
|
domain: 'macro tech',
|
|
nRight: 2,
|
|
nWrong: 5,
|
|
direction: 'over-confident',
|
|
});
|
|
expect(out).toBe('Your macro tech calls have a over-confident record — 2 of 7 held up.');
|
|
});
|
|
|
|
test('patternStatementTemplate handles empty resolved set', () => {
|
|
const out = patternStatementTemplate({ domain: 'X', nRight: 0, nWrong: 0 });
|
|
expect(out).toContain('Not enough resolved X calls yet');
|
|
});
|
|
|
|
test('nudgeTemplate includes the hush command', () => {
|
|
const out = nudgeTemplate({
|
|
domain: 'macro',
|
|
conviction: 0.85,
|
|
nRecentMisses: 2,
|
|
nRecentTotal: 3,
|
|
hushPattern: 'over-confident-macro',
|
|
});
|
|
expect(out).toContain('gbrain takes nudge --hush over-confident-macro');
|
|
expect(out).toContain('0.85');
|
|
expect(out).toContain('2 of 3 missed');
|
|
});
|
|
|
|
test('forecastBlurbTemplate flags insufficient data when n<5', () => {
|
|
const out = forecastBlurbTemplate({
|
|
domain: 'macro',
|
|
conviction: 0.7,
|
|
bucketBrier: 0.31,
|
|
overallBrier: 0.18,
|
|
bucketN: 3,
|
|
});
|
|
expect(out).toContain('Forecast unavailable');
|
|
expect(out).toContain('3 resolved');
|
|
});
|
|
|
|
test('forecastBlurbTemplate names comparison vs overall when n>=5', () => {
|
|
const out = forecastBlurbTemplate({
|
|
domain: 'macro',
|
|
conviction: 0.7,
|
|
bucketBrier: 0.31,
|
|
overallBrier: 0.18,
|
|
bucketN: 7,
|
|
});
|
|
expect(out).toContain('worse than your average');
|
|
});
|
|
|
|
test('dashboardCaptionTemplate is concise', () => {
|
|
const out = dashboardCaptionTemplate({ surface: 'Brier trend', fact: '0.18, improving from 0.22 90d ago' });
|
|
expect(out).toBe('Brier trend: 0.18, improving from 0.22 90d ago');
|
|
});
|
|
|
|
test('morningPulseTemplate skips pattern line when topPattern empty', () => {
|
|
const out = morningPulseTemplate({ brier: 0.18, trend: 'improving', topPattern: '' });
|
|
expect(out).toContain('Brier 0.18');
|
|
expect(out).toContain('improving');
|
|
expect(out).not.toContain('Top pattern');
|
|
});
|
|
});
|