@ejstembler/pi-classifier-router 1.0.0

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/src/router.ts ADDED
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Pure routing decision: config + classifier answers -> what model to apply.
3
+ *
4
+ * No I/O and no clock: the extension owns availability (circuit breaker),
5
+ * `dryRun`, and `setModel`. Every early exit returns a reportable decision
6
+ * (category/spec kept where known, `apply: null`) plus a single-line detail.
7
+ */
8
+
9
+ import type { Answer, Answers, Decision, DecisionReason, RouterConfig } from "./types.ts";
10
+
11
+ export interface DecideOptions {
12
+ isAvailable?: (spec: string) => boolean;
13
+ }
14
+
15
+ export function decide(config: RouterConfig, answers: Answers, options: DecideOptions = {}): Decision {
16
+ const routing = config.routing;
17
+
18
+ if (!config.enabled) {
19
+ return {
20
+ category: null,
21
+ confidence: 0,
22
+ spec: null,
23
+ chain: [],
24
+ apply: null,
25
+ reason: "disabled",
26
+ detail: "routing disabled (confidence 0.00); model unchanged",
27
+ extra: {},
28
+ };
29
+ }
30
+
31
+ if (Object.keys(routing.questions).length === 0) {
32
+ return {
33
+ category: null,
34
+ confidence: 0,
35
+ spec: null,
36
+ chain: [],
37
+ apply: null,
38
+ reason: "no-questions",
39
+ detail: "no routing questions configured (confidence 0.00); model unchanged",
40
+ extra: {},
41
+ };
42
+ }
43
+
44
+ const extra: Record<string, Answer> = {};
45
+ for (const [id, answer] of Object.entries(answers)) {
46
+ if (id !== routing.primaryQuestion) extra[id] = answer;
47
+ }
48
+
49
+ const primary = answers[routing.primaryQuestion];
50
+ if (primary === undefined || primary.type !== "choice") {
51
+ return {
52
+ category: null,
53
+ confidence: 0,
54
+ spec: null,
55
+ chain: [],
56
+ apply: null,
57
+ reason: "missing-answer",
58
+ detail: `no choice answer for question ${JSON.stringify(routing.primaryQuestion)} (confidence 0.00); model unchanged`,
59
+ extra,
60
+ };
61
+ }
62
+
63
+ // `choice` is the only answer type that names a model category.
64
+ const category = primary.choice;
65
+ const confidence = primary.confidence;
66
+ const isAvailable = options.isAvailable ?? (() => true);
67
+
68
+ if (confidence < routing.confidenceThreshold) {
69
+ const spec = routing.modelMapping[category] ?? null;
70
+ const chain = spec === null ? [] : (routing.fallbackChains[spec] ?? [spec]).filter((entry) => isAvailable(entry));
71
+ return {
72
+ category,
73
+ confidence,
74
+ spec,
75
+ chain,
76
+ apply: null,
77
+ reason: "low-confidence",
78
+ detail:
79
+ `category ${JSON.stringify(category)} (confidence ${confidence.toFixed(2)}) is below threshold ` +
80
+ `${routing.confidenceThreshold.toFixed(2)}; model unchanged${spec === null ? "" : `; preferred spec ${spec}`}`,
81
+ extra,
82
+ };
83
+ }
84
+
85
+ let chosenCategory = category;
86
+ let spec = routing.modelMapping[category];
87
+ let reason: DecisionReason = "routed";
88
+
89
+ if (spec === undefined) {
90
+ const fallbackCategory = routing.defaultCategory;
91
+ const mapped = fallbackCategory === null ? undefined : routing.modelMapping[fallbackCategory];
92
+ if (fallbackCategory === null || mapped === undefined) {
93
+ return {
94
+ category,
95
+ confidence,
96
+ spec: null,
97
+ chain: [],
98
+ apply: null,
99
+ reason: "unmapped-category",
100
+ detail: `category ${JSON.stringify(category)} (confidence ${confidence.toFixed(2)}) is unmapped with no usable default; model unchanged`,
101
+ extra,
102
+ };
103
+ }
104
+ chosenCategory = fallbackCategory;
105
+ spec = mapped;
106
+ reason = "unknown-category";
107
+ }
108
+
109
+ const chain = (routing.fallbackChains[spec] ?? [spec])
110
+ .filter((entry) => typeof entry === "string" && entry.trim() !== "")
111
+ .filter((entry) => isAvailable(entry));
112
+
113
+ if (chain.length === 0) {
114
+ return {
115
+ category: chosenCategory,
116
+ confidence,
117
+ spec,
118
+ chain,
119
+ apply: null,
120
+ reason: "all-circuits-open",
121
+ detail: `category ${JSON.stringify(chosenCategory)} (confidence ${confidence.toFixed(2)}) -> spec ${spec} but every candidate is unavailable`,
122
+ extra,
123
+ };
124
+ }
125
+
126
+ const apply = chain[0]!;
127
+ const dryRunNote = config.dryRun ? "; dry run: model unchanged" : "";
128
+ const detail =
129
+ reason === "routed"
130
+ ? `routed category ${JSON.stringify(chosenCategory)} (confidence ${confidence.toFixed(2)}) -> ${apply}${dryRunNote}`
131
+ : `category ${JSON.stringify(category)} is unknown (confidence ${confidence.toFixed(2)}); using default ` +
132
+ `${JSON.stringify(chosenCategory)} -> ${apply}${dryRunNote}`;
133
+
134
+ return { category: chosenCategory, confidence, spec, chain, apply, reason, detail, extra };
135
+ }
package/src/types.ts ADDED
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Frozen shared contract for the classifier router.
3
+ *
4
+ * Every module in this package depends only on this file, so the four
5
+ * implementation slices (config, router/breaker, classifiers, extension wiring)
6
+ * never need to agree on anything else.
7
+ *
8
+ * Question/Answer shapes mirror both supported backends, which speak the same
9
+ * wire format:
10
+ * - Jev (TypeSafe) `POST /v1/systemone` -> `{ model, answers, usage }`
11
+ * - Laya (local, via python sidecar) -> `{ model, answers, usage }`
12
+ * Keeping one answer type means routing logic is backend-agnostic.
13
+ */
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Questions
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /** `instructions` / criterion descriptions accept structured payloads, per TypeSafe. */
20
+ export type InstructionValue = string | Record<string, unknown> | unknown[];
21
+
22
+ export interface ChoiceQuestion {
23
+ type: "choice";
24
+ instructions: InstructionValue;
25
+ /** Option id -> rubric description (null when the id is self-describing). */
26
+ criteria: Record<string, InstructionValue | null>;
27
+ }
28
+
29
+ export interface ScoreQuestion {
30
+ type: "score";
31
+ instructions: InstructionValue;
32
+ /** Ordered, at least two levels, lowest first. */
33
+ criteria: InstructionValue[];
34
+ }
35
+
36
+ export interface NoulQuestion {
37
+ type: "noul";
38
+ instructions: InstructionValue;
39
+ criteria?: { true?: InstructionValue; false?: InstructionValue };
40
+ }
41
+
42
+ export type Question = ChoiceQuestion | ScoreQuestion | NoulQuestion;
43
+
44
+ /** Question id -> question. Ids are for code and are never sent to the model. */
45
+ export type Questions = Record<string, Question>;
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Answers
49
+ // ---------------------------------------------------------------------------
50
+
51
+ export interface ChoiceAnswer {
52
+ type: "choice";
53
+ choice: string;
54
+ /** Every option mapped to its probability. */
55
+ probabilities: Record<string, number>;
56
+ /** 0..1, derived from the probability distribution. */
57
+ confidence: number;
58
+ }
59
+
60
+ export interface ScoreAnswer {
61
+ type: "score";
62
+ /** Probability-weighted value across the levels; may land between levels. */
63
+ score: number;
64
+ legend: Record<string, string>;
65
+ probabilities: Record<string, number>;
66
+ confidence: number;
67
+ }
68
+
69
+ export interface NoulAnswer {
70
+ type: "noul";
71
+ /** Probability the answer is yes, 0..1. */
72
+ noul: number;
73
+ confidence: number;
74
+ }
75
+
76
+ export type Answer = ChoiceAnswer | ScoreAnswer | NoulAnswer;
77
+
78
+ export type Answers = Record<string, Answer>;
79
+
80
+ /** Normalized backend reply. `answers` is the only field routing reads. */
81
+ export interface ClassificationResult {
82
+ model?: string;
83
+ answers: Answers;
84
+ usage?: { input_tokens?: number; output_tokens?: number };
85
+ }
86
+
87
+ /** The state handed to a classifier: prompt text, or a structured document. */
88
+ export type ClassifierState = string | Record<string, unknown> | unknown[];
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Classifiers
92
+ // ---------------------------------------------------------------------------
93
+
94
+ export interface ClassifyOptions {
95
+ signal?: AbortSignal;
96
+ }
97
+
98
+ /**
99
+ * A classifier turns state + typed questions into typed answers.
100
+ *
101
+ * Implementations MUST throw `ClassifierError` on any failure (transport,
102
+ * auth, timeout, malformed reply). They MUST NOT throw for a well-formed reply
103
+ * that merely lacks a question id; callers treat missing answers as absent.
104
+ */
105
+ export interface Classifier {
106
+ /** Stable backend id, e.g. `"jev"` or `"laya"`. */
107
+ readonly name: string;
108
+ /**
109
+ * Optional: make the backend ready before the first classification.
110
+ * `classify` MUST still work without a prior `warmup` call.
111
+ */
112
+ warmup?(options?: ClassifyOptions): Promise<void>;
113
+ classify(
114
+ state: ClassifierState,
115
+ questions: Questions,
116
+ options?: ClassifyOptions,
117
+ ): Promise<ClassificationResult>;
118
+ /** Release resources (HTTP handles, sidecar processes). Idempotent. */
119
+ dispose(): Promise<void>;
120
+ }
121
+
122
+ export type ClassifierErrorCode =
123
+ | "unavailable"
124
+ | "timeout"
125
+ | "auth"
126
+ | "transport"
127
+ | "protocol"
128
+ | "aborted";
129
+
130
+ export class ClassifierError extends Error {
131
+ readonly code: ClassifierErrorCode;
132
+ readonly backend: string;
133
+
134
+ constructor(backend: string, code: ClassifierErrorCode, message: string, options?: { cause?: unknown }) {
135
+ super(message, options);
136
+ this.name = "ClassifierError";
137
+ this.backend = backend;
138
+ this.code = code;
139
+ }
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Configuration
144
+ // ---------------------------------------------------------------------------
145
+
146
+ export interface JevBackendConfig {
147
+ /** Evaluation endpoint, e.g. `https://api.typesafe.ai/v1/systemone`. */
148
+ endpoint: string;
149
+ /** Model alias sent with the request, e.g. `jev-latest`. */
150
+ model: string;
151
+ /** Environment variable holding the bearer token. */
152
+ apiKeyEnvVar: string;
153
+ timeoutMs: number;
154
+ }
155
+
156
+ /** Where Laya inference runs: the local python sidecar, or a remote HTTP host. */
157
+ export type LayaTransport = "python" | "http";
158
+
159
+ /**
160
+ * Laya System-One backend.
161
+ *
162
+ * `transport` selects the implementation: `"python"` spawns the local sidecar
163
+ * worker, `"http"` posts typed questions to `endpoint` and needs no local
164
+ * install. The python-only fields below are ignored when
165
+ * `transport === "http"`; `endpoint`/`apiKeyEnvVar` are ignored when
166
+ * `transport === "python"`.
167
+ *
168
+ * The http transport sends no `model` field: the remote host owns checkpoint
169
+ * selection, so there is deliberately no `model` here.
170
+ */
171
+ export interface LayaBackendConfig {
172
+ /** Where inference runs. */
173
+ transport: LayaTransport;
174
+ /** Full URL of a System-One-compatible endpoint; used when `transport === "http"`. */
175
+ endpoint: string;
176
+ /**
177
+ * Env var holding an optional bearer token for `endpoint`; an unset or empty
178
+ * value means no `Authorization` header is sent.
179
+ */
180
+ apiKeyEnvVar: string;
181
+ /** Python interpreter used to run the sidecar worker. Ignored for `"http"`. */
182
+ pythonBin: string;
183
+ /** Path to `python/laya_worker.py`, absolute or relative to the extension root. */
184
+ workerScript: string;
185
+ /** Hugging Face repo bundling the checkpoints. */
186
+ repo: string;
187
+ /** Checkpoint subfolder: `null` = English root, `"multilingual"`, `"typed-decisions"`. */
188
+ subfolder: string | null;
189
+ /** Torch device (`cpu`, `cuda`, `mps`), or `null` for auto-detect. */
190
+ device: string | null;
191
+ /** Serve every checkpoint through `laya.Router` (adds multilingual routing). */
192
+ router: boolean;
193
+ /** Load weights during `warmup` instead of on the first classification. */
194
+ preload: boolean;
195
+ /** Per-classification budget; exceeding it fails the call. */
196
+ timeoutMs: number;
197
+ /** Budget for loading weights during `warmup`. */
198
+ warmupTimeoutMs: number;
199
+ /** Environment variable holding the Hugging Face token, if the repo is gated. */
200
+ hfTokenEnvVar: string;
201
+ }
202
+
203
+ export interface RoutingConfig {
204
+ /** Typed questions sent to the classifier. MUST contain `primaryQuestion`. */
205
+ questions: Questions;
206
+ /**
207
+ * Question id whose answer selects the model. MUST be a `choice` question;
208
+ * score/noul answers cannot name a category. Other questions are collected
209
+ * and reported but do not affect routing.
210
+ */
211
+ primaryQuestion: string;
212
+ /** Category -> model spec, resolved through `ctx.models.resolve()`. */
213
+ modelMapping: Record<string, string>;
214
+ /**
215
+ * Model spec -> ordered candidate specs tried when the circuit is open.
216
+ * A spec with no entry falls back to itself. Chain entries are specs, not
217
+ * mapped categories.
218
+ */
219
+ fallbackChains: Record<string, string[]>;
220
+ /**
221
+ * Minimum primary-answer confidence required to override the model.
222
+ * Below it the session model is left alone.
223
+ */
224
+ confidenceThreshold: number;
225
+ /** Category used when the primary answer is missing/unknown. `null` = no override. */
226
+ defaultCategory: string | null;
227
+ }
228
+
229
+ /** Which sessions this router governs. */
230
+ export type ApplyTo = "all" | "main" | "subagents";
231
+
232
+ export interface CircuitBreakerConfig {
233
+ /** Consecutive failures that open a circuit. */
234
+ failureThreshold: number;
235
+ /** How long an open circuit stays open before half-open trials. */
236
+ cooldownMs: number;
237
+ /** Allow one trial request through while half-open. */
238
+ halfOpenMaxTrials: number;
239
+ }
240
+
241
+ export interface RouterConfig {
242
+ enabled: boolean;
243
+ backend: "jev" | "laya";
244
+ jev: JevBackendConfig;
245
+ laya: LayaBackendConfig;
246
+ routing: RoutingConfig;
247
+ circuitBreaker: CircuitBreakerConfig;
248
+ /** Classify and log, but never change the model. */
249
+ dryRun: boolean;
250
+ /** Surface routing decisions through `ui.notify`. */
251
+ notify: boolean;
252
+ applyTo: ApplyTo;
253
+ }
254
+
255
+ /** Deep-partial config accepted from disk; defaults are layered over it. */
256
+ export interface RouterConfigInput {
257
+ enabled?: boolean;
258
+ backend?: RouterConfig["backend"];
259
+ jev?: Partial<JevBackendConfig>;
260
+ laya?: Partial<LayaBackendConfig>;
261
+ routing?: Partial<RoutingConfig>;
262
+ circuitBreaker?: Partial<CircuitBreakerConfig>;
263
+ dryRun?: boolean;
264
+ notify?: boolean;
265
+ applyTo?: ApplyTo;
266
+ }
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // Decision
270
+ // ---------------------------------------------------------------------------
271
+
272
+ /** Why a routing decision did or did not change the model. */
273
+ export type DecisionReason =
274
+ | "routed"
275
+ | "disabled"
276
+ | "no-questions"
277
+ | "missing-answer"
278
+ | "low-confidence"
279
+ | "unknown-category"
280
+ | "unmapped-category"
281
+ | "all-circuits-open";
282
+
283
+ export interface Decision {
284
+ /** Chosen category, or null when the answer was unusable. */
285
+ category: string | null;
286
+ /** Confidence of the primary answer, 0 when absent. */
287
+ confidence: number;
288
+ /** Mapped model spec, before circuit filtering. Null when unmapped. */
289
+ spec: string | null;
290
+ /** Ordered candidate specs to try, already circuit-filtered. */
291
+ chain: string[];
292
+ /** Spec to apply, or null when the session model must be left alone. */
293
+ apply: string | null;
294
+ reason: DecisionReason;
295
+ /** One-line human-readable explanation. */
296
+ detail: string;
297
+ /** Other question answers, carried for reporting. */
298
+ extra: Record<string, Answer>;
299
+ }