toolnexus 0.17.0 → 0.18.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.
@@ -0,0 +1,832 @@
1
+ /**
2
+ * Classifier (SPEC.md §8B) — the contract for a JUDGMENT, as `Tool` is the contract for an
3
+ * ACTION. A System One model takes a state plus pre-declared, typed questions and returns
4
+ * calibrated answers with no free text. It has no messages, no tool calling and no streaming,
5
+ * so it never enters the §8 client loop.
6
+ *
7
+ * A classifier INTERPRETS; it never AUTHORISES. Schema validity is not correctness: a decision
8
+ * can be confidently wrong, and "cannot hallucinate" means only that the returned value is in
9
+ * the declared schema. Numeric limits, permission checks and allowlists stay in code. Nothing
10
+ * here is a security control.
11
+ */
12
+ import type { Client } from "./client.js"
13
+ import { type ErrorInfo, type ErrorTier, type MetricEvent } from "./client.js"
14
+ import { retryAfterMs, isRetryableStatus } from "./retry.js"
15
+ import type { Toolkit } from "./toolkit.js"
16
+
17
+ // ---------------------------------------------------------------- constants
18
+
19
+ /** The System One endpoint base. */
20
+ export const DEFAULT_CLASSIFIER_BASE_URL = "https://api.typesafe.ai/v1"
21
+ /** The floating model alias. Pin it (e.g. `jev-1.13.0`) once thresholds are tuned. */
22
+ export const DEFAULT_CLASSIFIER_MODEL = "jev-latest"
23
+ /** The NAME of the env var holding the credential — never the value. */
24
+ export const DEFAULT_CLASSIFIER_API_KEY_ENV = "TYPESAFE_API_KEY"
25
+ /** Bounds ONE request; a classifier has no loop to bound. */
26
+ export const DEFAULT_CLASSIFIER_TIMEOUT_MS = 10_000
27
+
28
+ /** Client-side cap on a choice's named options (§8B). */
29
+ export const MAX_CHOICE_OPTIONS = 255
30
+ /** Bounds of a score rubric (§8B). */
31
+ export const MIN_SCORE_LEVELS = 2
32
+ export const MAX_SCORE_LEVELS = 10
33
+
34
+ /**
35
+ * The ABSOLUTE tolerance on `max|p - 1/n|`, compared INCLUSIVELY (§8B). Pinned across every
36
+ * port; `examples/judge/near-uniform.json` pins both sides at 0.0499 / 0.0501.
37
+ */
38
+ export const NEAR_UNIFORM_TOLERANCE = 0.05
39
+
40
+ // ---------------------------------------------------------------- questions
41
+
42
+ /**
43
+ * Labels for the true and false cases of a noul question. Absent and empty are DIFFERENT values
44
+ * and both are preserved on the wire: omit `criteria` entirely and the field is absent; pass
45
+ * `{ true: "", false: "" }` and it is emitted with empty values.
46
+ */
47
+ export interface NoulCriteria {
48
+ true: string
49
+ false: string
50
+ }
51
+
52
+ /** The probability that a statement holds, one number in 0..1. It carries NO confidence. */
53
+ export interface NoulQuestion {
54
+ type: "noul"
55
+ instructions: string
56
+ /** Optional. Omitted ⇒ the field is ABSENT from the request, not null and not {}. */
57
+ criteria?: NoulCriteria
58
+ }
59
+
60
+ /**
61
+ * One option from a named set, 1..255 options.
62
+ *
63
+ * THE ENCODING OBLIGATION IS THE CALLER'S (§8B, docs/adr/0021 D1): `criteria[id]` is the only
64
+ * thing that differentiates one option from another to the model. Passing the id itself, an
65
+ * empty string, or one value repeated is schema-valid, returns HTTP 200 and a well-formed
66
+ * distribution — and ranks at chance (measured: 17 apples described by consequence, 0/1/0
67
+ * described by id).
68
+ */
69
+ export interface ChoiceQuestion {
70
+ type: "choice"
71
+ instructions: string
72
+ /** option id -> what picking it would MEAN. */
73
+ criteria: Record<string, string>
74
+ }
75
+
76
+ /**
77
+ * A rating against an ORDERED rubric of 2..10 levels. The array order IS the level numbering,
78
+ * so it is never sorted — a "sort everything" canonicaliser silently renumbers the rubric.
79
+ */
80
+ export interface ScoreQuestion {
81
+ type: "score"
82
+ instructions: string
83
+ criteria: string[]
84
+ }
85
+
86
+ /** The closed set of question types, discriminated by `type`. */
87
+ export type Question = NoulQuestion | ChoiceQuestion | ScoreQuestion
88
+
89
+ /** A noul question. Pass `criteria` only when you mean it — absent ≠ empty on the wire. */
90
+ export function noul(instructions: string, criteria?: NoulCriteria): NoulQuestion {
91
+ return criteria === undefined ? { type: "noul", instructions } : { type: "noul", instructions, criteria }
92
+ }
93
+
94
+ /** A choice question. Every description must say what picking that option would MEAN. */
95
+ export function choice(instructions: string, criteria: Record<string, string>): ChoiceQuestion {
96
+ return { type: "choice", instructions, criteria: { ...criteria } }
97
+ }
98
+
99
+ /** A score question. The array order IS the level numbering and is never reordered. */
100
+ export function score(instructions: string, criteria: string[]): ScoreQuestion {
101
+ return { type: "score", instructions, criteria: [...criteria] }
102
+ }
103
+
104
+ /**
105
+ * Build a choice from any (name, description) pairs — a Tool, a skill, an agent, an A2A card
106
+ * skill. The description must say what picking that option would MEAN; see the encoding
107
+ * obligation on `ChoiceQuestion`.
108
+ */
109
+ export function choiceOver(instructions: string, items: Record<string, string>): ChoiceQuestion {
110
+ return choice(instructions, items)
111
+ }
112
+
113
+ /** The wire form of one question — the object itself, minus an absent `criteria`. */
114
+ function questionWire(q: Question): Record<string, unknown> {
115
+ switch (q.type) {
116
+ case "noul":
117
+ return q.criteria === undefined
118
+ ? { type: "noul", instructions: q.instructions }
119
+ : { type: "noul", instructions: q.instructions, criteria: { true: q.criteria.true, false: q.criteria.false } }
120
+ case "choice":
121
+ return { type: "choice", instructions: q.instructions, criteria: { ...q.criteria } }
122
+ case "score":
123
+ return { type: "score", instructions: q.instructions, criteria: [...q.criteria] }
124
+ }
125
+ }
126
+
127
+ /** Client-side limits, enforced BEFORE the request. The error names the key and the limit. */
128
+ function validateQuestion(key: string, q: Question): void {
129
+ if (q.type === "choice") {
130
+ const n = Object.keys(q.criteria ?? {}).length
131
+ if (n < 1 || n > MAX_CHOICE_OPTIONS) {
132
+ throw new Error(`classifier: question "${key}": a choice needs 1..${MAX_CHOICE_OPTIONS} options, got ${n}`)
133
+ }
134
+ } else if (q.type === "score") {
135
+ const n = (q.criteria ?? []).length
136
+ if (n < MIN_SCORE_LEVELS || n > MAX_SCORE_LEVELS) {
137
+ throw new Error(
138
+ `classifier: question "${key}": a score needs ${MIN_SCORE_LEVELS}..${MAX_SCORE_LEVELS} ordered levels, got ${n}`,
139
+ )
140
+ }
141
+ }
142
+ }
143
+
144
+ // ---------------------------------------------------------------- the wire
145
+
146
+ /**
147
+ * Canonical JSON: object keys sorted recursively in CODE-UNIT (ASCII) order, arrays NEVER
148
+ * reordered, compact separators, and `<>&'"` plus non-ASCII transmitted raw.
149
+ *
150
+ * Hand-rolled because `JSON.stringify` has no recursive key sort, and its `replacer` ARRAY form
151
+ * applies one key list at EVERY depth — it cannot express "sort whatever keys are here". The
152
+ * per-value escaping and number formatting ARE delegated to `JSON.stringify`, which emits the
153
+ * shortest round-tripping decimal (so 1.21 is "1.21" and integer 0 is "0", not "0.0") and does
154
+ * not escape `<`, `>`, `&` or non-ASCII.
155
+ *
156
+ * `undefined` is ABSENT, never `null`: an object key whose value is undefined is dropped.
157
+ */
158
+ export function canonicalJson(value: unknown): string {
159
+ if (Array.isArray(value)) return "[" + value.map((v) => (v === undefined ? "null" : canonicalJson(v))).join(",") + "]"
160
+ if (value !== null && typeof value === "object") {
161
+ const obj = value as Record<string, unknown>
162
+ // `sort()` with no comparator compares UTF-16 code units — ASCII order, and NOT the
163
+ // culture-sensitive order `localeCompare` would give ("10_alpha" < "Alpha" < "beta" < "café").
164
+ const keys = Object.keys(obj)
165
+ .filter((k) => obj[k] !== undefined)
166
+ .sort()
167
+ return "{" + keys.map((k) => JSON.stringify(k) + ":" + canonicalJson(obj[k])).join(",") + "}"
168
+ }
169
+ const s = JSON.stringify(value)
170
+ return s === undefined ? "null" : s
171
+ }
172
+
173
+ /**
174
+ * The bytes the byte-identity claim covers: `model` + `questions`.
175
+ *
176
+ * `state` is deliberately NOT here. It is transmitted verbatim as the host supplied it and is
177
+ * OUTSIDE the claim, because numbers do not canonicalise across languages (`-0.0` renders four
178
+ * ways across our own seven runtimes). Do not re-widen this: a caller who needs their state
179
+ * pinned canonicalises it themselves before handing it over.
180
+ */
181
+ export function canonicalRequest(model: string, questions: Record<string, Question>): string {
182
+ const qs: Record<string, unknown> = {}
183
+ for (const [key, q] of Object.entries(questions)) {
184
+ if (!q) throw new Error(`classifier: question "${key}" is missing`)
185
+ qs[key] = questionWire(q)
186
+ }
187
+ return canonicalJson({ model, questions: qs })
188
+ }
189
+
190
+ // ---------------------------------------------------------------- answers
191
+
192
+ /** The probability that a statement holds. NO confidence: the number is the answer. */
193
+ export interface NoulAnswer {
194
+ type: "noul"
195
+ noul: number
196
+ }
197
+
198
+ /** One option from the offered set, with a probability for EVERY offered option. */
199
+ export interface ChoiceAnswer {
200
+ type: "choice"
201
+ choice: string
202
+ probabilities: Record<string, number>
203
+ confidence: number
204
+ /**
205
+ * DERIVED from `probabilities` on decode and never read from the wire (§8B):
206
+ * `max|p - 1/n| <= 0.05`, inclusive, `n` = the number of entries in the map.
207
+ *
208
+ * ADVISORY, NOT a correctness signal. It detects an encoding that gave the model nothing to
209
+ * rank on — it cannot distinguish a good encoding from a subtly wrong one. `calibrated`
210
+ * carries the same caveat.
211
+ */
212
+ nearUniform: boolean
213
+ }
214
+
215
+ /** A rating against the ordered rubric. `score` MAY fall between levels (1.21 is a real answer). */
216
+ export interface ScoreAnswer {
217
+ type: "score"
218
+ score: number
219
+ legend: Record<string, string>
220
+ probabilities: Record<string, number>
221
+ confidence: number
222
+ }
223
+
224
+ /**
225
+ * One typed answer, discriminated by `type`. Named `DecisionAnswer` because §10 already owns
226
+ * `Answer` (the suspension resolution) — same idea, different seam.
227
+ */
228
+ export type DecisionAnswer = NoulAnswer | ChoiceAnswer | ScoreAnswer
229
+
230
+ /** The wire's usage block. `cost` is absent on some backends. */
231
+ export interface ClassifierUsage {
232
+ inputTokens: number
233
+ outputTokens: number
234
+ cost?: number
235
+ }
236
+
237
+ /** The legend in LEVEL order, which the map itself loses ("2" sorts before "10" as a level). */
238
+ export function levels(answer: ScoreAnswer): string[] {
239
+ return Object.keys(answer.legend)
240
+ .sort((a, b) => (a.length !== b.length ? a.length - b.length : a < b ? -1 : a > b ? 1 : 0))
241
+ .map((k) => answer.legend[k])
242
+ }
243
+
244
+ /**
245
+ * One answer per question, keyed by the CALLER's keys. The keys are addressing, not content:
246
+ * they are never transmitted, so a key may be a tool, skill or agent name verbatim.
247
+ */
248
+ export class Decision {
249
+ constructor(
250
+ /** What actually answered, which may be more specific than the model that was asked for. */
251
+ readonly model: string,
252
+ readonly answers: Record<string, DecisionAnswer>,
253
+ readonly usage: ClassifierUsage,
254
+ /**
255
+ * Whether the probabilities are calibrated. `systemone` reports true; `llm` reports false
256
+ * unless it derived them from provider token probabilities. A THRESHOLD TUNED AGAINST ONE
257
+ * BACKEND DOES NOT TRANSFER TO ANOTHER.
258
+ */
259
+ readonly calibrated: boolean,
260
+ ) {}
261
+
262
+ /** Typed read. Throws (never returns undefined) on a wrong-type or absent key. */
263
+ noul(key: string): NoulAnswer {
264
+ return this.typed(key, "noul")
265
+ }
266
+ choice(key: string): ChoiceAnswer {
267
+ return this.typed(key, "choice")
268
+ }
269
+ score(key: string): ScoreAnswer {
270
+ return this.typed(key, "score")
271
+ }
272
+
273
+ private typed<T extends DecisionAnswer["type"]>(key: string, want: T): Extract<DecisionAnswer, { type: T }> {
274
+ const a = this.answers[key]
275
+ if (a === undefined) throw new Error(`classifier: no answer "${key}" in this decision`)
276
+ if (a.type !== want) throw new Error(`classifier: answer "${key}" is a ${a.type} answer, not ${want}`)
277
+ return a as Extract<DecisionAnswer, { type: T }>
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Whether a choice answer's probability map is indistinguishable from flat (§8B):
283
+ *
284
+ * nearUniform ⇔ max over i of |p_i - 1/n| <= 0.05
285
+ *
286
+ * `n` is the number of ENTRIES IN THE MAP and the values are taken AS RETURNED — not
287
+ * renormalised, not sorted, not rounded; an offered option absent from the map counts as 0 by
288
+ * not being an entry. The tolerance is ABSOLUTE (a relative band collapses below the wire's
289
+ * two-decimal rounding on a 255-option roster) and the comparison is INCLUSIVE. `n === 1` is
290
+ * trivially uniform. An empty map has no distribution at all and is reported false.
291
+ */
292
+ export function nearUniform(probabilities: Record<string, number>): boolean {
293
+ const values = Object.values(probabilities ?? {})
294
+ if (values.length === 0) return false
295
+ if (values.length === 1) return true
296
+ const target = 1 / values.length
297
+ return values.every((p) => Math.abs(p - target) <= NEAR_UNIFORM_TOLERANCE)
298
+ }
299
+
300
+ /**
301
+ * Decode a backend response into a Decision. The discriminated decode is the single place
302
+ * `nearUniform` is derived. A malformed answer is an error, never a repaired one (ADR 0020).
303
+ */
304
+ export function decodeDecision(raw: unknown, fallbackModel: string): Decision {
305
+ if (raw === null || typeof raw !== "object") throw new Error("classifier: response is not an object")
306
+ const body = raw as Record<string, any>
307
+ const answers: Record<string, DecisionAnswer> = {}
308
+ for (const [key, value] of Object.entries(body.answers ?? {})) {
309
+ if (value === null || typeof value !== "object") throw new Error(`classifier: answer "${key}" is not an object`)
310
+ const a = value as Record<string, any>
311
+ switch (a.type) {
312
+ case "noul":
313
+ answers[key] = { type: "noul", noul: num(a.noul) }
314
+ break
315
+ case "choice":
316
+ answers[key] = {
317
+ type: "choice",
318
+ choice: String(a.choice ?? ""),
319
+ probabilities: numbers(a.probabilities),
320
+ confidence: num(a.confidence),
321
+ nearUniform: nearUniform(numbers(a.probabilities)),
322
+ }
323
+ break
324
+ case "score":
325
+ answers[key] = {
326
+ type: "score",
327
+ score: num(a.score),
328
+ legend: strings(a.legend),
329
+ probabilities: numbers(a.probabilities),
330
+ confidence: num(a.confidence),
331
+ }
332
+ break
333
+ default:
334
+ throw new Error(`classifier: answer "${key}": unknown type ${JSON.stringify(a.type)}`)
335
+ }
336
+ }
337
+ const usage: ClassifierUsage = {
338
+ inputTokens: num(body.usage?.input_tokens),
339
+ outputTokens: num(body.usage?.output_tokens),
340
+ }
341
+ // Absent stays absent: a cost of 0 is a real cost and undefined is "not reported".
342
+ if (body.usage?.cost !== undefined) usage.cost = num(body.usage.cost)
343
+ // Absent ⇒ true: the systemone wire reports calibration by being itself. A backend that is
344
+ // not calibrated says so explicitly.
345
+ return new Decision(String(body.model ?? fallbackModel), answers, usage, body.calibrated !== false)
346
+ }
347
+
348
+ function num(v: unknown): number {
349
+ return typeof v === "number" ? v : 0
350
+ }
351
+ /** A zero probability stays an ENTRY: the map is copied key-for-key, never filtered. */
352
+ function numbers(v: unknown): Record<string, number> {
353
+ const out: Record<string, number> = {}
354
+ for (const [k, p] of Object.entries((v ?? {}) as Record<string, unknown>)) out[k] = num(p)
355
+ return out
356
+ }
357
+ function strings(v: unknown): Record<string, string> {
358
+ const out: Record<string, string> = {}
359
+ for (const [k, s] of Object.entries((v ?? {}) as Record<string, unknown>)) out[k] = String(s)
360
+ return out
361
+ }
362
+
363
+ // ---------------------------------------------------------------- options
364
+
365
+ /** `"systemone" | "llm" | "custom" | "static"` — the same slot as the client's `style`. */
366
+ export type ClassifierStyle = "systemone" | "llm" | "custom" | "static"
367
+
368
+ /**
369
+ * One entry of the `static` corpus. It is keyed on the canonical request AND the state: several
370
+ * recorded entries legitimately share one questions payload and differ only in state (the three
371
+ * guard bands of `examples/judge/decisions.json` do exactly that), so a corpus keyed on the
372
+ * canonical request alone cannot tell them apart.
373
+ */
374
+ export interface RecordedDecision {
375
+ state: unknown
376
+ questions: Record<string, Question>
377
+ /** The recorded backend response body, verbatim. */
378
+ response: unknown
379
+ }
380
+
381
+ /** The host's own backend (`style: "custom"`). Every wire option is ignored. */
382
+ export type EvaluateFn = (
383
+ state: unknown,
384
+ questions: Record<string, Question>,
385
+ signal?: AbortSignal,
386
+ ) => Promise<Decision> | Decision
387
+
388
+ /**
389
+ * Mirrors §8 `ClientOptions` field-for-field wherever a field makes sense, so a host that has
390
+ * configured one has configured the other.
391
+ */
392
+ export interface ClassifierOptions {
393
+ /** Default `"systemone"`. */
394
+ style?: ClassifierStyle
395
+ /** Default `https://api.typesafe.ai/v1`. OpenRouter serves this wire today. */
396
+ baseUrl?: string
397
+ /** Default `"jev-latest"`. Pin it once thresholds are tuned. */
398
+ model?: string
399
+ /** The NAME of the env var holding the credential, never the value — read at call time and
400
+ * never logged. §8's `apiKey` takes a value; this option deliberately does not. */
401
+ apiKeyEnv?: string
402
+ /** Extra headers. Values expand `${ENV_VAR}` from the environment AT CALL TIME and are NEVER
403
+ * logged, identically to remote-MCP headers (§2). */
404
+ headers?: Record<string, string>
405
+ /** Bounds ONE request (a classifier has no loop to bound). Default 10 s. */
406
+ timeoutMs?: number
407
+ /** §8 Gap 2 injectable transport. Scope is the classifier path only. */
408
+ fetch?: typeof fetch
409
+ /** Retries on transient errors (`408`/`429`/`500`/`502`/`503`/`504`/`529` + network). Default 2.
410
+ * Widen the status set with `retryableStatuses`. */
411
+ retries?: number
412
+ /** Base backoff in ms (exponential). Default 500. */
413
+ retryBaseMs?: number
414
+ /**
415
+ * Extra HTTP statuses to treat as retryable, ADDED to the default set
416
+ * (`429`/`500`/`502`/`503`/`504`/`529` plus `408` here). It can only widen: a host cannot remove `429` and
417
+ * lose `Retry-After` handling with it. This sets the DEFAULT classification; `onError` still
418
+ * runs per attempt and has the final say, so `onError` returning `"fail"` overrides a status
419
+ * listed here. Example: a Cloudflare-fronted origin that answers `520`–`527`.
420
+ */
421
+ retryableStatuses?: readonly number[]
422
+
423
+ /** §8 Resilience. REUSES the client's `ErrorInfo`/`ErrorTier` and the `Retry-After`
424
+ * delay-seconds rule verbatim — there is no second retry policy, and no `"suspend"` tier. */
425
+ onError?: (info: ErrorInfo) => ErrorTier
426
+ /** §8 Gap 1. Extra top-level keys shallow-merged into the body after the classifier builds its
427
+ * own; a `requestParams` key WINS on collision. Omit ⇒ body byte-identical. */
428
+ requestParams?: Record<string, unknown>
429
+ /** §8 Gap 1. Receives the assembled body after the `requestParams` merge and returns the body
430
+ * to send. Order: base body → requestParams merge → bodyTransform → marshal, exactly as §8. */
431
+ bodyTransform?: (body: Record<string, any>) => Record<string, any> | undefined | void
432
+ /** Emits `classifier.evaluate` events into the SAME §8 sink, and carries the
433
+ * degenerate-criteria warning as `classifier.warning`. */
434
+ onMetric?: (ev: MetricEvent) => void
435
+ /** `style: "llm"` only — the §8 Client to emulate over. */
436
+ client?: Client
437
+ /** `style: "custom"` only — the host's own function. */
438
+ evaluate?: EvaluateFn
439
+ /** `style: "static"` only — the recorded corpus. */
440
+ decisions?: RecordedDecision[]
441
+ }
442
+
443
+ // ---------------------------------------------------------------- classifier
444
+
445
+ /** The whole seam: one verb. */
446
+ export class Classifier {
447
+ private readonly style: ClassifierStyle
448
+ private readonly baseUrl: string
449
+ private readonly model: string
450
+ private readonly apiKeyEnv: string
451
+ private readonly timeoutMs: number
452
+ private readonly corpus = new Map<string, unknown>()
453
+ /** The once-per-question-key set for the degenerate warning, so a per-turn judge does not
454
+ * flood the sink. */
455
+ private readonly warned = new Set<string>()
456
+
457
+ constructor(private readonly opts: ClassifierOptions) {
458
+ this.style = opts.style ?? "systemone"
459
+ this.baseUrl = opts.baseUrl ?? DEFAULT_CLASSIFIER_BASE_URL
460
+ this.model = opts.model ?? DEFAULT_CLASSIFIER_MODEL
461
+ this.apiKeyEnv = opts.apiKeyEnv ?? DEFAULT_CLASSIFIER_API_KEY_ENV
462
+ this.timeoutMs = opts.timeoutMs ?? DEFAULT_CLASSIFIER_TIMEOUT_MS
463
+ switch (this.style) {
464
+ case "systemone":
465
+ break
466
+ case "llm":
467
+ if (!opts.client) throw new Error('classifier: style "llm" requires `client`')
468
+ break
469
+ case "custom":
470
+ if (!opts.evaluate) throw new Error('classifier: style "custom" requires `evaluate`')
471
+ break
472
+ case "static":
473
+ for (const [i, rec] of (opts.decisions ?? []).entries()) {
474
+ try {
475
+ this.corpus.set(this.staticKey(rec.state, rec.questions), rec.response)
476
+ } catch (e) {
477
+ throw new Error(`classifier: recorded decision ${i}: ${(e as Error).message}`)
478
+ }
479
+ }
480
+ break
481
+ default: {
482
+ const unknown: never = this.style
483
+ throw new Error(`classifier: unknown style ${JSON.stringify(unknown)}`)
484
+ }
485
+ }
486
+ }
487
+
488
+ /**
489
+ * The whole contract: a state plus typed questions in, a Decision out. Questions are
490
+ * INDEPENDENT — one answer is never context for another.
491
+ */
492
+ async evaluate(state: unknown, questions: Record<string, Question>, signal?: AbortSignal): Promise<Decision> {
493
+ const start = Date.now()
494
+ const keys = Object.keys(questions ?? {}).sort()
495
+ if (keys.length === 0) {
496
+ const err = new Error("classifier: no questions to evaluate")
497
+ this.emitError(start, err)
498
+ throw err
499
+ }
500
+ try {
501
+ // Limits are enforced CLIENT-SIDE, before the request: the caller finds out faster and
502
+ // more legibly than from the backend's own 400. Keys are walked in sorted order so the
503
+ // same malformed set always names the same key first.
504
+ for (const key of keys) {
505
+ const q = questions[key]
506
+ if (!q) throw new Error(`classifier: question "${key}" is missing`)
507
+ validateQuestion(key, q)
508
+ }
509
+ } catch (e) {
510
+ this.emitError(start, e)
511
+ throw e
512
+ }
513
+ // Detection, never repair (ADR 0020/0021): the request goes out BYTE-UNCHANGED and the
514
+ // warning is the entire observable effect.
515
+ this.reportDegenerate(keys, questions)
516
+
517
+ let decision: Decision
518
+ try {
519
+ switch (this.style) {
520
+ case "custom":
521
+ decision = await this.opts.evaluate!(state, questions, signal)
522
+ break
523
+ case "static":
524
+ decision = this.evaluateStatic(state, questions)
525
+ break
526
+ case "llm":
527
+ decision = await this.evaluateLLM(state, questions, signal)
528
+ break
529
+ default:
530
+ decision = await this.evaluateSystemOne(state, questions, signal)
531
+ }
532
+ } catch (e) {
533
+ this.emitError(start, e)
534
+ throw e
535
+ }
536
+ this.emit({
537
+ event: "classifier.evaluate",
538
+ model: decision.model,
539
+ status: "ok",
540
+ ms: Date.now() - start,
541
+ promptTokens: decision.usage.inputTokens,
542
+ completionTokens: decision.usage.outputTokens,
543
+ })
544
+ return decision
545
+ }
546
+
547
+ // ---------------------------------------------------------------- degenerate
548
+
549
+ /**
550
+ * Emits ONE warning per degenerate question key per classifier, naming the key, and changes
551
+ * nothing about the request. Repairing would invent option descriptions the caller did not
552
+ * write, and the library has no way to know what the options mean.
553
+ */
554
+ private reportDegenerate(keys: string[], questions: Record<string, Question>): void {
555
+ for (const key of keys) {
556
+ const q = questions[key]
557
+ if (q.type !== "choice") continue
558
+ const reason = degenerateCriteria(q.criteria)
559
+ if (!reason || this.warned.has(key)) continue
560
+ this.warned.add(key)
561
+ this.emit({
562
+ event: "classifier.warning",
563
+ question: key,
564
+ warning:
565
+ `classifier: question "${key}" has degenerate criteria (${reason}) — every option reads ` +
566
+ "the same to the model and the answer ranks at chance; describe what picking each " +
567
+ "option would MEAN (SPEC.md §8B)",
568
+ })
569
+ }
570
+ }
571
+
572
+ // ---------------------------------------------------------------- backends
573
+
574
+ /**
575
+ * Assembles the request: the canonical model + questions, plus state VERBATIM as the host
576
+ * supplied it, then the §8 Gap 1 pipeline in §8 order (base → requestParams merge →
577
+ * bodyTransform → marshal).
578
+ */
579
+ private body(state: unknown, questions: Record<string, Question>): string {
580
+ const qs: Record<string, unknown> = {}
581
+ for (const [key, q] of Object.entries(questions)) qs[key] = questionWire(q)
582
+ let body: Record<string, any> = { model: this.model, questions: qs, state }
583
+ // A requestParams key WINS on collision.
584
+ if (this.opts.requestParams) body = { ...body, ...this.opts.requestParams }
585
+ if (this.opts.bodyTransform) body = this.opts.bodyTransform(body) || body
586
+ return canonicalJson(body)
587
+ }
588
+
589
+ /** Identifies a recorded decision by the canonical request AND the state. */
590
+ private staticKey(state: unknown, questions: Record<string, Question>): string {
591
+ return canonicalRequest(this.model, questions) + "\u0000" + canonicalJson(state)
592
+ }
593
+
594
+ private evaluateStatic(state: unknown, questions: Record<string, Question>): Decision {
595
+ const raw = this.corpus.get(this.staticKey(state, questions))
596
+ if (raw === undefined) throw new Error("classifier: static: no recorded decision for this request+state")
597
+ return decodeDecision(raw, this.model)
598
+ }
599
+
600
+ private async evaluateSystemOne(
601
+ state: unknown,
602
+ questions: Record<string, Question>,
603
+ signal?: AbortSignal,
604
+ ): Promise<Decision> {
605
+ const text = await this.post(this.body(state, questions), signal)
606
+ let parsed: unknown
607
+ try {
608
+ parsed = JSON.parse(text)
609
+ } catch (e) {
610
+ throw new Error(`classifier: response was not JSON: ${(e as Error).message}`)
611
+ }
612
+ return decodeDecision(parsed, this.model)
613
+ }
614
+
615
+ /**
616
+ * The one POST, with the §8 retry budget, reusing the client's `ErrorInfo`/`ErrorTier`
617
+ * classifier and the `Retry-After` delay-seconds rule verbatim.
618
+ *
619
+ * NO CREDENTIAL VALUE AND NO EXPANDED HEADER VALUE APPEARS ON ANY PATH OUT OF HERE: an
620
+ * authentication failure names the status and the endpoint, nothing else, and a 401/403 body
621
+ * is never echoed back (a gateway happily reflects a bad Authorization header into its own
622
+ * 401 text).
623
+ */
624
+ private async post(payload: string, signal?: AbortSignal): Promise<string> {
625
+ const endpoint = this.baseUrl.replace(/\/+$/, "") + "/systemone"
626
+ const retries = this.opts.retries ?? 2
627
+ const base = this.opts.retryBaseMs ?? 500
628
+ const classify = this.opts.onError ?? ((info: ErrorInfo): ErrorTier => (info.retryable ? "retry" : "fail"))
629
+ const doFetch = this.opts.fetch ?? fetch
630
+ let lastErr: unknown
631
+ for (let attempt = 0; ; attempt++) {
632
+ const timer = new AbortController()
633
+ const t = setTimeout(() => timer.abort(new Error(`classifier: request timeout after ${this.timeoutMs}ms`)), this.timeoutMs)
634
+ ;(t as any).unref?.()
635
+ const onOuter = () => timer.abort(signal!.reason)
636
+ if (signal) {
637
+ if (signal.aborted) {
638
+ clearTimeout(t)
639
+ throw signal.reason ?? new Error("aborted")
640
+ }
641
+ signal.addEventListener("abort", onOuter, { once: true })
642
+ }
643
+ let res: Response | undefined
644
+ let thrown: unknown
645
+ try {
646
+ res = await doFetch(endpoint, {
647
+ method: "POST",
648
+ headers: this.requestHeaders(),
649
+ body: payload,
650
+ signal: timer.signal,
651
+ })
652
+ } catch (e) {
653
+ thrown = e
654
+ } finally {
655
+ clearTimeout(t)
656
+ signal?.removeEventListener("abort", onOuter)
657
+ }
658
+ if (res && res.ok) return await res.text()
659
+ if (signal?.aborted) throw signal.reason ?? new Error("aborted") // caller cancellation is never retried
660
+ let retryAfter: string | null = null
661
+ if (res) {
662
+ const text = await res.text().catch(() => "")
663
+ retryAfter = res.headers.get("retry-after")
664
+ lastErr = new Error(`classifier: POST ${endpoint}: HTTP ${res.status}${cause(res.status, text)}`)
665
+ } else {
666
+ lastErr = new Error(`classifier: POST ${endpoint}: ${errText(thrown)}`)
667
+ }
668
+ const retryable = res ? res.status === 408 || isRetryableStatus(res.status, this.opts.retryableStatuses) : true
669
+ if (attempt >= retries) throw lastErr
670
+ const tier = classify(res ? { status: res.status, attempt, retryable } : { error: thrown, attempt, retryable })
671
+ if (tier !== "retry") throw lastErr
672
+ // `??`, not `||`: Retry-After: 0 means "retry now", not "no opinion".
673
+ await sleep(retryAfterMs(retryAfter) ?? base * 2 ** attempt)
674
+ }
675
+ }
676
+
677
+ /** Credential and `${ENV_VAR}` headers resolve AT CALL TIME. Never logged, never returned. */
678
+ private requestHeaders(): Record<string, string> {
679
+ const headers: Record<string, string> = { "Content-Type": "application/json" }
680
+ const key = process.env[this.apiKeyEnv]
681
+ if (key) headers["Authorization"] = `Bearer ${key}`
682
+ for (const [k, v] of Object.entries(this.opts.headers ?? {})) headers[k] = expandEnv(v)
683
+ return headers
684
+ }
685
+
686
+ /**
687
+ * The three question types rendered as ONE structured-output call on any §8 Client — the
688
+ * vendor-neutral fallback, so a host with no System One credential runs the same questions on
689
+ * a cheap chat model. `calibrated` is FALSE: the numbers are the model's self-report, not
690
+ * token probabilities.
691
+ */
692
+ private async evaluateLLM(
693
+ state: unknown,
694
+ questions: Record<string, Question>,
695
+ signal?: AbortSignal,
696
+ ): Promise<Decision> {
697
+ const qs: Record<string, unknown> = {}
698
+ for (const [key, q] of Object.entries(questions)) qs[key] = questionWire(q)
699
+ const prompt =
700
+ "Answer every question about the state below. Questions are INDEPENDENT: one answer is " +
701
+ "never context for another.\n\n" +
702
+ `STATE:\n${canonicalJson(state)}\n\nQUESTIONS:\n${canonicalJson(qs)}\n\n` +
703
+ "Reply with JSON only, no prose and no code fence, shaped exactly:\n" +
704
+ '{"answers":{"<key>":{"type":"noul","noul":0.0}}}\n' +
705
+ 'A "noul" answer is {"type":"noul","noul":<0..1>}. A "choice" answer is ' +
706
+ '{"type":"choice","choice":"<one offered option id>","probabilities":{"<every offered option id>":<0..1>},"confidence":<0..1>}. ' +
707
+ 'A "score" answer is {"type":"score","score":<a number within the rubric bounds, fractional allowed>,' +
708
+ '"legend":{"0":"<level 0>",…},"probabilities":{"0":<0..1>,…},"confidence":<0..1>}.'
709
+ const run = await this.opts.client!.run(prompt, { toolkit: EMPTY_TOOLKIT, signal })
710
+ const payload = firstJsonObject(run.text)
711
+ let parsed: unknown
712
+ try {
713
+ parsed = JSON.parse(payload)
714
+ } catch (e) {
715
+ // Never repaired: an unparseable answer is no answer (ADR 0020).
716
+ throw new Error(`classifier: llm: ${(e as Error).message}`)
717
+ }
718
+ const d = decodeDecision(parsed, this.model)
719
+ return new Decision(
720
+ d.model || this.model,
721
+ d.answers,
722
+ { inputTokens: run.usage.promptTokens, outputTokens: run.usage.completionTokens },
723
+ // The model reports no calibration and none is derived here (ADR 0020).
724
+ false,
725
+ )
726
+ }
727
+
728
+ // ---------------------------------------------------------------- metrics
729
+
730
+ private emit(ev: MetricEvent): void {
731
+ this.opts.onMetric?.(ev)
732
+ }
733
+
734
+ private emitError(start: number, e: unknown): void {
735
+ this.emit({
736
+ event: "classifier.evaluate",
737
+ model: this.model,
738
+ status: "error",
739
+ ms: Date.now() - start,
740
+ promptTokens: 0,
741
+ completionTokens: 0,
742
+ error: errText(e),
743
+ })
744
+ }
745
+ }
746
+
747
+ /** Builds a Classifier, applying the §8B defaults and rejecting a style whose required option
748
+ * is missing before any call is made. */
749
+ export function createClassifier(opts: ClassifierOptions = {}): Classifier {
750
+ return new Classifier(opts)
751
+ }
752
+
753
+ // ---------------------------------------------------------------- helpers
754
+
755
+ /**
756
+ * The §8B predicate. Degenerate ⇔ ANY of:
757
+ * 1. every value is empty (empty string or absent), or
758
+ * 2. every value equals its own key, or
759
+ * 3. every value is identical to every other value (n >= 2).
760
+ *
761
+ * A single-option choice (n === 1) is NEVER reported: there is nothing to differentiate. Returns
762
+ * the reason, or "" when the criteria are fine.
763
+ */
764
+ export function degenerateCriteria(criteria: Record<string, string>): string {
765
+ const entries = Object.entries(criteria ?? {})
766
+ if (entries.length < 2) return ""
767
+ if (entries.every(([, v]) => (v ?? "") === "")) return "every description is empty"
768
+ if (entries.every(([k, v]) => v === k)) return "every description is just its own option id"
769
+ const first = entries[0][1]
770
+ if (entries.every(([, v]) => v === first)) return "every description is identical"
771
+ return ""
772
+ }
773
+
774
+ /**
775
+ * Surfaces a backend's reported cause intact — EXCEPT on an authentication status: a 401/403
776
+ * body routinely reflects the credential or the header that was sent, so it never reaches a log,
777
+ * a metric, an error or a return value.
778
+ */
779
+ function cause(status: number, body: string): string {
780
+ if (status === 401 || status === 403) return ""
781
+ const s = body.trim()
782
+ if (!s) return ""
783
+ return ": " + (s.length > 200 ? s.slice(0, 200) + "…" : s)
784
+ }
785
+
786
+ /** `${ENV_VAR}` expands at call time; an unset var expands to the empty string, as §2. */
787
+ function expandEnv(value: string): string {
788
+ return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, name: string) => process.env[name] ?? "")
789
+ }
790
+
791
+ /** The outermost JSON object of a model reply, which may arrive wrapped in a fence or prose. */
792
+ function firstJsonObject(s: string): string {
793
+ const start = s.indexOf("{")
794
+ const end = s.lastIndexOf("}")
795
+ if (start < 0 || end <= start) throw new Error("classifier: llm: no JSON object in the reply")
796
+ return s.slice(start, end + 1)
797
+ }
798
+
799
+ function errText(e: unknown): string {
800
+ return e instanceof Error ? e.message : String(e)
801
+ }
802
+
803
+ function sleep(ms: number): Promise<void> {
804
+ // NOT unref'd, unlike the timeout watchdog above. This timer is the retry
805
+ // backoff we are about to await, so it is real pending work: unref'ing it
806
+ // tells Node the process need not stay alive for it, and the loop can resolve
807
+ // before the retry ever happens. The §8 client's `delay` has never unref'd for
808
+ // the same reason. Caught on Node 22, where it cancelled every test after the
809
+ // first one to exercise a classifier retry ("Promise resolution is still
810
+ // pending but the event loop has already resolved"); Node 24 happened to hide it.
811
+ return new Promise((resolve) => {
812
+ setTimeout(resolve, ms)
813
+ })
814
+ }
815
+
816
+ /**
817
+ * The `llm` style declares no tools — a classifier has no loop and executes nothing. The §8
818
+ * `run` takes a Toolkit, so this is the empty one; the cast is the price of not exposing a
819
+ * public "no tools" Toolkit constructor just for this path.
820
+ */
821
+ const EMPTY_TOOLKIT = {
822
+ skillsPrompt: () => "",
823
+ toOpenAI: () => [],
824
+ toAnthropic: () => [],
825
+ toGemini: () => [],
826
+ get: () => undefined,
827
+ list: () => [],
828
+ execute: async () => {
829
+ throw new Error("classifier: the llm style declares no tools")
830
+ },
831
+ close: async () => {},
832
+ } as unknown as Toolkit