Files
gbrain/test/takes-resolution.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

242 lines
9.1 KiB
TypeScript

/**
* v0.30.0 (Slice A1): tests for the pure resolution + scorecard helpers.
* Covers the (quality, outcome) tuple derivation and the Brier math
* including the partial-exclusion contract (D5 + D11).
*/
import { describe, test, expect } from 'bun:test';
import {
deriveResolutionTuple,
finalizeScorecard,
PARTIAL_RATE_WARNING_THRESHOLD,
} from '../src/core/takes-resolution.ts';
import { GBrainError } from '../src/core/types.ts';
describe('deriveResolutionTuple', () => {
test('quality=correct → (correct, true)', () => {
expect(deriveResolutionTuple({ quality: 'correct', resolvedBy: 'garry' })).toEqual({
quality: 'correct',
outcome: true,
});
});
test('quality=incorrect → (incorrect, false)', () => {
expect(deriveResolutionTuple({ quality: 'incorrect', resolvedBy: 'garry' })).toEqual({
quality: 'incorrect',
outcome: false,
});
});
test('quality=partial → (partial, null)', () => {
expect(deriveResolutionTuple({ quality: 'partial', resolvedBy: 'garry' })).toEqual({
quality: 'partial',
outcome: null,
});
});
test('outcome=true (back-compat alias) → (correct, true)', () => {
expect(deriveResolutionTuple({ outcome: true, resolvedBy: 'garry' })).toEqual({
quality: 'correct',
outcome: true,
});
});
test('outcome=false (back-compat alias) → (incorrect, false)', () => {
expect(deriveResolutionTuple({ outcome: false, resolvedBy: 'garry' })).toEqual({
quality: 'incorrect',
outcome: false,
});
});
test('quality wins when both inputs supplied AND consistent', () => {
// outcome=true is consistent with quality=correct; quality wins.
expect(deriveResolutionTuple({ quality: 'correct', outcome: true, resolvedBy: 'garry' })).toEqual({
quality: 'correct',
outcome: true,
});
});
test('contradictory quality + outcome throws TAKE_RESOLUTION_INVALID', () => {
expect(() =>
deriveResolutionTuple({ quality: 'correct', outcome: false, resolvedBy: 'garry' })
).toThrow(GBrainError);
expect(() =>
deriveResolutionTuple({ quality: 'partial', outcome: true, resolvedBy: 'garry' })
).toThrow(GBrainError);
});
test('neither field set throws TAKE_RESOLUTION_INVALID', () => {
expect(() =>
deriveResolutionTuple({ resolvedBy: 'garry' })
).toThrow(GBrainError);
});
// v0.36.1.1 R1-class: 'unresolvable' joins partial as null-outcome.
test('R1: quality=unresolvable → (unresolvable, null)', () => {
expect(deriveResolutionTuple({ quality: 'unresolvable', resolvedBy: 'garry' })).toEqual({
quality: 'unresolvable',
outcome: null,
});
});
test('R4-helper: unresolvable + outcome=true is contradictory (throws)', () => {
// Defense-in-depth: deriveResolutionTuple rejects contradictory inputs
// before the schema CHECK fires. unresolvable requires null outcome —
// same shape as partial.
expect(() =>
deriveResolutionTuple({ quality: 'unresolvable', outcome: true, resolvedBy: 'garry' })
).toThrow(GBrainError);
expect(() =>
deriveResolutionTuple({ quality: 'unresolvable', outcome: false, resolvedBy: 'garry' })
).toThrow(GBrainError);
});
});
describe('finalizeScorecard (Brier math)', () => {
test('n=0: returns nulls, no divide-by-zero', () => {
const card = finalizeScorecard({
total_bets: 0, resolved: 0, correct: 0, incorrect: 0, partial: 0, brier: null,
});
expect(card).toEqual({
total_bets: 0,
resolved: 0,
correct: 0,
incorrect: 0,
partial: 0,
accuracy: null,
brier: null,
partial_rate: null,
unresolvable_count: 0,
unresolvable_rate: null,
});
});
test('all correct: accuracy=1, Brier reflects mean (weight - 1)^2', () => {
// 3 correct bets at weight 0.7. Per-row Brier = (0.7 - 1)^2 = 0.09.
// Mean = 0.09. Accuracy = 3/3 = 1.0.
const card = finalizeScorecard({
total_bets: 3, resolved: 3, correct: 3, incorrect: 0, partial: 0, brier: 0.09,
});
expect(card.accuracy).toBe(1.0);
expect(card.brier).toBeCloseTo(0.09, 5);
expect(card.partial_rate).toBe(0);
});
test('all incorrect: accuracy=0, Brier reflects mean weight^2', () => {
// 3 incorrect bets at weight 0.7. Per-row Brier = (0.7 - 0)^2 = 0.49.
const card = finalizeScorecard({
total_bets: 3, resolved: 3, correct: 0, incorrect: 3, partial: 0, brier: 0.49,
});
expect(card.accuracy).toBe(0.0);
expect(card.brier).toBeCloseTo(0.49, 5);
});
test('hand-calculated reference: 4 mixed bets', () => {
// bets: weight=0.9 correct, weight=0.6 correct, weight=0.7 incorrect, weight=0.4 incorrect
// Brier per row: (0.9-1)^2=0.01, (0.6-1)^2=0.16, (0.7-0)^2=0.49, (0.4-0)^2=0.16
// Mean: (0.01+0.16+0.49+0.16)/4 = 0.205
// Accuracy: 2/4 = 0.5
const card = finalizeScorecard({
total_bets: 4, resolved: 4, correct: 2, incorrect: 2, partial: 0, brier: 0.205,
});
expect(card.accuracy).toBe(0.5);
expect(card.brier).toBeCloseTo(0.205, 5);
expect(card.partial_rate).toBe(0);
});
test('D5: partial excluded from Brier denominator; appears in partial_rate', () => {
// 2 correct, 1 incorrect, 1 partial. Brier reflects only the 3 binary rows
// (the SQL aggregation passes partial=null per row to AVG, so partial
// doesn't affect Brier in finalizeScorecard either).
// partial_rate = 1/4 = 0.25
const card = finalizeScorecard({
total_bets: 4, resolved: 4, correct: 2, incorrect: 1, partial: 1, brier: 0.18,
});
// accuracy uses correct + incorrect denominator (binary), excluding partial.
expect(card.accuracy).toBeCloseTo(2 / 3, 5);
expect(card.partial_rate).toBe(0.25);
expect(card.brier).toBe(0.18);
});
test('D11: partial_rate threshold constant matches plan (20%)', () => {
expect(PARTIAL_RATE_WARNING_THRESHOLD).toBe(0.20);
});
test('all-partial scorecard: Brier null, accuracy null, partial_rate=1', () => {
const card = finalizeScorecard({
total_bets: 5, resolved: 5, correct: 0, incorrect: 0, partial: 5, brier: null,
});
expect(card.brier).toBeNull();
expect(card.accuracy).toBeNull();
expect(card.partial_rate).toBe(1);
});
test('partial_rate = 0 when no partial bets', () => {
const card = finalizeScorecard({
total_bets: 2, resolved: 2, correct: 1, incorrect: 1, partial: 0, brier: 0.5,
});
expect(card.partial_rate).toBe(0);
});
// v0.36.1.1 R5 — sibling-field semantics. `resolved` MUST keep the v0.36.1.0
// 3-state meaning so historical comparisons stay valid; unresolvable rows
// live in the new sibling fields only.
test('R5: unresolvable_count + unresolvable_rate populated; resolved/accuracy/partial_rate unchanged', () => {
// 2 correct, 1 incorrect, 1 partial, 1 unresolvable. resolved=4 (3-state),
// unresolvable_count=1. unresolvable_rate = 1 / (4 + 1) = 0.2.
const card = finalizeScorecard({
total_bets: 5,
resolved: 4,
correct: 2,
incorrect: 1,
partial: 1,
brier: 0.2,
unresolvable_count: 1,
});
expect(card.resolved).toBe(4); // 3-state, NOT 5
expect(card.unresolvable_count).toBe(1);
expect(card.unresolvable_rate).toBeCloseTo(0.2, 5);
// accuracy unchanged: correct / (correct + incorrect) = 2/3
expect(card.accuracy).toBeCloseTo(2 / 3, 5);
// partial_rate unchanged: partial / resolved (3-state denominator) = 1/4
expect(card.partial_rate).toBe(0.25);
// brier unchanged
expect(card.brier).toBe(0.2);
});
test('R5: legacy v0.36.1.0 ScorecardRowRaw (no unresolvable_count) backfills count=0; rate=0 when resolved>0', () => {
// Engines pre-v0.36.1.1 won't return unresolvable_count in the row. The
// optional `?` on ScorecardRowRaw + default-0 in finalizeScorecard means
// existing brains keep working. unresolvable_rate matches partial_rate
// semantics: 0 when there are resolved rows but no unresolvable ones.
const card = finalizeScorecard({
total_bets: 3, resolved: 3, correct: 2, incorrect: 1, partial: 0, brier: 0.18,
});
expect(card.unresolvable_count).toBe(0);
expect(card.unresolvable_rate).toBe(0);
});
test('R5: empty brain (resolved=0 AND unresolvable_count=0) → unresolvable_rate=null', () => {
// Only when BOTH counts are zero do we return null (avoid divide-by-zero
// and signal "no data" rather than "0% unresolvable on no data").
const card = finalizeScorecard({
total_bets: 0, resolved: 0, correct: 0, incorrect: 0, partial: 0, brier: null,
});
expect(card.unresolvable_count).toBe(0);
expect(card.unresolvable_rate).toBeNull();
});
test('R5: brain with only unresolvable rows surfaces unresolvable_rate=1.0', () => {
const card = finalizeScorecard({
total_bets: 5, resolved: 0, correct: 0, incorrect: 0, partial: 0, brier: null,
unresolvable_count: 5,
});
expect(card.resolved).toBe(0);
expect(card.unresolvable_count).toBe(5);
expect(card.unresolvable_rate).toBe(1.0);
expect(card.accuracy).toBeNull();
expect(card.brier).toBeNull();
expect(card.partial_rate).toBeNull(); // resolved is 0 → null per existing contract
});
});