Files
gbrain/docs/architecture/calibration-quality-gate-spec.md
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

8.7 KiB
Raw Blame History

Calibration Quality Gate — Falsifiability Filter + Category Classification

Historical context. This is the source spec absorbed from PR #1191 into two waves of implementation:

  • v0.37.2.0 hotfix (this release): widens the takes_resolution_consistency CHECK constraint to accept quality='unresolvable' as a 4th valid state. Unblocks the production grading script. Adds unresolvable_count + unresolvable_rate to TakesScorecard as sibling fields (preserves v0.36.1.0 historical comparison semantics). Migration renumbered v74→v79→v80 during successive master merges — v0.37.0.0's autonomous-remediation wave claimed v68-v78, then v0.37.1.0 (brainstorm/lsd) claimed v79.
  • Follow-up minor (forthcoming): falsifiability + category extraction at propose_takes, SQL-side grade gate, per-category calibration scorecards, pg_trgm-based proposal dedup. Wave-blocking on cat15 F1 re-validation against the v0.36.1.0 fixtures.

Preserved here per the hotfix plan's PR #1191 close protocol so the production context (96K-page brain, 6.8% falsifiability rate, category breakdown) doesn't get lost in the CHANGELOG → release-notes condensation.

Problem

v0.36.1.0 ships propose_takes, grade_takes, and calibration_profile as a connected pipeline: extract claims → grade them against outcomes → build a calibration profile showing systematic biases.

In production on a 96K-page brain with 36K takes across 6,239 holders, the grade_takes phase produces noisy results:

  • 6.8% falsifiability rate: Of 500 candidate takes (weight ≥ 0.7), only 34 passed an LLM falsifiability filter. The other 93% were philosophical beliefs, present-state observations, advice, logistics, or vague vibes.
  • 50% unresolvable: Even after filtering, 17/34 predictions couldn't be graded because evidence was insufficient or the claim was too ambiguous.
  • Duplicates: Same claim from the same page extracted multiple times with slightly different wording.

The root cause: propose_takes extracts everything that looks like a belief or assertion. That's correct for the takes table (epistemological layer), but grade_takes needs a much narrower subset: falsifiable predictions about future outcomes where we can check what actually happened.

Example classifications from production testing

Genuine predictions (grade-worthy):

  • "X will reach $1M ARR very soon" → company_outcome
  • "X is going to leave Y" → people_move
  • "AI will make authentic authorship more important" → technology
  • "X was convinced Y would win the Z market" → market_call

Not predictions (should skip grading):

  • "Desire is mimetic" → philosophical belief
  • "X should charge 10x more" → advice
  • "Return from Toronto on Monday" → logistics
  • "Something is going to happen there" → vague/unfalsifiable
  • "X is growing very quickly" → present-state observation

Solution

1. Falsifiability score at extraction time

Add a falsifiability column to the takes table (real, 0.01.0, nullable, default null). propose_takes sets this during extraction using the same LLM call that already produces the take — one additional field in the JSON schema.

ALTER TABLE takes ADD COLUMN IF NOT EXISTS falsifiability real;
ALTER TABLE takes ADD COLUMN IF NOT EXISTS falsifiability_category text;

The LLM prompt addition (appended to the existing propose_takes extraction prompt):

For each claim, also assess:
- falsifiability (0.0-1.0): Can this claim be checked against future reality?
  1.0 = specific, measurable, time-bounded prediction about an outcome
  0.5 = directional claim that's partially checkable
  0.0 = philosophical belief, advice, observation, or unfalsifiable assertion
- falsifiability_category: one of
  company_outcome | fundraising | technology | people_move | market_call | other_prediction | not_prediction

Cost: ~0 incremental tokens (the claim is already being extracted; this adds two fields to the JSON output schema).

2. Grade gate in grade_takes

Before attempting grading, filter:

const gradeable = candidates.filter(t =>
  t.falsifiability !== null && t.falsifiability >= 0.7
  && t.falsifiability_category !== 'not_prediction'
);

This reduces grading volume by ~93% in production, which means:

  • LLM cost for grading drops proportionally
  • Evidence retrieval load drops (each grade attempt triggers hybrid search)
  • Calibration profiles are built on real predictions, not noise

