okengine 0.22.0 → 0.23.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +7 -0
- package/manifest.v1.schema.json +44 -0
- package/package.json +32 -19
- package/site/content/docs/ai/mcp.mdx +14 -10
- package/site/content/docs/ai/skills.mdx +1 -1
- package/site/content/docs/elements/ai/agents.mdx +61 -13
- package/site/content/docs/elements/ai/decide.mdx +536 -0
- package/site/content/docs/elements/ai/events.mdx +373 -0
- package/site/content/docs/elements/ai/index.mdx +9 -3
- package/site/content/docs/elements/ai/meta.json +1 -1
- package/site/content/docs/elements/ai/prompts.mdx +4 -2
- package/site/content/docs/elements/channel/index.mdx +4 -4
- package/site/content/docs/elements/channel/receipts.mdx +7 -5
- package/site/content/docs/elements/channel/sms.mdx +2 -1
- package/site/content/docs/elements/flow/index.mdx +22 -18
- package/site/content/docs/elements/gate/auth.mdx +4 -1
- package/site/content/docs/elements/signal/index.mdx +15 -14
- package/site/content/docs/elements/store/index.mdx +1 -1
- package/site/content/docs/elements/store/kv.mdx +5 -5
- package/site/content/docs/plugins/otp.mdx +3 -3
- package/site/content/docs/recipes/dragonfly.mdx +5 -5
- package/site/content/docs/recipes/index.mdx +6 -6
- package/site/content/docs/recipes/postgres.mdx +12 -8
- package/site/content/docs/recipes/redis.mdx +4 -4
- package/site/content/docs/reference/cli.mdx +2 -1
- package/site/content/docs/reference/configuration.mdx +9 -7
- package/site/content/docs/reference/fx.mdx +19 -10
- package/src/auth/api-key-sql.test.ts +44 -0
- package/src/auth/api-key-sql.ts +1 -0
- package/src/auth/api-keys.ts +30 -12
- package/src/auth/config.ts +6 -2
- package/src/auth/gate-auth.test.ts +20 -0
- package/src/auth/tenants.ts +17 -0
- package/src/cli/agents-md.test.ts +25 -0
- package/src/cli/ask-vault-gaps.test.ts +38 -1
- package/src/cli/ask-vault-gaps.ts +35 -2
- package/src/cli/decide.ts +174 -0
- package/src/cli/decision-lock-watch.test.ts +45 -0
- package/src/cli/decision-lock-watch.ts +51 -0
- package/src/cli/dev-app-runner.ts +34 -8
- package/src/cli/dev-auth-secret.test.ts +49 -0
- package/src/cli/dev-auth-secret.ts +49 -0
- package/src/cli/dev-hot.test.ts +41 -0
- package/src/cli/dev-hot.ts +48 -0
- package/src/cli/dev.test.ts +1 -1
- package/src/cli/dev.ts +24 -6
- package/src/cli/doctor-diff.test.ts +29 -1
- package/src/cli/doctor-diff.ts +35 -0
- package/src/cli/eval.ts +142 -1
- package/src/cli/index.ts +5 -0
- package/src/cli/load-config.images.test.ts +2 -2
- package/src/cli/load-config.ts +1 -1
- package/src/cli/manifest-pr-diff.ts +16 -0
- package/src/cli/mcp-from-console.ts +48 -0
- package/src/cli/registry.ts +35 -1
- package/src/client/agent.ts +307 -0
- package/src/client/transport.test.ts +20 -0
- package/src/client/transport.ts +2 -1
- package/src/client/types.ts +1 -1
- package/src/client-react/index.ts +7 -1
- package/src/client-react/use-agent-run.test.ts +271 -0
- package/src/client-react/use-agent-run.ts +193 -0
- package/src/compiler/ai-approval.test.ts +36 -0
- package/src/compiler/ai-repair.test.ts +22 -0
- package/src/compiler/decisions.extract.test.ts +164 -0
- package/src/compiler/effects-fetch.test.ts +77 -0
- package/src/compiler/effects-infer.ts +114 -17
- package/src/compiler/effects-join.test.ts +48 -0
- package/src/compiler/extract.test.ts +28 -0
- package/src/compiler/extract.ts +279 -13
- package/src/compiler/fx-index.ts +58 -4
- package/src/compiler/response.ts +35 -2
- package/src/config/index.ts +21 -3
- package/src/console/index.ts +1 -1
- package/src/console/server/ai-runs-flows.ts +269 -0
- package/src/console/server/ai-runs.test.ts +323 -0
- package/src/console/server/ai-runs.ts +562 -0
- package/src/console/server/ai.ts +12 -1
- package/src/console/server/app.ts +10 -1
- package/src/console/server/decisions-flows.ts +157 -0
- package/src/console/server/decisions.test.ts +66 -0
- package/src/console/server/decisions.ts +229 -0
- package/src/console/server/flows.ts +50 -2
- package/src/console/server/invoke-user-flow.ts +9 -0
- package/src/console/server/runs-ingest.ts +16 -0
- package/src/console/server/serve.ts +5 -0
- package/src/console/server/state.ts +25 -1
- package/src/console/server/store-stats.test.ts +47 -0
- package/src/console/server/store-stats.ts +92 -18
- package/src/console/ui-next/dist/assets/access-page-C_N4qvai.js +4 -0
- package/src/console/ui-next/dist/assets/{agent-disclosure-Bkohc_8X.js → agent-disclosure-Cq1TxniW.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-rL0lHxat.js → cache-glyph-BaI4uxZz.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-Deo4PP18.js → call-pii-button-DNyK7IIY.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-CoJ6amHf.js → collapsible-CRhBJMZR.js} +1 -1
- package/src/console/ui-next/dist/assets/confirm-sheet-DZfuJXP4.js +1 -0
- package/src/console/ui-next/dist/assets/copy-inline-button-BTjPVjRT.js +1 -0
- package/src/console/ui-next/dist/assets/decisions-page-D9twj8Xy.js +1 -0
- package/src/console/ui-next/dist/assets/detail-header-Dk5LipF6.js +1 -0
- package/src/console/ui-next/dist/assets/dropdown-menu-CRGrj5JU.js +1 -0
- package/src/console/ui-next/dist/assets/duration-tone-CsHWlVPU.js +9 -0
- package/src/console/ui-next/dist/assets/element-icons-C6t8Vlml.js +1 -0
- package/src/console/ui-next/dist/assets/explorer-chrome-Dslh2yE8.js +1 -0
- package/src/console/ui-next/dist/assets/explorer-empty-CIVg7Jgb.js +1 -0
- package/src/console/ui-next/dist/assets/flows-page-ZaTJAhR9.js +1 -0
- package/src/console/ui-next/dist/assets/{highlighted-json-CPYBzDh0.js → highlighted-json-DQ-EMAfz.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-DTPevKmM.js → http-method-Cn_Fl79s.js} +1 -1
- package/src/console/ui-next/dist/assets/index-BlrN49Hs.css +2 -0
- package/src/console/ui-next/dist/assets/index-CmRFeWdT.js +58 -0
- package/src/console/ui-next/dist/assets/observability-page-Cn6R_dXz.js +8 -0
- package/src/console/ui-next/dist/assets/{replica-lag-BM27lXld.js → replica-lag-Bx6EZhSe.js} +7 -7
- package/src/console/ui-next/dist/assets/request-meta-DmdiUrfh.js +1 -0
- package/src/console/ui-next/dist/assets/shortcut-keys-DTE1sjTC.js +1 -0
- package/src/console/ui-next/dist/assets/store-page-8P6vGXnn.js +41 -0
- package/src/console/ui-next/dist/assets/trace-detail-sheet-BfGvpFSv.js +2 -0
- package/src/console/ui-next/dist/assets/tree-expand-toggle-CWdUHbc0.js +55 -0
- package/src/console/ui-next/dist/assets/units-page-CZOFZzay.js +1 -0
- package/src/console/ui-next/dist/assets/use-vault-list-BAyQG8Re.js +1 -0
- package/src/console/ui-next/dist/assets/vault-page-ki_ishmX.js +2 -0
- package/src/console/ui-next/dist/index.html +4 -3
- package/src/console/ui-next/seed-parked-approval.ts +126 -0
- package/src/console/ui-next/src/client.ts +224 -1
- package/src/console/ui-next/src/components/explorer/detail-header.tsx +9 -0
- package/src/console/ui-next/src/components/explorer/explorer-start-toggle.tsx +5 -1
- package/src/console/ui-next/src/components/ui/sheet-form.tsx +21 -12
- package/src/console/ui-next/src/features/flows/decisions/decisions-page.tsx +216 -0
- package/src/console/ui-next/src/features/flows/decisions/resolve.test.ts +48 -0
- package/src/console/ui-next/src/features/flows/decisions/resolve.ts +111 -0
- package/src/console/ui-next/src/features/flows/flows-page.tsx +10 -0
- package/src/console/ui-next/src/features/flows/graph/element-map.ts +1 -0
- package/src/console/ui-next/src/features/flows/traces/effect-kind.ts +6 -0
- package/src/console/ui-next/src/features/flows/traces/effect-summary.test.ts +24 -0
- package/src/console/ui-next/src/features/flows/traces/effect-summary.ts +24 -0
- package/src/console/ui-next/src/features/flows/traces/http-status.ts +58 -0
- package/src/console/ui-next/src/features/flows/traces/request-client-card.tsx +355 -0
- package/src/console/ui-next/src/features/flows/traces/request-client.test.ts +114 -0
- package/src/console/ui-next/src/features/flows/traces/request-client.ts +423 -0
- package/src/console/ui-next/src/features/flows/traces/request-input-view.test.ts +52 -6
- package/src/console/ui-next/src/features/flows/traces/request-input-view.ts +85 -19
- package/src/console/ui-next/src/features/flows/traces/trace-detail-sheet.tsx +505 -80
- package/src/console/ui-next/src/features/flows/traces/trace-lanes.test.ts +61 -0
- package/src/console/ui-next/src/features/flows/traces/trace-lanes.ts +78 -0
- package/src/console/ui-next/src/features/flows/traces/trace-nav.test.ts +48 -0
- package/src/console/ui-next/src/features/flows/traces/trace-nav.ts +57 -0
- package/src/console/ui-next/src/features/flows/traces/trace-request-section.tsx +275 -96
- package/src/console/ui-next/src/features/flows/traces/traces-pane.tsx +16 -2
- package/src/console/ui-next/src/features/observability/detail/ai-runs.tsx +369 -0
- package/src/console/ui-next/src/features/observability/observability-page.tsx +31 -1
- package/src/console/ui-next/src/features/observability/state/observability-selection.ts +18 -3
- package/src/console/ui-next/src/features/store/performance/kv-performance-panel.tsx +12 -1
- package/src/console/ui-next/src/features/store/performance/performance-panel.tsx +14 -1
- package/src/console/ui-next/src/features/units/call/call-api-panel.tsx +1 -1
- package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +6 -2
- package/src/console/ui-next/src/features/units/explorer/units-tree.tsx +73 -18
- package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +21 -0
- package/src/console/ui-next/src/features/units/lib/unit-tree.ts +82 -0
- package/src/console/ui-next/src/features/units/units-page.tsx +25 -7
- package/src/console/ui-next/src/router.tsx +17 -0
- package/src/console/ui-next/ui-next-seed-runs.ts +23 -0
- package/src/console/ui-next/vite-console-kernel-plugin.ts +43 -3
- package/src/console/ui-next/vite.config.ts +9 -0
- package/src/docker/recipes/dragonfly.ts +3 -2
- package/src/docker/recipes/redis.ts +1 -1
- package/src/drivers/ai-anthropic.ts +212 -21
- package/src/drivers/ai-mock.ts +34 -1
- package/src/drivers/ai-openai-compatible.ts +67 -26
- package/src/drivers/ai-preconnect.ts +18 -0
- package/src/drivers/ai-stream.test.ts +62 -0
- package/src/drivers/ai-types.ts +20 -0
- package/src/drivers/journal-postgres.test.ts +22 -1
- package/src/drivers/journal-postgres.ts +511 -13
- package/src/drivers/memory-ddl.test.ts +18 -0
- package/src/drivers/memory.ts +21 -1
- package/src/drivers/pg-rls.test.ts +31 -0
- package/src/drivers/pg-rls.ts +15 -0
- package/src/drivers/postgres.test.ts +9 -0
- package/src/drivers/postgres.ts +49 -11
- package/src/drivers/signal-compete.test.ts +234 -0
- package/src/drivers/signal-postgres.ts +39 -19
- package/src/drivers/signal-redis.ts +167 -11
- package/src/drivers/signal-types.ts +12 -0
- package/src/elements/ai/agent-event-slot.ts +27 -0
- package/src/elements/ai/approval-http.ts +207 -0
- package/src/elements/ai/approval.test.ts +741 -0
- package/src/elements/ai/approval.ts +228 -0
- package/src/elements/ai/decisions/bind.ts +163 -0
- package/src/elements/ai/decisions/certificate.test.ts +291 -0
- package/src/elements/ai/decisions/certificate.ts +377 -0
- package/src/elements/ai/decisions/certify.ts +314 -0
- package/src/elements/ai/decisions/decide.test.ts +953 -0
- package/src/elements/ai/decisions/e2e.test.ts +399 -0
- package/src/elements/ai/decisions/export.test.ts +120 -0
- package/src/elements/ai/decisions/export.ts +146 -0
- package/src/elements/ai/decisions/fixtures/openrouter-request.json +24 -0
- package/src/elements/ai/decisions/fixtures/openrouter-response.json +22 -0
- package/src/elements/ai/decisions/fixtures/typesafe-request.json +19 -0
- package/src/elements/ai/decisions/fixtures/typesafe-response.json +13 -0
- package/src/elements/ai/decisions/http.test.ts +154 -0
- package/src/elements/ai/decisions/http.ts +240 -0
- package/src/elements/ai/decisions/labels.test.ts +282 -0
- package/src/elements/ai/decisions/labels.ts +257 -0
- package/src/elements/ai/decisions/openrouter.live.test.ts +136 -0
- package/src/elements/ai/decisions/openrouter.ts +36 -0
- package/src/elements/ai/decisions/provider.ts +102 -0
- package/src/elements/ai/decisions/typesafe.ts +36 -0
- package/src/elements/ai/declare.ts +252 -1
- package/src/elements/ai/events.test.ts +379 -0
- package/src/elements/ai/events.ts +138 -0
- package/src/elements/ai/mcp-protocol.ts +5 -6
- package/src/elements/ai/run-events.test.ts +461 -0
- package/src/elements/ai/run-events.ts +522 -0
- package/src/elements/ai/runtime.ts +1187 -105
- package/src/elements/ai/stream-turn.ts +214 -0
- package/src/elements/ai/stream.live.test.ts +76 -0
- package/src/elements/ai/subagent.test.ts +136 -0
- package/src/elements/ai.test.ts +497 -3
- package/src/elements/ai.ts +16 -1
- package/src/elements/channel/runtime.ts +11 -0
- package/src/elements/channel/sql-ledger.ts +251 -0
- package/src/elements/clock/durable.ts +27 -1
- package/src/elements/signal/runtime.ts +9 -0
- package/src/elements/signal.test.ts +1 -0
- package/src/elements/store/cache.test.ts +120 -15
- package/src/elements/store/cache.ts +200 -39
- package/src/elements/store/prepare-row.test.ts +6 -2
- package/src/elements/store/sql-session.test.ts +53 -0
- package/src/elements/store/sql-session.ts +206 -10
- package/src/elements/store.ts +1 -0
- package/src/i18n/catalogs/ar.ts +5 -0
- package/src/i18n/catalogs/en.ts +5 -0
- package/src/index.ts +2 -0
- package/src/kernel/agent-event-store.ts +448 -0
- package/src/kernel/app.ts +135 -17
- package/src/kernel/auto-cache.test.ts +228 -0
- package/src/kernel/boot-bind/ai.ts +3 -0
- package/src/kernel/boot-bind/channel.ts +63 -2
- package/src/kernel/boot-bind/honor-config.test.ts +40 -10
- package/src/kernel/boot-bind/signal.ts +61 -25
- package/src/kernel/boot-bind/store.test.ts +26 -0
- package/src/kernel/boot-bind/store.ts +82 -6
- package/src/kernel/boot.ts +31 -6
- package/src/kernel/builtin-errors.ts +2 -0
- package/src/kernel/capability.ts +3 -0
- package/src/kernel/decision-label-store.ts +441 -0
- package/src/kernel/effects.test.ts +2 -1
- package/src/kernel/effects.ts +13 -1
- package/src/kernel/element-registries.ts +3 -0
- package/src/kernel/errors-compiler.ts +11 -0
- package/src/kernel/errors-text.ts +20 -0
- package/src/kernel/errors.ts +10 -0
- package/src/kernel/flow.test.ts +2 -3
- package/src/kernel/flow.ts +33 -3
- package/src/kernel/fx-call-types.test.ts +32 -0
- package/src/kernel/fx-decide.ts +765 -0
- package/src/kernel/fx-sql-handle.ts +46 -7
- package/src/kernel/fx.test.ts +13 -8
- package/src/kernel/fx.ts +339 -45
- package/src/kernel/http-frame.test.ts +94 -0
- package/src/kernel/http-frame.ts +203 -0
- package/src/kernel/idempotency.test.ts +2 -1
- package/src/kernel/journal.test.ts +114 -0
- package/src/kernel/journal.ts +471 -51
- package/src/kernel/json-result.ts +10 -0
- package/src/kernel/pipeline-tenant.ts +5 -1
- package/src/kernel/pipeline.test.ts +45 -0
- package/src/kernel/pipeline.ts +5 -0
- package/src/kernel/signal-tx.ts +28 -0
- package/src/kernel/sse-id.ts +32 -0
- package/src/kernel/store-transaction.test.ts +95 -0
- package/src/manifest/diff.ts +1 -0
- package/src/manifest/types.ts +29 -2
- package/src/mcp/ai-tools.test.ts +202 -0
- package/src/mcp/authorization.ts +66 -0
- package/src/mcp/docs-server.ts +19 -2
- package/src/mcp/mcp.test.ts +83 -0
- package/src/mcp/protocol.ts +8 -2
- package/src/mcp/server.ts +23 -2
- package/src/mcp/session.ts +3 -0
- package/src/mcp/tools.ts +168 -0
- package/src/mcp/versions.ts +60 -0
- package/src/plugins/index.ts +1 -0
- package/src/plugins/mena.ts +56 -0
- package/src/release/http-graph.test.ts +2 -0
- package/src/release/http-graph.ts +5 -0
- package/src/release/measure.ts +39 -14
- package/src/runs/collect.ts +4 -1
- package/src/runs/parquet.test.ts +27 -1
- package/src/runs/parquet.ts +19 -0
- package/src/runs/types.ts +31 -0
- package/src/runtime/json-code-block.test.ts +106 -47
- package/src/runtime/json-code-block.ts +1209 -46
- package/src/term.test.ts +13 -0
- package/src/term.ts +81 -49
- package/src/console/ui-next/dist/assets/PlusSignIcon-CwG3nxfu.js +0 -1
- package/src/console/ui-next/dist/assets/access-page-Ze6ckjdd.js +0 -4
- package/src/console/ui-next/dist/assets/copy-inline-button-CWG5_ZOh.js +0 -1
- package/src/console/ui-next/dist/assets/detail-header-b_ZzIsmU.js +0 -1
- package/src/console/ui-next/dist/assets/dropdown-menu-nwXEO1Ac.js +0 -1
- package/src/console/ui-next/dist/assets/duration-tone-BbQ_8z50.js +0 -9
- package/src/console/ui-next/dist/assets/element-icons-CwyLpVXz.js +0 -1
- package/src/console/ui-next/dist/assets/explorer-empty-C8sSCqYT.js +0 -1
- package/src/console/ui-next/dist/assets/flows-page-D2E7BtKK.js +0 -1
- package/src/console/ui-next/dist/assets/index-VxoEz295.css +0 -2
- package/src/console/ui-next/dist/assets/index-vTuwmeQz.js +0 -58
- package/src/console/ui-next/dist/assets/observability-page-SJTSzHFJ.js +0 -4
- package/src/console/ui-next/dist/assets/request-meta-BjFT7DNl.js +0 -1
- package/src/console/ui-next/dist/assets/shortcut-keys-CUNegEV4.js +0 -1
- package/src/console/ui-next/dist/assets/store-page-Qj9ThRE-.js +0 -41
- package/src/console/ui-next/dist/assets/trace-detail-sheet-Cmitq3da.js +0 -2
- package/src/console/ui-next/dist/assets/tree-expand-toggle-CbDIB-7x.js +0 -55
- package/src/console/ui-next/dist/assets/units-page-_PuYqFty.js +0 -1
- package/src/console/ui-next/dist/assets/use-vault-list-Cs45Czkt.js +0 -1
- package/src/console/ui-next/dist/assets/vault-page-BOMr0go9.js +0 -2
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decision certificates. The lockfile is the only autonomy grant.
|
|
3
|
+
* The runtime can force review or abstain; it never raises a threshold.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
|
|
8
|
+
/** Temperature scaling for choice and score. */
|
|
9
|
+
export interface TemperatureCalibrator {
|
|
10
|
+
readonly kind: "temperature";
|
|
11
|
+
readonly t: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Platt scaling for a boolean `noul` probability. */
|
|
15
|
+
export interface PlattCalibrator {
|
|
16
|
+
readonly kind: "platt";
|
|
17
|
+
readonly a: number;
|
|
18
|
+
readonly b: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Beta scaling for a boolean `noul` probability. `c` is the scale. */
|
|
22
|
+
export interface BetaCalibrator {
|
|
23
|
+
readonly kind: "beta";
|
|
24
|
+
readonly a: number;
|
|
25
|
+
readonly b: number;
|
|
26
|
+
readonly c?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Calibrator stored on one certificate slice. */
|
|
30
|
+
export type DecisionCalibrator = TemperatureCalibrator | PlattCalibrator | BetaCalibrator;
|
|
31
|
+
|
|
32
|
+
/** One locale slice. Absent `locale` is the empty-string slice. */
|
|
33
|
+
export interface DecisionCertSlice {
|
|
34
|
+
readonly hash: string;
|
|
35
|
+
readonly calibrator: DecisionCalibrator;
|
|
36
|
+
readonly threshold: number;
|
|
37
|
+
readonly metrics?: Readonly<Record<string, number>>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One decision inside `oke-decisions.lock.json`. */
|
|
41
|
+
export interface DecisionLockEntry {
|
|
42
|
+
readonly model: string;
|
|
43
|
+
/** Epoch ms the certificate was written. Drift counts labels after this. */
|
|
44
|
+
readonly certifiedAt?: number;
|
|
45
|
+
readonly questions: Readonly<Record<string, Readonly<Record<string, DecisionCertSlice>>>>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** App lockfile. Lives next to that app's OKE config. */
|
|
49
|
+
export interface DecisionLockfile {
|
|
50
|
+
readonly decisions: Readonly<Record<string, DecisionLockEntry>>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Why a question is not auto. */
|
|
54
|
+
export type DecisionUncertainty =
|
|
55
|
+
| "low-confidence"
|
|
56
|
+
| "missing-lock"
|
|
57
|
+
| "stale-hash"
|
|
58
|
+
| "version-mismatch"
|
|
59
|
+
| "uncertified-locale"
|
|
60
|
+
| "drift"
|
|
61
|
+
| "outage";
|
|
62
|
+
|
|
63
|
+
/** Label row written when a review resolves. */
|
|
64
|
+
export interface DecisionLabel {
|
|
65
|
+
readonly decision: string;
|
|
66
|
+
readonly question: string;
|
|
67
|
+
readonly value: unknown;
|
|
68
|
+
readonly propensity: number;
|
|
69
|
+
readonly reviewer: string;
|
|
70
|
+
readonly locale?: string;
|
|
71
|
+
readonly tenant?: string;
|
|
72
|
+
readonly model?: string;
|
|
73
|
+
/** Calibrated confidence used by Learn-then-Test. */
|
|
74
|
+
readonly score?: number;
|
|
75
|
+
/** Raw provider distribution the candidate fits. */
|
|
76
|
+
readonly raw?: unknown;
|
|
77
|
+
/** 1 when the label disagrees with the model, else 0. */
|
|
78
|
+
readonly loss?: number;
|
|
79
|
+
/** Epoch ms the label was written. */
|
|
80
|
+
readonly at?: number;
|
|
81
|
+
/** Calling input, with secret and redacted fields already masked. */
|
|
82
|
+
readonly input?: unknown;
|
|
83
|
+
/** Review id this label belongs to. Rows from one review share it. */
|
|
84
|
+
readonly reviewId?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** App-wide candidate. A full lock entry, not a count. */
|
|
88
|
+
export interface DecisionCandidate {
|
|
89
|
+
readonly model: string;
|
|
90
|
+
readonly questions: DecisionLockEntry["questions"];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Lockfile name at the app project root. */
|
|
94
|
+
export const DECISION_LOCK_FILENAME = "oke-decisions.lock.json";
|
|
95
|
+
|
|
96
|
+
const labels: DecisionLabel[] = [];
|
|
97
|
+
let lockfile: DecisionLockfile | undefined;
|
|
98
|
+
const drifted = new Map<string, number>();
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Replace the in-memory lock. `undefined` is a missing lockfile.
|
|
102
|
+
*
|
|
103
|
+
* @param next - Lockfile, or none
|
|
104
|
+
*/
|
|
105
|
+
export function setDecisionLock(next: DecisionLockfile | undefined): void {
|
|
106
|
+
lockfile = next;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Current lockfile, if one was loaded or promoted.
|
|
111
|
+
*/
|
|
112
|
+
export function getDecisionLock(): DecisionLockfile | undefined {
|
|
113
|
+
return lockfile;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Set or clear drift for one decision.
|
|
118
|
+
*
|
|
119
|
+
* @param name - Decision name
|
|
120
|
+
* @param next - Suspend that decision when true
|
|
121
|
+
*/
|
|
122
|
+
export function setDecisionDrift(name: string, next: boolean): void {
|
|
123
|
+
if (next) drifted.set(name, drifted.get(name) ?? 0);
|
|
124
|
+
else drifted.delete(name);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Whether drift has suspended this decision.
|
|
129
|
+
*
|
|
130
|
+
* @param name - Decision name
|
|
131
|
+
*/
|
|
132
|
+
export function decisionDriftSuspended(name: string): boolean {
|
|
133
|
+
return drifted.has(name);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Decision names currently suspended.
|
|
138
|
+
*/
|
|
139
|
+
export function decisionDriftNames(): readonly string[] {
|
|
140
|
+
return [...drifted.keys()];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Labels collected by review resolution, including audit reviews.
|
|
145
|
+
*/
|
|
146
|
+
export function decisionLabels(): readonly DecisionLabel[] {
|
|
147
|
+
return labels;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Drop lock, labels, and drift. Tests only.
|
|
152
|
+
*/
|
|
153
|
+
export function resetDecisionCertificates(): void {
|
|
154
|
+
labels.length = 0;
|
|
155
|
+
lockfile = undefined;
|
|
156
|
+
drifted.clear();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Stable hash of a question's instructions and options.
|
|
161
|
+
*
|
|
162
|
+
* @param question - Author question
|
|
163
|
+
*/
|
|
164
|
+
export function questionHash(question: {
|
|
165
|
+
readonly instructions: string;
|
|
166
|
+
readonly options?: Readonly<Record<string, string | null>>;
|
|
167
|
+
readonly levels?: readonly string[];
|
|
168
|
+
}): string {
|
|
169
|
+
const body = JSON.stringify({
|
|
170
|
+
instructions: question.instructions,
|
|
171
|
+
options: question.options ?? null,
|
|
172
|
+
levels: question.levels ?? null,
|
|
173
|
+
});
|
|
174
|
+
return createHash("sha256").update(body).digest("hex");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Temperature-scale a distribution. `t = 1` leaves it unchanged.
|
|
179
|
+
*
|
|
180
|
+
* @param probs - Probabilities that sum to about 1
|
|
181
|
+
* @param t - Temperature
|
|
182
|
+
*/
|
|
183
|
+
export function applyTemperature(probs: readonly number[], t: number): number[] {
|
|
184
|
+
const temp = t > 0 ? t : 1;
|
|
185
|
+
const scaled = probs.map((p) => Math.pow(Math.max(p, 1e-12), 1 / temp));
|
|
186
|
+
const z = scaled.reduce((sum, p) => sum + p, 0);
|
|
187
|
+
return scaled.map((p) => p / z);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Calibrated P(true). Confidence is `max(p, 1 - p)` at the call site.
|
|
192
|
+
*
|
|
193
|
+
* @param noul - Provider probability of the true class
|
|
194
|
+
* @param calibrator - Platt or beta
|
|
195
|
+
*/
|
|
196
|
+
export function calibrateBoolean(
|
|
197
|
+
noul: number,
|
|
198
|
+
calibrator: PlattCalibrator | BetaCalibrator,
|
|
199
|
+
): number {
|
|
200
|
+
const p = Math.min(1, Math.max(0, noul));
|
|
201
|
+
if (calibrator.kind === "platt") {
|
|
202
|
+
const logit = Math.log(Math.max(p, 1e-12) / Math.max(1 - p, 1e-12));
|
|
203
|
+
const z = calibrator.a * logit + calibrator.b;
|
|
204
|
+
return 1 / (1 + Math.exp(-z));
|
|
205
|
+
}
|
|
206
|
+
const a = calibrator.a;
|
|
207
|
+
const b = calibrator.b;
|
|
208
|
+
const c = calibrator.c ?? 0;
|
|
209
|
+
const ratio = Math.pow(Math.max(1 - p, 1e-12), b) / Math.pow(Math.max(p, 1e-12), a);
|
|
210
|
+
return 1 / (1 + Math.exp(-c) * ratio);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Fixed Learn-then-Test grid: 0.50 through 0.99, step 0.01. */
|
|
214
|
+
export const LEARN_THEN_TEST_GRID: readonly number[] = Array.from(
|
|
215
|
+
{ length: 50 },
|
|
216
|
+
(_, i) => (50 + i) / 100,
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* One-sided exact binomial cdf, `P(X ≤ k)` for `X ~ Binomial(n, p)`.
|
|
221
|
+
* `k < 0` is 0. The sum is in log space.
|
|
222
|
+
*
|
|
223
|
+
* @param k - Observed errors
|
|
224
|
+
* @param n - Accepted labels
|
|
225
|
+
* @param p - Boundary error rate
|
|
226
|
+
*/
|
|
227
|
+
export function binomialCdf(k: number, n: number, p: number): number {
|
|
228
|
+
if (k < 0) return 0;
|
|
229
|
+
if (n <= 0) return 1;
|
|
230
|
+
if (p <= 0) return 1;
|
|
231
|
+
if (p >= 1) return k >= n ? 1 : 0;
|
|
232
|
+
if (k >= n) return 1;
|
|
233
|
+
const last = Math.floor(k);
|
|
234
|
+
let logTerm = n * Math.log(1 - p);
|
|
235
|
+
let maxLog = logTerm;
|
|
236
|
+
const logs = [logTerm];
|
|
237
|
+
for (let i = 0; i < last; i++) {
|
|
238
|
+
logTerm += Math.log(n - i) - Math.log(i + 1) + Math.log(p) - Math.log(1 - p);
|
|
239
|
+
logs.push(logTerm);
|
|
240
|
+
if (logTerm > maxLog) maxLog = logTerm;
|
|
241
|
+
}
|
|
242
|
+
let sum = 0;
|
|
243
|
+
for (const logP of logs) sum += Math.exp(logP - maxLog);
|
|
244
|
+
const total = Math.exp(maxLog) * sum;
|
|
245
|
+
return Math.min(1, Number.isFinite(total) ? total : 0);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Learn-then-Test on a fixed threshold grid. A threshold passes only when
|
|
250
|
+
* the error count is low under Binomial(n, maxError): `binomialCdf(errors, n, maxError) ≤ δ/m`
|
|
251
|
+
* (Bonferroni). The loosest passing threshold wins. A grid with no pass returns null.
|
|
252
|
+
*
|
|
253
|
+
* @param rows - Score and 0/1 loss
|
|
254
|
+
* @param maxError - Risk cap
|
|
255
|
+
* @param delta - Family-wise level. Default `0.1` (`autonomy.risk`)
|
|
256
|
+
*/
|
|
257
|
+
export function learnThenTest(
|
|
258
|
+
rows: readonly { readonly score: number; readonly loss: number }[],
|
|
259
|
+
maxError: number,
|
|
260
|
+
delta = 0.1,
|
|
261
|
+
): number | null {
|
|
262
|
+
if (rows.length === 0) return null;
|
|
263
|
+
const m = LEARN_THEN_TEST_GRID.length;
|
|
264
|
+
const alpha = delta / m;
|
|
265
|
+
let chosen: number | null = null;
|
|
266
|
+
for (const threshold of LEARN_THEN_TEST_GRID) {
|
|
267
|
+
const accepted = rows.filter((row) => row.score >= threshold);
|
|
268
|
+
if (accepted.length === 0) continue;
|
|
269
|
+
const errors = accepted.reduce((sum, row) => sum + (row.loss > 0 ? 1 : 0), 0);
|
|
270
|
+
const pValue = binomialCdf(errors, accepted.length, maxError);
|
|
271
|
+
if (pValue > alpha) continue;
|
|
272
|
+
if (chosen === null || threshold < chosen) chosen = threshold;
|
|
273
|
+
}
|
|
274
|
+
return chosen;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Record a label. The reviewer Flow owns this write.
|
|
279
|
+
*
|
|
280
|
+
* @param label - Resolved label
|
|
281
|
+
*/
|
|
282
|
+
export function recordDecisionLabel(label: DecisionLabel): void {
|
|
283
|
+
labels.push(label);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Build one lock entry from stored labels. The same fit and test as certify.
|
|
288
|
+
*
|
|
289
|
+
* @param decision - Decision name
|
|
290
|
+
* @param fit - Turns that decision's labels into a lock entry
|
|
291
|
+
*/
|
|
292
|
+
export function aggregateDecisionCandidate(
|
|
293
|
+
decision: string,
|
|
294
|
+
fit: (rows: readonly DecisionLabel[]) => DecisionCandidate = () => ({
|
|
295
|
+
model: "",
|
|
296
|
+
questions: {},
|
|
297
|
+
}),
|
|
298
|
+
): DecisionCandidate {
|
|
299
|
+
const rows = labels.filter((label) => label.decision === decision);
|
|
300
|
+
return fit(rows);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Write a fetched candidate into the lockfile slot. Does not recompute it.
|
|
305
|
+
*
|
|
306
|
+
* @param name - Decision name
|
|
307
|
+
* @param candidate - Body returned by the admin endpoint
|
|
308
|
+
* @param current - Lockfile to merge into
|
|
309
|
+
*/
|
|
310
|
+
export function lockFromCandidate(
|
|
311
|
+
name: string,
|
|
312
|
+
candidate: unknown,
|
|
313
|
+
current: DecisionLockfile | undefined,
|
|
314
|
+
): DecisionLockfile {
|
|
315
|
+
const entry = parseDecisionLockEntry(candidate);
|
|
316
|
+
if (!entry) throw new TypeError("promote: candidate is not a lock entry");
|
|
317
|
+
setDecisionDrift(name, false);
|
|
318
|
+
return { decisions: { ...(current?.decisions ?? {}), [name]: entry } };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Accept a lock entry with a model and a questions map.
|
|
323
|
+
*
|
|
324
|
+
* @param body - Candidate or file slot
|
|
325
|
+
*/
|
|
326
|
+
export function parseDecisionLockEntry(body: unknown): DecisionLockEntry | undefined {
|
|
327
|
+
if (!body || typeof body !== "object") return undefined;
|
|
328
|
+
const record = body as Record<string, unknown>;
|
|
329
|
+
if (typeof record.model !== "string") return undefined;
|
|
330
|
+
if (
|
|
331
|
+
!record.questions ||
|
|
332
|
+
typeof record.questions !== "object" ||
|
|
333
|
+
Array.isArray(record.questions)
|
|
334
|
+
) {
|
|
335
|
+
return undefined;
|
|
336
|
+
}
|
|
337
|
+
return {
|
|
338
|
+
model: record.model,
|
|
339
|
+
...(typeof record.certifiedAt === "number" ? { certifiedAt: record.certifiedAt } : {}),
|
|
340
|
+
questions: record.questions as DecisionLockEntry["questions"],
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Accept a lockfile object.
|
|
346
|
+
*
|
|
347
|
+
* @param body - Parsed JSON
|
|
348
|
+
*/
|
|
349
|
+
export function parseDecisionLockfile(body: unknown): DecisionLockfile | undefined {
|
|
350
|
+
if (!body || typeof body !== "object") return undefined;
|
|
351
|
+
const decisions = (body as { decisions?: unknown }).decisions;
|
|
352
|
+
if (!decisions || typeof decisions !== "object" || Array.isArray(decisions)) return undefined;
|
|
353
|
+
const next: Record<string, DecisionLockEntry> = {};
|
|
354
|
+
for (const [name, entry] of Object.entries(decisions)) {
|
|
355
|
+
const parsed = parseDecisionLockEntry(entry);
|
|
356
|
+
if (!parsed) return undefined;
|
|
357
|
+
next[name] = parsed;
|
|
358
|
+
}
|
|
359
|
+
return { decisions: next };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Read `oke-decisions.lock.json` from an app root and install it.
|
|
364
|
+
* A missing file clears the in-memory lock.
|
|
365
|
+
*
|
|
366
|
+
* @param root - Directory that holds the app config
|
|
367
|
+
*/
|
|
368
|
+
export async function loadDecisionLockfile(root: string): Promise<DecisionLockfile | undefined> {
|
|
369
|
+
const file = Bun.file(`${root}/${DECISION_LOCK_FILENAME}`);
|
|
370
|
+
if (!(await file.exists())) {
|
|
371
|
+
setDecisionLock(undefined);
|
|
372
|
+
return undefined;
|
|
373
|
+
}
|
|
374
|
+
const parsed = parseDecisionLockfile(await file.json());
|
|
375
|
+
setDecisionLock(parsed);
|
|
376
|
+
return parsed;
|
|
377
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a decision certificate from a seed JSONL file.
|
|
3
|
+
* `{ id?, input, expect: { <question>: value }, locale? }`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { AiDecisionQuestion } from "../declare.ts";
|
|
7
|
+
import {
|
|
8
|
+
applyTemperature,
|
|
9
|
+
learnThenTest,
|
|
10
|
+
questionHash,
|
|
11
|
+
type DecisionCalibrator,
|
|
12
|
+
type DecisionCertSlice,
|
|
13
|
+
type DecisionLabel,
|
|
14
|
+
type DecisionLockEntry,
|
|
15
|
+
} from "./certificate.ts";
|
|
16
|
+
import type { DecisionResponse } from "./provider.ts";
|
|
17
|
+
|
|
18
|
+
/** One seed row. */
|
|
19
|
+
export interface DecisionSeedCase {
|
|
20
|
+
readonly id?: string;
|
|
21
|
+
readonly input: unknown;
|
|
22
|
+
readonly expect: Readonly<Record<string, unknown>>;
|
|
23
|
+
readonly locale?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** One provider call used while certifying. */
|
|
27
|
+
export type DecisionEvaluate = (input: unknown) => Promise<DecisionResponse>;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parse decision seed JSONL. Blank lines are skipped.
|
|
31
|
+
*
|
|
32
|
+
* @param text - File contents
|
|
33
|
+
*/
|
|
34
|
+
export function parseDecisionSeed(text: string): DecisionSeedCase[] {
|
|
35
|
+
const cases: DecisionSeedCase[] = [];
|
|
36
|
+
for (const line of text.split("\n")) {
|
|
37
|
+
const trimmed = line.trim();
|
|
38
|
+
if (!trimmed) continue;
|
|
39
|
+
const row = JSON.parse(trimmed) as {
|
|
40
|
+
id?: unknown;
|
|
41
|
+
input?: unknown;
|
|
42
|
+
expect?: unknown;
|
|
43
|
+
locale?: unknown;
|
|
44
|
+
};
|
|
45
|
+
if (!row.expect || typeof row.expect !== "object" || Array.isArray(row.expect)) {
|
|
46
|
+
throw new TypeError("decision seed row needs expect");
|
|
47
|
+
}
|
|
48
|
+
cases.push({
|
|
49
|
+
...(typeof row.id === "string" ? { id: row.id } : {}),
|
|
50
|
+
input: row.input,
|
|
51
|
+
expect: row.expect as Readonly<Record<string, unknown>>,
|
|
52
|
+
...(typeof row.locale === "string" ? { locale: row.locale } : {}),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return cases;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One labeled probability used to fit a slice. */
|
|
59
|
+
interface FitRow {
|
|
60
|
+
readonly probs: readonly number[];
|
|
61
|
+
readonly labelIndex: number;
|
|
62
|
+
readonly noul?: number;
|
|
63
|
+
readonly booleanLabel?: boolean;
|
|
64
|
+
/** Inverse propensity. Audit rows count more than a full review. */
|
|
65
|
+
readonly weight: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Certificate from a seed file. Calls the provider, fits a calibrator per
|
|
70
|
+
* question, and keeps a threshold only when Learn-then-Test passes.
|
|
71
|
+
*
|
|
72
|
+
* @param options - Decision, risk, seed, and provider
|
|
73
|
+
*/
|
|
74
|
+
export async function certifySeed(options: {
|
|
75
|
+
readonly model: string;
|
|
76
|
+
readonly maxError: number;
|
|
77
|
+
readonly delta?: number;
|
|
78
|
+
readonly jsonl: string;
|
|
79
|
+
readonly ask: Readonly<Record<string, AiDecisionQuestion>>;
|
|
80
|
+
readonly evaluate: DecisionEvaluate;
|
|
81
|
+
}): Promise<DecisionLockEntry> {
|
|
82
|
+
const cases = parseDecisionSeed(options.jsonl);
|
|
83
|
+
const buckets = new Map<string, FitRow[]>();
|
|
84
|
+
let resolved = options.model;
|
|
85
|
+
for (const row of cases) {
|
|
86
|
+
const response = await options.evaluate(row.input);
|
|
87
|
+
if (response.model) resolved = response.model;
|
|
88
|
+
const locale = row.locale ?? "";
|
|
89
|
+
for (const [id, expected] of Object.entries(row.expect)) {
|
|
90
|
+
const question = options.ask[id];
|
|
91
|
+
if (!question) continue;
|
|
92
|
+
const answer = response.answers[id];
|
|
93
|
+
const fit = fitRow(question, answer, expected);
|
|
94
|
+
if (!fit) continue;
|
|
95
|
+
const key = `${id}\0${locale}`;
|
|
96
|
+
const list = buckets.get(key) ?? [];
|
|
97
|
+
list.push(fit);
|
|
98
|
+
buckets.set(key, list);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const entry = entryFromBuckets(
|
|
102
|
+
resolved,
|
|
103
|
+
options.ask,
|
|
104
|
+
buckets,
|
|
105
|
+
options.maxError,
|
|
106
|
+
options.delta ?? 0.1,
|
|
107
|
+
);
|
|
108
|
+
return { ...entry, certifiedAt: Date.now() };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The same certificate, built from persisted labels that already carry a score.
|
|
113
|
+
*
|
|
114
|
+
* @param options - Model, questions, labels, and risk
|
|
115
|
+
*/
|
|
116
|
+
export function certifyLabels(options: {
|
|
117
|
+
readonly model: string;
|
|
118
|
+
readonly maxError: number;
|
|
119
|
+
readonly delta?: number;
|
|
120
|
+
readonly ask: Readonly<Record<string, AiDecisionQuestion>>;
|
|
121
|
+
readonly labels: readonly DecisionLabel[];
|
|
122
|
+
}): DecisionLockEntry {
|
|
123
|
+
const groups = new Map<string, DecisionLabel[]>();
|
|
124
|
+
for (const label of options.labels) {
|
|
125
|
+
if (label.raw === undefined) continue;
|
|
126
|
+
if (options.model && label.model !== options.model) continue;
|
|
127
|
+
const key = `${label.question}\0${label.locale ?? ""}`;
|
|
128
|
+
const list = groups.get(key) ?? [];
|
|
129
|
+
list.push(label);
|
|
130
|
+
groups.set(key, list);
|
|
131
|
+
}
|
|
132
|
+
const questions: Record<string, Record<string, DecisionCertSlice>> = {};
|
|
133
|
+
const model = options.labels.find((label) => label.model)?.model ?? options.model;
|
|
134
|
+
const delta = options.delta ?? 0.1;
|
|
135
|
+
for (const [key, rows] of groups) {
|
|
136
|
+
const split = key.indexOf("\0");
|
|
137
|
+
const id = key.slice(0, split);
|
|
138
|
+
const locale = key.slice(split + 1);
|
|
139
|
+
const question = options.ask[id];
|
|
140
|
+
if (!question) continue;
|
|
141
|
+
const fitted = rows.flatMap((row) => {
|
|
142
|
+
const fit = fitRow(question, row.raw, row.value);
|
|
143
|
+
return fit ? [{ ...fit, weight: propensityWeight(row.propensity) }] : [];
|
|
144
|
+
});
|
|
145
|
+
const slice = sliceFromRows(question, fitted, options.maxError, delta);
|
|
146
|
+
if (!slice) continue;
|
|
147
|
+
const bucket = questions[id] ?? {};
|
|
148
|
+
bucket[locale] = slice;
|
|
149
|
+
questions[id] = bucket;
|
|
150
|
+
}
|
|
151
|
+
return { model, certifiedAt: Date.now(), questions };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function entryFromBuckets(
|
|
155
|
+
model: string,
|
|
156
|
+
ask: Readonly<Record<string, AiDecisionQuestion>>,
|
|
157
|
+
buckets: ReadonlyMap<string, readonly FitRow[]>,
|
|
158
|
+
maxError: number,
|
|
159
|
+
delta: number,
|
|
160
|
+
): DecisionLockEntry {
|
|
161
|
+
const questions: Record<string, Record<string, DecisionCertSlice>> = {};
|
|
162
|
+
for (const [key, rows] of buckets) {
|
|
163
|
+
const split = key.indexOf("\0");
|
|
164
|
+
const id = key.slice(0, split);
|
|
165
|
+
const locale = key.slice(split + 1);
|
|
166
|
+
const question = ask[id];
|
|
167
|
+
if (!question) continue;
|
|
168
|
+
const slice = sliceFromRows(question, rows, maxError, delta);
|
|
169
|
+
if (!slice) continue;
|
|
170
|
+
const bucket = questions[id] ?? {};
|
|
171
|
+
bucket[locale] = slice;
|
|
172
|
+
questions[id] = bucket;
|
|
173
|
+
}
|
|
174
|
+
return { model, questions };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function sliceFromRows(
|
|
178
|
+
question: AiDecisionQuestion,
|
|
179
|
+
rows: readonly FitRow[],
|
|
180
|
+
maxError: number,
|
|
181
|
+
delta: number,
|
|
182
|
+
): DecisionCertSlice | undefined {
|
|
183
|
+
const calibrator = fitCalibrator(question, rows);
|
|
184
|
+
const scored = rows.map((row) => scoreFit(question, row, calibrator));
|
|
185
|
+
const threshold = learnThenTest(scored, maxError, delta);
|
|
186
|
+
if (threshold === null) return undefined;
|
|
187
|
+
const errors = scored.filter((row) => row.score >= threshold && row.loss > 0).length;
|
|
188
|
+
const accepted = scored.filter((row) => row.score >= threshold).length;
|
|
189
|
+
return {
|
|
190
|
+
hash: questionHash(question),
|
|
191
|
+
calibrator,
|
|
192
|
+
threshold,
|
|
193
|
+
metrics: { labels: rows.length, accepted, errors, maxError, delta },
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function fitCalibrator(question: AiDecisionQuestion, rows: readonly FitRow[]): DecisionCalibrator {
|
|
198
|
+
if (question.kind === "boolean") return fitPlatt(rows);
|
|
199
|
+
return { kind: "temperature", t: fitTemperature(rows) };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function fitTemperature(rows: readonly FitRow[]): number {
|
|
203
|
+
let bestT = 1;
|
|
204
|
+
let best = Number.POSITIVE_INFINITY;
|
|
205
|
+
for (let step = 1; step <= 20; step++) {
|
|
206
|
+
const t = step / 10;
|
|
207
|
+
let nll = 0;
|
|
208
|
+
for (const row of rows) {
|
|
209
|
+
const scaled = applyTemperature(row.probs, t);
|
|
210
|
+
nll -= row.weight * Math.log(Math.max(scaled[row.labelIndex] ?? 1e-12, 1e-12));
|
|
211
|
+
}
|
|
212
|
+
if (nll < best) {
|
|
213
|
+
best = nll;
|
|
214
|
+
bestT = t;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return bestT;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function fitPlatt(rows: readonly FitRow[]): DecisionCalibrator {
|
|
221
|
+
let best = { a: 1, b: 0, nll: Number.POSITIVE_INFINITY };
|
|
222
|
+
for (let a = -2; a <= 2.01; a += 0.5) {
|
|
223
|
+
for (let b = -2; b <= 2.01; b += 0.5) {
|
|
224
|
+
let nll = 0;
|
|
225
|
+
for (const row of rows) {
|
|
226
|
+
const p = platt(row.noul ?? 0.5, a, b);
|
|
227
|
+
const y = row.booleanLabel ? 1 : 0;
|
|
228
|
+
nll -=
|
|
229
|
+
row.weight *
|
|
230
|
+
(y * Math.log(Math.max(p, 1e-12)) + (1 - y) * Math.log(Math.max(1 - p, 1e-12)));
|
|
231
|
+
}
|
|
232
|
+
if (nll < best.nll) best = { a, b, nll };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return { kind: "platt", a: best.a, b: best.b };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function platt(noul: number, a: number, b: number): number {
|
|
239
|
+
const p = Math.min(1, Math.max(0, noul));
|
|
240
|
+
const logit = Math.log(Math.max(p, 1e-12) / Math.max(1 - p, 1e-12));
|
|
241
|
+
return 1 / (1 + Math.exp(-(a * logit + b)));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function scoreFit(
|
|
245
|
+
question: AiDecisionQuestion,
|
|
246
|
+
row: FitRow,
|
|
247
|
+
calibrator: DecisionCalibrator,
|
|
248
|
+
): { readonly score: number; readonly loss: number } {
|
|
249
|
+
if (question.kind === "boolean" && calibrator.kind !== "temperature") {
|
|
250
|
+
const p = platt(
|
|
251
|
+
row.noul ?? 0.5,
|
|
252
|
+
calibrator.kind === "platt" ? calibrator.a : 1,
|
|
253
|
+
calibrator.kind === "platt" ? calibrator.b : 0,
|
|
254
|
+
);
|
|
255
|
+
const predicted = p >= 0.5;
|
|
256
|
+
return { score: Math.max(p, 1 - p), loss: predicted === row.booleanLabel ? 0 : 1 };
|
|
257
|
+
}
|
|
258
|
+
const t = calibrator.kind === "temperature" ? calibrator.t : 1;
|
|
259
|
+
const scaled = applyTemperature(row.probs, t);
|
|
260
|
+
let best = 0;
|
|
261
|
+
for (let i = 1; i < scaled.length; i++) {
|
|
262
|
+
if ((scaled[i] ?? 0) > (scaled[best] ?? 0)) best = i;
|
|
263
|
+
}
|
|
264
|
+
return { score: scaled[best] ?? 0, loss: best === row.labelIndex ? 0 : 1 };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Provider answers wrap the distribution in `probabilities`.
|
|
269
|
+
* A stored label writes that same distribution as `raw`.
|
|
270
|
+
*
|
|
271
|
+
* @param record - Answer or stored raw
|
|
272
|
+
*/
|
|
273
|
+
function storedDistribution(record: Record<string, unknown>): unknown {
|
|
274
|
+
if (record.probabilities !== undefined) return record.probabilities;
|
|
275
|
+
return record;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function fitRow(
|
|
279
|
+
question: AiDecisionQuestion,
|
|
280
|
+
answer: unknown,
|
|
281
|
+
expected: unknown,
|
|
282
|
+
): FitRow | undefined {
|
|
283
|
+
const record = answer && typeof answer === "object" ? (answer as Record<string, unknown>) : {};
|
|
284
|
+
if (question.kind === "boolean") {
|
|
285
|
+
if (typeof expected !== "boolean") return undefined;
|
|
286
|
+
return {
|
|
287
|
+
probs: [],
|
|
288
|
+
labelIndex: expected ? 1 : 0,
|
|
289
|
+
noul: typeof record.noul === "number" ? record.noul : 0,
|
|
290
|
+
booleanLabel: expected,
|
|
291
|
+
weight: 1,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
const keys =
|
|
295
|
+
question.kind === "choice"
|
|
296
|
+
? [...Object.keys(question.options), "none_of_these"]
|
|
297
|
+
: [...question.levels];
|
|
298
|
+
const labelIndex = keys.indexOf(String(expected));
|
|
299
|
+
if (labelIndex < 0) return undefined;
|
|
300
|
+
const raw = storedDistribution(record);
|
|
301
|
+
const probs = keys.map((key, index) => {
|
|
302
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
303
|
+
const value = (raw as Record<string, unknown>)[key];
|
|
304
|
+
return typeof value === "number" ? value : 0;
|
|
305
|
+
}
|
|
306
|
+
return Array.isArray(raw) && typeof raw[index] === "number" ? raw[index] : 0;
|
|
307
|
+
});
|
|
308
|
+
return { probs, labelIndex, weight: 1 };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function propensityWeight(propensity: number): number {
|
|
312
|
+
if (!(propensity > 0)) return 1;
|
|
313
|
+
return 1 / propensity;
|
|
314
|
+
}
|