diff --git a/apps/web/package.json b/apps/web/package.json index cdc9ec7..9859614 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,11 +13,11 @@ "@astrojs/mdx": "^5.0.2", "@astrojs/react": "^5.0.1", "@better-auth/passkey": "^1.5.5", - "@ezmode-games/drizzle-ledger": "^0.0.1", "@hono/zod-validator": "^0.7.6", "@polar-sh/better-auth": "^1.8.3", "@polar-sh/checkout": "^0.2.0", "@polar-sh/sdk": "^0.46.6", + "@rafters/ledger": "^0.2.0", "@tailwindcss/vite": "^4.2.2", "@tanstack/react-form": "^1.28.5", "@tanstack/react-query": "^5.91.0", diff --git a/apps/web/src/api/auth.ts b/apps/web/src/api/auth.ts index ba58a5c..07fb3ed 100644 --- a/apps/web/src/api/auth.ts +++ b/apps/web/src/api/auth.ts @@ -6,7 +6,7 @@ import { organization } from "better-auth/plugins/organization"; import { passkey } from "@better-auth/passkey"; import { checkout, polar, webhooks } from "@polar-sh/better-auth"; import { Polar } from "@polar-sh/sdk"; -import { ledgerPlugin } from "@ezmode-games/drizzle-ledger/better-auth"; +import { ledgerPlugin } from "@rafters/ledger/better-auth"; import { uuidv7 } from "uuidv7"; import { createDb } from "../db/client"; import { auditLog } from "../db/schema/audit"; diff --git a/docs/designs/uncertainty-engine.md b/docs/designs/uncertainty-engine.md new file mode 100644 index 0000000..71cf97e --- /dev/null +++ b/docs/designs/uncertainty-engine.md @@ -0,0 +1,237 @@ +# Uncertainty Engine -- the AI Honesty Layer + +Design document for the Rafters platform service that records, calibrates, and audits AI predictions across every studio surface. + +**Status:** Spike +**Author:** platform +**Date:** 2026-04-26 + +--- + +## Context + +Every Rafters surface that calls a model produces predictions: rafters names colors, eavesdrop classifies signals, mail scores delivery viability, ctrl scores decision quality. Each surface has its own confidence story and no shared ledger. There is no way to ask "when this model said 80%, was it right 80% of the time?" across the studio. + +The ezmode lineage carried a `UncertaintyClient` in rafters that posted predictions to a `CORE_API` service binding. The server side never existed. The client was non-blocking by design -- silent failure, no gating. The shape of that client is the right shape: observe, don't decide. Build the server side fresh on platform. + +**Positioning:** The platform does not build models. The platform audits them. Other agents own naming, classification, scoring. Platform owns the receipt trail and the calibration math. The engine produces a single posture: every confidence number Rafters publishes is grounded in a published reliability record. + +--- + +## The institutional read + +The uncertainty engine is the **asymmetry of knowing** applied to machine output. Same ethic the platform already holds for revenue (publish the algorithm) and for permissions (the math is too simple to game). When AI returns a number, the platform's question is not "is the model good" but "what does this number mean in our hands." + +Three institutional commitments fall out of that posture: + +1. **Predictions are events, not opinions.** A prediction is recorded with the same rigor as a money transfer -- inputs, model, version, claimed confidence, timestamp. Surfaces emit; platform stores. No silent loss. +2. **Outcomes are first-class.** A prediction without a witnessed outcome is not a failure -- it is a state. Most predictions never get explicit feedback. The engine names that state and counts it. +3. **Calibration is published.** Reliability diagrams (and the underlying counts) are queryable, eventually public. The same instinct as "publish the revenue split." Trust comes from auditability, not from claims. + +--- + +## Lifecycle of a prediction + +Five states. Mirrors the lifecycle of a dollar. + +| State | Trigger | Notes | +| ------------ | ------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| `emitted` | Surface POSTs a prediction with input fingerprint, model, version, claimed confidence | Default state at write | +| `witnessed` | Surface PUTs an outcome (accepted, rejected, edited, ignored, custom) | Outcome shape is per-surface; engine stores the label only | +| `calibrated` | Joined to a reliability cohort `(surface, model, version, confidence_bucket)` | Background job; updates Brier and reliability diagram cache | +| `orphaned` | No outcome witnessed within `T` (per-surface configurable, default 30 days) | Counted separately; not poisoned into the calibration | +| `retired` | Model version superseded; predictions move to archive cohort | Kept for historical drift analysis | + +The orphan state is load-bearing. The wrong design is to treat silence as agreement (poisons calibration upward) or as disagreement (poisons it downward). Silence is its own state, surfaced as a metric -- "76% of color names are accepted without edit, 4% rejected, 20% never witnessed." That third number is itself a signal about the surface. + +--- + +## Schema + +One D1 table covers emit + witness. Calibration is computed from queries, cached in a second table. + +### `uncertainty_prediction` + +| Column | Type | Purpose | +| --------------------- | ------------------------- | ------------------------------------------------------------------------------------------------ | +| `id` | text (uuidv7) | Primary key | +| `surface` | text | `rafters.color`, `eavesdrop.classify`, `mail.deliverability`, `ctrl.decision` | +| `feature_key` | text | Stable identifier for the input shape (e.g. `oklch.lowChromaHighLightness`, `email.cold-domain`) | +| `input_fingerprint` | text | Hash of the canonical input. For dedup and drift cohorts | +| `model` | text | `claude-sonnet-4-7`, `kimi-k2`, `qwen3-coder` | +| `model_version` | text | Provider's version string at call time | +| `claimed_confidence` | real | The number the model (or surface) attached to the prediction. 0.0 - 1.0 | +| `prediction_payload` | text (JSON) | What the model returned. Surface-shaped | +| `state` | text | `emitted` \| `witnessed` \| `orphaned` \| `retired` | +| `outcome_label` | text \| null | Per-surface enum: `accepted`, `rejected`, `edited`, `ignored`, `custom` | +| `outcome_payload` | text (JSON) \| null | Optional detail (the edited name, the rejection reason) | +| `outcome_correctness` | real \| null | 0.0 - 1.0. Surface decides the mapping. `accepted` -> 1.0, `rejected` -> 0.0, `edited` -> 0.5 | +| `created_at` | integer (unix ms) | Emit timestamp | +| `witnessed_at` | integer (unix ms) \| null | Witness timestamp | +| `orphan_after` | integer (unix ms) | When this prediction flips to `orphaned` if no witness arrives | +| `cohort_key` | text | Computed grouping key. Tuple of surface, model, model_version, confidence_bucket | + +### `uncertainty_calibration_snapshot` + +Materialized reliability per cohort. Recomputed on a cron, not on read. + +| Column | Type | Purpose | +| -------------------- | ----------------- | ----------------------------------------------------- | +| `id` | text (uuidv7) | Primary key | +| `cohort_key` | text | Joins to `uncertainty_prediction.cohort_key` | +| `bucket_lower` | real | Confidence bucket lower bound (e.g. 0.7) | +| `bucket_upper` | real | Confidence bucket upper bound (e.g. 0.8) | +| `claimed_confidence` | real | Mean claimed confidence in this bucket | +| `actual_correctness` | real | Mean witnessed correctness in this bucket | +| `prediction_count` | integer | Witnessed predictions in this bucket | +| `orphan_count` | integer | Orphaned predictions in this cohort (separate metric) | +| `brier_score` | real | Mean squared error of the cohort | +| `computed_at` | integer (unix ms) | When this snapshot ran | + +Indexes: `cohort_key`, `surface`, `state`, `(state, orphan_after)` for the orphan sweep. + +--- + +## API + +Hono routes mounted at `/api/uncertainty`. Internal-only. ctrl is the only consumer surface. + +### `POST /api/uncertainty/predictions` + +Surface emits a prediction. + +```ts +{ + surface: string, + feature_key: string, + input_fingerprint: string, + model: string, + model_version: string, + claimed_confidence: number, + prediction_payload: unknown, + orphan_ttl_days?: number // override per-surface default +} +``` + +Returns `{ id, orphan_after }`. + +### `PUT /api/uncertainty/predictions/:id/witness` + +Surface witnesses an outcome. + +```ts +{ + outcome_label: string, + outcome_correctness: number, // 0.0 - 1.0 + outcome_payload?: unknown +} +``` + +Idempotent. If already witnessed, returns 409. + +### `GET /api/uncertainty/calibration` + +Reliability data for a cohort. Read from snapshot table. + +Query params: `surface`, `model`, `model_version`. Returns array of bucket rows with claimed vs actual + counts. + +### `GET /api/uncertainty/orphans` + +Orphan metrics per surface. For dashboards. + +--- + +## Algorithms + +The engine ships with the boring math first. Conformal prediction, Bayesian feature priors, and disagreement detection wait for v2. + +### v1: Brier + reliability diagrams + +For each cohort, bucket predictions by claimed confidence (10 buckets of 0.1 width). Per bucket, compute mean claimed and mean correctness. The reliability diagram is the plot. Brier score is the headline number. + +This is the math that makes the engine a credible audit tool. The output is publishable as-is. + +### v2: Drift detection + +Compare this week's calibration snapshot against the rolling 30-day baseline per cohort. If the per-bucket actual correctness drifts more than `K` standard deviations from baseline, flag the cohort. Cheap. Effective. Catches model regressions before users do. + +### v3: Bayesian feature priors + +Some `feature_key` values are systematically harder. Track per-feature posteriors over time. The engine learns _which inputs deserve less confidence regardless of what the model claims_ and exposes a `feature_difficulty` query for surfaces that want to attenuate their own confidence. + +### v4: Disagreement detection + +When two models run on the same `input_fingerprint`, log both predictions linked by fingerprint. Divergence between `claimed_confidence` values is itself a signal. Particularly useful for color naming where the model bake-off (Kimi vs Sonnet vs Qwen3) is already happening offline. Move it online. + +### v5: Conformal prediction wrapper + +Distribution-free confidence intervals. The proof is publishable; the math is clean. This is the version that lets the platform make a falsifiable claim like "if we say 80%, we are wrong less than 20% of the time, with no distributional assumptions." That claim is the public-facing payload. + +--- + +## Cron jobs + +Two scheduled tasks on the Worker. + +| Job | Cadence | Work | +| ------------------ | ------- | -------------------------------------------------------------------------------- | +| `orphan-sweep` | hourly | Update `state = 'orphaned'` where `orphan_after < now()` and `state = 'emitted'` | +| `calibration-roll` | nightly | Recompute `uncertainty_calibration_snapshot` per active cohort | + +Both jobs are bounded -- query, update, exit. No long-running work. + +--- + +## Surface integration + +Surfaces talk to the engine through a thin client (later extracted as `@rafters/uncertainty-client` if more than one surface adopts). + +```ts +const prediction = await uncertainty.emit({ + surface: "rafters.color", + feature_key: "oklch.lowChromaHighLightness", + input_fingerprint: hashOklch(input), + model: "claude-sonnet-4-7", + model_version: response.model, + claimed_confidence: 0.82, + prediction_payload: { name: "parchment", character: "..." }, +}); + +// later, when designer accepts/edits/rejects in ctrl +await uncertainty.witness(prediction.id, { + outcome_label: "edited", + outcome_correctness: 0.5, + outcome_payload: { final_name: "bone" }, +}); +``` + +The client is non-blocking by design. Emit failures log and continue. The surface never blocks on the audit layer. Same posture as the original ezmode client; that part of the lineage was right. + +--- + +## What this is not + +- **Not a feature flag system.** The engine does not gate predictions. ctrl can route low-confidence predictions to human review, but that's ctrl's policy, not the engine's. +- **Not a metrics platform.** Brier score and reliability diagrams are the only metrics in scope. Latency, cost, throughput belong to observability tooling, not here. +- **Not a model registry.** Surfaces declare `model` and `model_version` per call. The engine doesn't manage model deployment. +- **Not a training signal pipeline.** The engine collects outcomes; it does not retrain models. If a future surface wants supervised fine-tuning data, it can query the engine, but that is downstream. + +--- + +## Phasing + +Phase 1 unblocks rafters. Everything else is incremental. + +- **Phase 1 (spike):** D1 migration + Drizzle schema + zod companion. POST/PUT/GET routes. Orphan sweep. Brier + reliability diagrams via SQL. ctrl reads basic calibration into a debug page. Rafters wires `uncertainty.emit` into color naming. Wrangler binding flips from `ezmode-api` to `platform`. +- **Phase 2:** Drift detection + ctrl alarm surface. eavesdrop becomes a second surface. +- **Phase 3:** Bayesian feature priors. mail integration. +- **Phase 4:** Disagreement detection. Used to retire the color model bake-off scripts and bring it online. +- **Phase 5:** Conformal prediction wrapper. First public reliability diagram on rafters.studio. + +--- + +## Open questions + +- Outcome correctness mapping is per-surface. Is a surface-level config table needed, or is the convention "surface decides at witness time" enough? Spike with the latter; revisit if surfaces start disagreeing on what `edited` means. +- Public dashboard lives where? rafters.studio probably, but the data shape belongs to platform. Defer to phase 5. +- Should the engine emit its own predictions (about its own predictions)? Tempting. Out of scope for v1. diff --git a/package.json b/package.json index f802cf6..c9ed25b 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "packageManager": "pnpm@10.32.1", "pnpm": { "onlyBuiltDependencies": [ - "@ezmode-games/drizzle-ledger", + "@rafters/ledger", "esbuild", "sharp", "workerd" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5d13e2..6eba144 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,9 +96,6 @@ importers: '@better-auth/passkey': specifier: ^1.5.5 version: 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.13)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-auth@1.5.5(drizzle-kit@0.31.10)(drizzle-orm@0.45.1(kysely@0.28.13))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2))))(better-call@1.3.2(zod@4.3.6))(nanostores@1.2.0) - '@ezmode-games/drizzle-ledger': - specifier: ^0.0.1 - version: 0.0.1(better-auth@1.5.5(drizzle-kit@0.31.10)(drizzle-orm@0.45.1(kysely@0.28.13))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2))))(drizzle-orm@0.45.1(kysely@0.28.13)) '@hono/zod-validator': specifier: ^0.7.6 version: 0.7.6(hono@4.12.8)(zod@4.3.6) @@ -111,6 +108,9 @@ importers: '@polar-sh/sdk': specifier: ^0.46.6 version: 0.46.6 + '@rafters/ledger': + specifier: ^0.2.0 + version: 0.2.0(better-auth@1.5.5(drizzle-kit@0.31.10)(drizzle-orm@0.45.1(kysely@0.28.13))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2))))(drizzle-orm@0.45.1(kysely@0.28.13)) '@tailwindcss/vite': specifier: ^4.2.2 version: 4.2.2(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2)) @@ -1139,16 +1139,6 @@ packages: cpu: [x64] os: [win32] - '@ezmode-games/drizzle-ledger@0.0.1': - resolution: {integrity: sha512-a2+8KB0EEQWGoJUxoAgZzNf7cEIy8XU5nSJh7VEnCaLT1o0iLfR9sh9egxb4qtCsyfPJ5WdgfkfDtyY5+8L4VQ==} - engines: {node: '>=18'} - peerDependencies: - better-auth: '*' - drizzle-orm: '>=0.30.0' - peerDependenciesMeta: - better-auth: - optional: true - '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} @@ -2211,6 +2201,18 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@rafters/ledger@0.2.0': + resolution: {integrity: sha512-inYp61hrP8cokRO8xop4FOmA9QEM7Hlxo5ZXrdzEFtkxMgCnA+cC8WD/hxsREXFhplA7eR0r0FFkrEQ36vB7TA==} + engines: {node: '>=22'} + peerDependencies: + better-auth: '*' + drizzle-orm: '>=0.30.0' + peerDependenciesMeta: + better-auth: + optional: true + drizzle-orm: + optional: true + '@reduxjs/toolkit@2.11.2': resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==} peerDependencies: @@ -5903,13 +5905,6 @@ snapshots: '@esbuild/win32-x64@0.27.4': optional: true - '@ezmode-games/drizzle-ledger@0.0.1(better-auth@1.5.5(drizzle-kit@0.31.10)(drizzle-orm@0.45.1(kysely@0.28.13))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2))))(drizzle-orm@0.45.1(kysely@0.28.13))': - dependencies: - drizzle-orm: 0.45.1(kysely@0.28.13) - uuidv7: 1.1.0 - optionalDependencies: - better-auth: 1.5.5(drizzle-kit@0.31.10)(drizzle-orm@0.45.1(kysely@0.28.13))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2))) - '@floating-ui/core@1.7.5': dependencies: '@floating-ui/utils': 0.2.11 @@ -6951,6 +6946,13 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@rafters/ledger@0.2.0(better-auth@1.5.5(drizzle-kit@0.31.10)(drizzle-orm@0.45.1(kysely@0.28.13))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2))))(drizzle-orm@0.45.1(kysely@0.28.13))': + dependencies: + uuidv7: 1.1.0 + optionalDependencies: + better-auth: 1.5.5(drizzle-kit@0.31.10)(drizzle-orm@0.45.1(kysely@0.28.13))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2))) + drizzle-orm: 0.45.1(kysely@0.28.13) + '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)': dependencies: '@standard-schema/spec': 1.1.0