3. Deduplication at extraction

propose_takes should check for near-duplicate claims before inserting:

// Before inserting a new take, check if a similar claim exists
// for the same holder from the same page
const existing = await engine.sql`
  SELECT id, claim FROM takes
  WHERE holder = ${holder}
  AND page_id = ${pageId}
  AND similarity(claim, ${newClaim}) > 0.8
  LIMIT 1
`;
if (existing.length > 0) {
  // Skip — near-duplicate
  continue;
}

Requires pg_trgm extension (already available on most Postgres installations). Falls back gracefully: if similarity() isn't available, skip the dedup check.

4. Category-aware calibration profiles

The calibration_profile phase can now group resolved takes by falsifiability_category to produce per-domain scorecards:

"Your company_outcome calls are 73% accurate.
 Your people_move calls are 90% accurate.
 Your technology calls are 60% accurate — you tend to be ~18 months early."

This is the tweetable output: a calibration profile that says "here's how you're systematically right and wrong by category."

Schema Changes

-- Migration: add falsifiability columns to takes
ALTER TABLE takes ADD COLUMN IF NOT EXISTS falsifiability real;
ALTER TABLE takes ADD COLUMN IF NOT EXISTS falsifiability_category text;

-- Index for grade_takes filter
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_takes_falsifiability
  ON takes (falsifiability)
  WHERE falsifiability IS NOT NULL AND falsifiability >= 0.7;

-- Optional: pg_trgm for dedup (CREATE EXTENSION IF NOT EXISTS pg_trgm;)

Evidence Retrieval (v0.36.1.0 → v0.37 enhancement)

The current grade_takes evidence retriever returns a stub placeholder. In production testing, we wired real evidence retrieval via gbrain query (hybrid search). The pattern that works:

  1. Extract the core claim from the take (first 150 chars)
  2. Run engine.query(claim) to get relevant pages
  3. Filter to pages updated AFTER the take's since_date (evidence must be newer)
  4. Pass top-5 chunks as the evidence block to the judge

This should replace the stub in the evidenceRetriever injection point.

Production Results

After implementing the falsifiability filter (as a pre-processing step outside the cycle):

Metric Before (v2, no filter) After (v3, with filter)
Candidates evaluated 50 34 (from 500 screened)
Falsifiable predictions ~19 (38%) 34 (100%)
Correct 10 (52.6% of resolvable) 10 (58.8% of resolvable)
Incorrect 5 (26.3%) 2 (11.8%)
Partial 4 (21.1%) 5 (29.4%)
Unresolvable 31 (62%) 17 (50%)
Category breakdown N/A people_move:13, company_outcome:11, technology:4, market_call:2

Key improvement: the false positive rate dropped from 62% noise to 0% noise in the gradeable set. The remaining 50% unresolvable rate is genuine — those predictions are about outcomes that haven't happened yet or where the brain lacks evidence. That's correct behavior, not noise.

Files to Change

  1. src/core/cycle/propose-takes.ts — Add falsifiability + category to extraction prompt and output schema
  2. src/core/cycle/grade-takes.ts — Add falsifiability gate before grading; wire real evidence retrieval
  3. src/core/cycle/calibration-profile.ts — Group scorecards by category
  4. src/core/engine.ts — Add similarity() helper for dedup (graceful fallback)
  5. New migration — Add columns + index

Testing

  • Unit test: falsifiability classifier on 20 known-good and 20 known-noise takes
  • Unit test: dedup correctly merges near-identical claims
  • Unit test: grade gate filters below threshold
  • Integration test: full cycle with falsifiability → grade → profile pipeline
  • Regression test: existing takes without falsifiability score are not broken (null falsifiability = ungated, backward compatible)

Backward Compatibility

  • falsifiability defaults to null. Existing takes are unaffected.
  • grade_takes with null falsifiability: configurable behavior. Default: grade all (backward compat). Operator can set cycle.grade_takes.require_falsifiability: true to gate.
  • Category column is purely additive.
  • Dedup is opt-in: cycle.propose_takes.dedup.enabled: true.