Files
gbrain/test/calibration-profile.test.ts
T
9a4ae0962e v0.37.2.0: takes_resolution_consistency CHECK accepts 'unresolvable' (#1211)
* v0.36.1.1 hotfix: takes_resolution_consistency CHECK accepts 'unresolvable'

Unblocks production grading scripts that write the judge's 4th verdict
type. Before this fix, every quality='unresolvable' INSERT/UPDATE hit
a CHECK violation — 0 of 34 writes landed in a recent prod run.

Migration v74 widens BOTH:
  - takes_resolution_consistency (table-level CHECK) — admits the
    ('unresolvable', NULL) pair alongside the existing 4 legal shapes
  - resolved_quality column-level CHECK — drops the auto-generated
    name from v37, re-adds as takes_resolved_quality_values with the
    4-state enum

Backward compatible. Existing rows with quality IN (NULL, 'correct',
'incorrect', 'partial') all satisfy the new CHECKs unchanged.

TakesScorecard gains sibling fields unresolvable_count + unresolvable_rate;
the existing `resolved` field deliberately keeps its 3-state meaning
so historical scorecards compare apples-to-apples (T1c sibling-field
design from the eng review).

Pinned by:
  - test/takes-resolution.test.ts — R1-R5 round-trip
  - test/migrate.test.ts — v74 structural assertions + PGLite E2E
    suite exercising all valid + invalid (quality, outcome) shapes

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

* test(e2e/schema-drift): reset public schema in beforeAll to isolate from caller bootstrap state

Previously the test trusted caller-provided DATABASE_URL to point at a fresh
database. CLAUDE.md's E2E lifecycle prescribes 'gbrain doctor --json' as the
bootstrap step (needed by oauth-related tests for table creation), but doctor
configures the gateway and bakes the configured embedding model into
content_chunks.model DEFAULT during the initial CREATE TABLE.

On re-run, CREATE TABLE IF NOT EXISTS is a no-op and the bootstrapped default
sticks. PGLite (always fresh-in-memory) gets the unconfigured-gateway fallback
'text-embedding-3-large'. The test reported phantom drift:
  pg.default="'zembed-1'::text"  pglite.default="'text-embedding-3-large'::text"

Fix: DROP SCHEMA public CASCADE + CREATE SCHEMA public before pg.initSchema.
Resets every table/index/sequence/constraint added by prior tooling. The PGLite
side is already fresh-per-test by construction.

Verified order-independent:
  - Fresh DB → 6/0 pass
  - After 'gbrain doctor' bootstrap → 6/0 pass
  - Full E2E suite (mechanical + schema-drift) → 84/0 pass

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

* fix(safety): gate schema-drift DROP SCHEMA + relax TakesScorecard interface

Two findings from Codex adversarial review on the v0.37.0.1 hotfix:

1. **DROP SCHEMA safety gate (P0).** test/e2e/schema-drift.test.ts had an
   unguarded DROP SCHEMA public CASCADE. A developer running the E2E with
   DATABASE_URL pointing at a real brain or staging DB would lose the entire
   public schema. The fix: triple-check before destruction.
   - Parse the DATABASE_URL hostname + db name
   - Allow reset only when: explicit GBRAIN_TEST_DB=1 OR (localhost host AND
     test-shaped db name like gbrain_test, *_test, test_*, *_e2e)
   - Refuse otherwise with a loud paste-ready warning
   - The test still proceeds (the parity check is the fail-safe — if the
     caller already had a fresh DB, parity passes; if not, parity fails
     LOUDLY instead of nuking their data)

   Verified all three branches: localhost+gbrain_test resets (6/0 pass);
   localhost+production_brain refuses + warns (6/0 pass against pre-existing
   schema); GBRAIN_TEST_DB=1 override on production_brain name allows reset.

2. **TakesScorecard interface compat.** Making `unresolvable_count` +
   `unresolvable_rate` required fields on the public TakesScorecard
   interface broke downstream SDK consumers who construct scorecard
   fixtures (gbrain-evals, custom engines). The hotfix shouldn't impose
   a compile-break on hotfix users.

   Fix: make both fields optional (`?: number` / `?: number | null`).
   `finalizeScorecard` still always populates them, so all internal code
   sees the real values. External fixtures that omit them compile cleanly.

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

* fix(safety,ux): tighten DROP SCHEMA gate + surface unresolvable in scorecard CLI

Second codex adversarial pass on v0.37.0.1 surfaced two residual findings.

**P0 — Safety gate still bypassable.** First-pass safety gate used
`explicitOptIn || (isLocalhost && looksLikeTestDb)` — meaning
`GBRAIN_TEST_DB=1` bypassed BOTH the host check AND the db-name check.
Someone running the E2E with that env set against a production DATABASE_URL
would still nuke their schema. Codex re-flagged it as P0.

Tightened logic: `looksLikeTestDb && (isLocalhost || ciOptIn)`. The db-name
pattern is now the hard floor — `gbrain_test`, `*_test`, `test_*`, `*_e2e`.
GBRAIN_TEST_DB=1 only relaxes the localhost requirement (for CI service-name
hosts). Setting the env on a DATABASE_URL pointing at `production_data` is
explicitly refused with a paste-ready message naming the failed check.

Verified 3 ways:
  - gbrain_test + localhost → resets (6/0 pass)
  - production_data + GBRAIN_TEST_DB=1 → REFUSES with clear message
  - foo_e2e + GBRAIN_TEST_DB=1 → resets (test-shaped name passes)

**P2 — gbrain takes scorecard hides the unresolvable signal.** Early-return
on `resolved === 0` was triggered before the new sibling fields rendered.
A brain with only `quality='unresolvable'` verdicts — the spec's whole
production case — printed "No resolved bets yet" and exited. The
unresolvable_rate field was unreachable from the human CLI unless the user
knew to pass `--json`.

Fix: gate the early-return on `resolved === 0 AND unresolvable_count === 0`.
Render `unresolvable` count + `unresolvable_rate` alongside `partial_rate`
when present. Threshold warn at 30% (mirrors PARTIAL_RATE_WARNING_THRESHOLD)
pointing at retrieval coverage, not prediction accuracy — the actionable
read for high-unresolvable brains.

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

* chore: renumber v0.37.0.1 → v0.37.2.0 (v0.37.1.0 claimed by other PRs)

PRs #1214 and #1215 both claim v0.37.1.0; bumping past to the next free
slot. Migration v79 renamed `takes_unresolvable_quality_v0_37_0_1` →
`takes_unresolvable_quality_v0_37_2_0`. VERSION + package.json +
CHANGELOG + llms bundles + inline doc references all swept.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:19:18 -07:00

305 lines
12 KiB
TypeScript

/**
* v0.36.1.0 (T6) — calibration_profile phase unit tests.
*
* Hermetic. Mock engine + injected patterns generator + injected voice gate
* judge. Exercises:
* - cold-brain skip: <5 resolved takes
* - happy path: scorecard → generator → voice gate pass → row written
* - voice gate rejects both attempts → template fallback written
* - bias tags generator wired
* - parsePatternStatementsOutput + parseBiasTagsOutput unit tests
* - grade_completion plumbed through to the DB row
* - budget exhausted → status='warn', no row written
*/
import { describe, test, expect } from 'bun:test';
import {
runPhaseCalibrationProfile,
parsePatternStatementsOutput,
parseBiasTagsOutput,
__testing,
type PatternStatementsGenerator,
type BiasTagsGenerator,
} from '../src/core/cycle/calibration-profile.ts';
import type { VoiceGateJudge } from '../src/core/calibration/voice-gate.ts';
import type { OperationContext } from '../src/core/operations.ts';
import type { BrainEngine, TakesScorecard } from '../src/core/engine.ts';
interface CapturedSql {
sql: string;
params: unknown[];
}
function buildMockEngine(opts: { scorecard: TakesScorecard }): {
engine: BrainEngine;
captured: CapturedSql[];
} {
const captured: CapturedSql[] = [];
const engine = {
kind: 'pglite',
async getScorecard() {
return opts.scorecard;
},
async executeRaw<T>(sql: string, params?: unknown[]): Promise<T[]> {
captured.push({ sql, params: params ?? [] });
return [];
},
} as unknown as BrainEngine;
return { engine, captured };
}
function buildCtx(engine: BrainEngine): OperationContext {
return {
engine,
config: {} as never,
logger: { info() {}, warn() {}, error() {} } as never,
dryRun: false,
remote: false,
sourceId: 'default',
};
}
const passJudge: VoiceGateJudge = async () => ({ verdict: 'conversational', reason: 'fine' });
const rejectJudge: VoiceGateJudge = async () => ({ verdict: 'academic', reason: 'clinical' });
// ─── Parsers ────────────────────────────────────────────────────────
describe('parsePatternStatementsOutput', () => {
test('splits newline-separated statements', () => {
const raw = 'You called early-stage tactics well — 8 of 10 held up.\nGeography is your blind spot. 4 of 6 missed.';
expect(parsePatternStatementsOutput(raw)).toEqual([
'You called early-stage tactics well — 8 of 10 held up.',
'Geography is your blind spot. 4 of 6 missed.',
]);
});
test('strips numbered list markers if the LLM emits them', () => {
const raw = '1. First pattern.\n2) Second pattern.\n- Third pattern.';
expect(parsePatternStatementsOutput(raw)).toEqual([
'First pattern.',
'Second pattern.',
'Third pattern.',
]);
});
test('caps at 4 statements', () => {
const raw = ['a', 'b', 'c', 'd', 'e', 'f'].join('\n');
expect(parsePatternStatementsOutput(raw).length).toBe(4);
});
test('drops empty lines and excessively long lines', () => {
const long = 'x'.repeat(250);
const raw = `valid\n\n${long}\nalso valid`;
expect(parsePatternStatementsOutput(raw)).toEqual(['valid', 'also valid']);
});
test('returns [] on empty input', () => {
expect(parsePatternStatementsOutput('')).toEqual([]);
});
});
describe('parseBiasTagsOutput', () => {
test('parses clean kebab-case tags', () => {
const raw = '["over-confident-geography","late-on-macro-tech"]';
expect(parseBiasTagsOutput(raw)).toEqual(['over-confident-geography', 'late-on-macro-tech']);
});
test('strips markdown fence', () => {
const raw = '```json\n["over-confident-geography"]\n```';
expect(parseBiasTagsOutput(raw)).toEqual(['over-confident-geography']);
});
test('lowercases input + drops non-kebab-case', () => {
const raw = '["Over-Confident-Geography","INVALID TAG","late-on-macro"]';
expect(parseBiasTagsOutput(raw)).toEqual(['over-confident-geography', 'late-on-macro']);
});
test('caps at 4 tags', () => {
const raw = JSON.stringify(['a-b', 'c-d', 'e-f', 'g-h', 'i-j', 'k-l']);
expect(parseBiasTagsOutput(raw).length).toBe(4);
});
test('returns [] on malformed input', () => {
expect(parseBiasTagsOutput('not json')).toEqual([]);
expect(parseBiasTagsOutput('')).toEqual([]);
});
});
// ─── pickFallbackSlots ──────────────────────────────────────────────
describe('pickFallbackSlots', () => {
test('over-confident direction when brier > 0.25', () => {
const scorecard: TakesScorecard = {
total_bets: 10,
resolved: 10,
correct: 4,
incorrect: 6,
partial: 0,
accuracy: 0.4,
brier: 0.32,
partial_rate: 0,
unresolvable_count: 0,
unresolvable_rate: null,
};
expect(__testing.pickFallbackSlots(scorecard).direction).toBe('over-confident');
});
test('mostly-right direction when brier <= 0.25', () => {
const scorecard: TakesScorecard = {
total_bets: 10,
resolved: 10,
correct: 8,
incorrect: 2,
partial: 0,
accuracy: 0.8,
brier: 0.12,
partial_rate: 0,
unresolvable_count: 0,
unresolvable_rate: null,
};
expect(__testing.pickFallbackSlots(scorecard).direction).toBe('mostly right');
});
test('zero resolved → "overall" domain, 0/0', () => {
const scorecard: TakesScorecard = {
total_bets: 0,
resolved: 0,
correct: 0,
incorrect: 0,
partial: 0,
accuracy: null,
brier: null,
partial_rate: null,
unresolvable_count: 0,
unresolvable_rate: null,
};
const out = __testing.pickFallbackSlots(scorecard);
expect(out.nRight).toBe(0);
expect(out.nWrong).toBe(0);
});
});
// ─── Phase integration ──────────────────────────────────────────────
const ENOUGH_RESOLVED_SCORECARD: TakesScorecard = {
total_bets: 20,
resolved: 12,
correct: 7,
incorrect: 4,
partial: 1,
accuracy: 0.636,
brier: 0.21,
partial_rate: 0.083,
unresolvable_count: 0,
unresolvable_rate: null,
};
describe('runPhaseCalibrationProfile — phase integration', () => {
test('cold-brain skip: <5 resolved → no row written, status=ok', async () => {
const { engine, captured } = buildMockEngine({
scorecard: { ...ENOUGH_RESOLVED_SCORECARD, resolved: 3 },
});
const result = await runPhaseCalibrationProfile(buildCtx(engine), {});
expect(result.status).toBe('ok');
expect((result.details as Record<string, unknown>).profile_written).toBe(false);
expect((result.details as Record<string, unknown>).skipped).toBe('insufficient_data');
expect(captured.filter(c => c.sql.includes('INSERT INTO calibration_profiles'))).toHaveLength(0);
});
test('happy path: row written with passed voice gate', async () => {
const { engine, captured } = buildMockEngine({ scorecard: ENOUGH_RESOLVED_SCORECARD });
const patternsGenerator: PatternStatementsGenerator = async () => [
'You called early-stage tactics well — 8 of 10 held up.',
'Geography is your blind spot — 4 of 6 missed.',
];
const biasTagsGenerator: BiasTagsGenerator = async () => ['over-confident-geography'];
const result = await runPhaseCalibrationProfile(buildCtx(engine), {
patternsGenerator,
biasTagsGenerator,
voiceGateJudge: passJudge,
});
expect(result.status).toBe('ok');
const details = result.details as Record<string, unknown>;
expect(details.profile_written).toBe(true);
expect(details.voice_gate_passed).toBe(true);
expect(details.voice_gate_attempts).toBe(1);
expect((details.pattern_statements as string[]).length).toBe(2);
expect((details.active_bias_tags as string[])).toEqual(['over-confident-geography']);
const insert = captured.find(c => c.sql.includes('INSERT INTO calibration_profiles'));
expect(insert).toBeDefined();
// Params: source_id, holder, total_resolved, brier, accuracy, partial_rate,
// grade_completion, domain_scorecards_json, patterns[], voice_passed, voice_attempts,
// bias_tags[], model_id
expect(insert!.params[0]).toBe('default'); // source_id
expect(insert!.params[1]).toBe('garry'); // holder
expect(insert!.params[2]).toBe(12); // total_resolved
expect(insert!.params[9]).toBe(true); // voice_gate_passed
expect(insert!.params[10]).toBe(1); // voice_gate_attempts
expect(insert!.params[11]).toEqual(['over-confident-geography']); // active_bias_tags
});
test('voice gate rejects both attempts → template fallback written, voice_gate_passed=false', async () => {
const { engine, captured } = buildMockEngine({ scorecard: ENOUGH_RESOLVED_SCORECARD });
const patternsGenerator: PatternStatementsGenerator = async () => [
'Per our analysis, the data indicates patterns.',
];
const result = await runPhaseCalibrationProfile(buildCtx(engine), {
patternsGenerator,
voiceGateJudge: rejectJudge,
});
const details = result.details as Record<string, unknown>;
expect(details.voice_gate_passed).toBe(false);
expect(details.voice_gate_attempts).toBe(2);
expect(details.profile_written).toBe(true);
const patterns = details.pattern_statements as string[];
expect(patterns.length).toBeGreaterThan(0);
expect(patterns[0]).toContain('overall'); // template fallback contains "overall" domain
const insert = captured.find(c => c.sql.includes('INSERT INTO calibration_profiles'));
expect(insert!.params[9]).toBe(false); // voice_gate_passed=false
expect(insert!.params[10]).toBe(2); // voice_gate_attempts=2
});
test('grade_completion is plumbed through to the row', async () => {
const { engine, captured } = buildMockEngine({ scorecard: ENOUGH_RESOLVED_SCORECARD });
const patternsGenerator: PatternStatementsGenerator = async () => ['fine pattern'];
await runPhaseCalibrationProfile(buildCtx(engine), {
patternsGenerator,
voiceGateJudge: passJudge,
gradeCompletion: 0.6,
});
const insert = captured.find(c => c.sql.includes('INSERT INTO calibration_profiles'));
expect(insert!.params[6]).toBe(0.6); // grade_completion
});
test('bias_tags_generator failure logs warning + phase continues', async () => {
const { engine } = buildMockEngine({ scorecard: ENOUGH_RESOLVED_SCORECARD });
const patternsGenerator: PatternStatementsGenerator = async () => ['fine pattern'];
const biasTagsGenerator: BiasTagsGenerator = async () => {
throw new Error('Haiku timed out');
};
const result = await runPhaseCalibrationProfile(buildCtx(engine), {
patternsGenerator,
biasTagsGenerator,
voiceGateJudge: passJudge,
});
expect(result.status).toBe('ok');
const details = result.details as Record<string, unknown>;
expect(details.profile_written).toBe(true);
expect((details.warnings as string[])[0]).toContain('Haiku timed out');
});
test('source_id from ctx scope reaches the INSERT params', async () => {
const { engine, captured } = buildMockEngine({ scorecard: ENOUGH_RESOLVED_SCORECARD });
const patternsGenerator: PatternStatementsGenerator = async () => ['fine pattern'];
const ctx = { ...buildCtx(engine), sourceId: 'tenant-b' };
await runPhaseCalibrationProfile(ctx, {
patternsGenerator,
voiceGateJudge: passJudge,
});
const insert = captured.find(c => c.sql.includes('INSERT INTO calibration_profiles'));
expect(insert!.params[0]).toBe('tenant-b');
});
});