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
text/typescript
/**
* 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);
}