UNPKG

pi-decider

Version:

Decision backends for pi and omp — TypeSafe Jev, OpenRouter's Decisions API, or an OpenAI-compatible chat proxy — exposed as one typed tool (noul / choice / score)

136 lines (125 loc) • 4.93 kB
/** * The documented TypeSafe usage shapes, made first-class for the interactive command surface. * * Primitives (question types) are noul / choice / score. On top of them the docs describe four * patterns, and the interesting part of each is what *code* does with the answers: * * single one question * fanout many independent questions in one request (speculative fan-out) * gate ask, then route on the probability/confidence threshold (confidence-gated routing) * composite several score questions combined with weights (composite scoring) * * `intent routing` needs no extra machinery here: it is a `choice` question whose answer selects a * handler in code, which the single shape already returns. */ import { JevError } from "./errors.ts"; import type { JevAnswer } from "./questions.ts"; export type ShapeKind = "single" | "fanout" | "gate" | "composite"; export interface ShapeSpec { kind: ShapeKind; label: string; description: string; /** Questions the shape makes sense with. */ minQuestions: number; needsWeights: boolean; needsThreshold: boolean; } export const SHAPES: Record<ShapeKind, ShapeSpec> = { single: { kind: "single", label: "single — one typed question (noul / choice / score)", description: "One judgment, returned with probabilities.", minQuestions: 1, needsWeights: false, needsThreshold: false, }, fanout: { kind: "fanout", label: "fanout — many independent questions in one request", description: "Speculative fan-out: every question sees the same state and they run in parallel in a single call.", minQuestions: 2, needsWeights: false, needsThreshold: false, }, gate: { kind: "gate", label: "gate — route on a probability/confidence threshold", description: "Confidence-gated routing: noul answers gate on the probability, choice/score on confidence.", minQuestions: 1, needsWeights: false, needsThreshold: true, }, composite: { kind: "composite", label: "composite — weighted score across several score questions", description: "Composite scoring: each score answer is normalized over its levels, then combined by weight in code.", minQuestions: 2, needsWeights: true, needsThreshold: false, }, }; export interface GateRow { id: string; /** noul probability, or confidence for choice/score; undefined when the answer reported none. */ value: number | undefined; metric: "noul" | "confidence"; pass: boolean; } /** Which number gates the decision: a noul's probability, otherwise the answer's confidence. */ export function evaluateGate(ids: string[], answers: Record<string, JevAnswer>, threshold: number): GateRow[] { return ids.map((id) => { const answer = answers[id]; if (answer === undefined) return { id, value: undefined, metric: "confidence", pass: false }; const value = answer.type === "noul" ? answer.noul : answer.confidence; return { id, value, metric: answer.type === "noul" ? "noul" : "confidence", pass: value !== undefined && value >= threshold, }; }); } export interface CompositeRow { id: string; /** `score` normalized to 0..1 across its own levels. */ normalized: number; weight: number; contribution: number; } export interface CompositeResult { rows: CompositeRow[]; total: number; totalWeight: number; } /** * Weighted mean of normalized scores. Questions that are not `score` answers, or that failed * conformance, are skipped and reported by the caller. */ export function compositeScore(ids: string[], answers: Record<string, JevAnswer>, weights: number[]): CompositeResult { const rows: CompositeRow[] = []; ids.forEach((id, index) => { const answer = answers[id]; if (answer === undefined || answer.type !== "score") return; const levels = Object.keys(answer.legend).length; const normalized = levels <= 1 ? 0 : answer.score / (levels - 1); const weight = weights[index] ?? 1; rows.push({ id, normalized, weight, contribution: normalized * weight }); }); const totalWeight = rows.reduce((sum, row) => sum + row.weight, 0); const total = totalWeight === 0 ? 0 : rows.reduce((sum, row) => sum + row.contribution, 0) / totalWeight; return { rows, total, totalWeight }; } /** Parse `1,2,0.5` into weights (missing entries default to 1). */ export function parseWeights(raw: string, count: number): number[] { const parts = raw .split(/[,\s]+/) .map((part) => part.trim()) .filter((part) => part !== ""); if (parts.length === 0) return Array.from({ length: count }, () => 1); const weights = parts.map((part) => Number(part)); if (weights.some((value) => !Number.isFinite(value) || value < 0)) { throw new JevError(`Weights must be non-negative numbers, got "${raw}".`); } while (weights.length < count) weights.push(1); return weights.slice(0, count); }