toolnexus 0.25.0 → 0.27.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/classifier.ts CHANGED
@@ -9,9 +9,11 @@
9
9
  * the declared schema. Numeric limits, permission checks and allowlists stay in code. Nothing
10
10
  * here is a security control.
11
11
  */
12
+ import { createHash } from "node:crypto"
12
13
  import type { Client } from "./client.js"
13
14
  import { type ErrorInfo, type ErrorTier, type MetricEvent, redactErrorBody, capErrorBody } from "./client.js"
14
15
  import { retryAfterMs, isRetryableStatus } from "./retry.js"
16
+ import { isContentPart, type ContentPart } from "./content.js"
15
17
 
16
18
  // ---------------------------------------------------------------- constants
17
19
 
@@ -29,14 +31,121 @@ export const DEFAULT_BATCH_CONCURRENCY = 16
29
31
  /**
30
32
  * A named BACKEND: `baseUrl`, `model` and `apiKeyEnv` are only jointly valid, so they travel as
31
33
  * a UNIT (ADR 0027 D1). `jev-latest` is TypeSafe's spelling; the gateway spells the same model
32
- * `typesafe/jev-1.13`, and mixing the two is issue #91.
34
+ * `typesafe/jev-1.14`, and mixing the two is issue #91. `custom` is a self-hosted System One
35
+ * endpoint and requires `baseUrl` + `supports` (SPEC §8B "Backends, images, cache and validation").
33
36
  */
34
- export type ClassifierBackend = "typesafe" | "openrouter"
37
+ export type ClassifierBackend = "typesafe" | "openrouter" | "cloudflare" | "vercel" | "custom"
35
38
 
36
- /** The two servable pairings, as configurations rather than six independently-choosable cells. */
37
- export const CLASSIFIER_BACKENDS: Readonly<Record<ClassifierBackend, { baseUrl: string; model: string; apiKeyEnv: string }>> = {
38
- typesafe: { baseUrl: DEFAULT_CLASSIFIER_BASE_URL, model: DEFAULT_CLASSIFIER_MODEL, apiKeyEnv: DEFAULT_CLASSIFIER_API_KEY_ENV },
39
- openrouter: { baseUrl: "https://openrouter.ai/api/v1", model: "typesafe/jev-1.13", apiKeyEnv: "OPENROUTER_API_KEY" },
39
+ /** What a backend+model can take. DATA: a fail-fast for what the table says, never a claim about
40
+ * the vendor. Override per classifier with `ClassifierOptions.supports`. */
41
+ export interface ClassifierSupports {
42
+ images: boolean
43
+ }
44
+
45
+ /** One row of the backend table. */
46
+ export interface ClassifierBackendPreset {
47
+ /** `""` on `custom`, which requires `baseUrl`. May carry `${NAME}`, expanded at call time. */
48
+ baseUrl: string
49
+ model: string
50
+ apiKeyEnv: string
51
+ /** `systemone`: POST {baseUrl}/systemone. `cloudflare`: POST {baseUrl}, `/ai/run` envelope. */
52
+ wire: "systemone" | "cloudflare"
53
+ /** Send `TypeSafe-Danger-Zone-Images: true` when a request carries images. */
54
+ imagesHeader: boolean
55
+ /** The default support for a model on this backend; absent on `custom`. */
56
+ supports?: (model: string) => ClassifierSupports
57
+ }
58
+
59
+ /** TypeSafe's required opt-in header for image ("danger zone") input. */
60
+ export const CLASSIFIER_IMAGES_HEADER = "TypeSafe-Danger-Zone-Images"
61
+ /** Image limits (docs.typesafe.ai/images): checked client-side, never resized. */
62
+ export const CLASSIFIER_IMAGE_MIN_SIDE = 280
63
+ export const CLASSIFIER_IMAGE_MAX_SIDE = 1568
64
+ export const CLASSIFIER_IMAGE_MAX_BYTES = 16 * 1024 * 1024
65
+ export const CLASSIFIER_REQUEST_IMAGE_BYTES = 32 * 1024 * 1024
66
+
67
+ /**
68
+ * The backend table (PROPOSED defaults, owner to confirm — design.md PD-1..PD-4). Vercel and
69
+ * Cloudflare-Jev images stay false until a live probe proves them.
70
+ */
71
+ export const CLASSIFIER_BACKENDS: Readonly<Record<ClassifierBackend, Readonly<ClassifierBackendPreset>>> = Object.freeze({
72
+ typesafe: {
73
+ baseUrl: DEFAULT_CLASSIFIER_BASE_URL,
74
+ model: DEFAULT_CLASSIFIER_MODEL,
75
+ apiKeyEnv: DEFAULT_CLASSIFIER_API_KEY_ENV,
76
+ wire: "systemone",
77
+ imagesHeader: true,
78
+ supports: () => ({ images: true }),
79
+ },
80
+ openrouter: {
81
+ baseUrl: "https://openrouter.ai/api/v1",
82
+ model: "typesafe/jev-1.14",
83
+ apiKeyEnv: "OPENROUTER_API_KEY",
84
+ wire: "systemone",
85
+ imagesHeader: true,
86
+ supports: () => ({ images: true }),
87
+ },
88
+ cloudflare: {
89
+ baseUrl: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/run",
90
+ model: "typesafe/jev",
91
+ apiKeyEnv: "CLOUDFLARE_JEV_API_KEY",
92
+ wire: "cloudflare",
93
+ imagesHeader: false,
94
+ supports: (model: string) => ({ images: model.includes("clef") }),
95
+ },
96
+ vercel: {
97
+ baseUrl: "https://ai-gateway.vercel.sh/typesafe/v1",
98
+ model: "typesafe-ai/jev",
99
+ apiKeyEnv: "AI_GATEWAY_API_KEY",
100
+ wire: "systemone",
101
+ imagesHeader: true,
102
+ supports: () => ({ images: false }),
103
+ },
104
+ custom: {
105
+ baseUrl: "",
106
+ model: DEFAULT_CLASSIFIER_MODEL,
107
+ apiKeyEnv: "",
108
+ wire: "systemone",
109
+ imagesHeader: true,
110
+ },
111
+ })
112
+
113
+ /**
114
+ * The env var whose key may be sent to `baseUrl` (already `${NAME}`-expanded): the backend's own
115
+ * variable, and only when `baseUrl` is that backend's own host over https (OpenRouter also its
116
+ * subdomains). `""` when none may — `custom` has no preset key at all. A key is never sent to a
117
+ * host the caller did not name (#155, SPEC §8B).
118
+ */
119
+ export function classifierKeyEnv(backend: ClassifierBackend, baseUrl: string): string {
120
+ const p = Object.prototype.hasOwnProperty.call(CLASSIFIER_BACKENDS, backend) ? CLASSIFIER_BACKENDS[backend] : undefined
121
+ if (!p || !p.apiKeyEnv || !p.baseUrl) return ""
122
+ const hostOf = (u: string): string => {
123
+ try {
124
+ const url = new URL(u)
125
+ return url.protocol === "https:" ? url.hostname.toLowerCase() : ""
126
+ } catch {
127
+ return ""
128
+ }
129
+ }
130
+ const h = hostOf(baseUrl)
131
+ const own = hostOf(p.baseUrl)
132
+ if (!h || !own) return ""
133
+ return h === own || (backend === "openrouter" && h.endsWith(".openrouter.ai")) ? p.apiKeyEnv : ""
134
+ }
135
+
136
+ /**
137
+ * Raised BEFORE any network call when the backend table says the backend cannot take what the
138
+ * request carries. Message, exact: `classifier: <backend> (<model>) does not support <feature>`.
139
+ */
140
+ export class ClassifierUnsupported extends Error {
141
+ constructor(
142
+ readonly backend: string,
143
+ readonly model: string,
144
+ readonly feature: string,
145
+ ) {
146
+ super(`classifier: ${backend} (${model}) does not support ${feature}`)
147
+ this.name = "ClassifierUnsupported"
148
+ }
40
149
  }
41
150
 
42
151
  /** Client-side cap on a choice's named options (§8B). */
@@ -97,9 +206,36 @@ export interface ScoreQuestion {
97
206
  criteria: string[]
98
207
  }
99
208
 
100
- /** The closed set of question types, discriminated by `type`. */
209
+ /** The modelled question types, discriminated by `type`. */
101
210
  export type Question = NoulQuestion | ChoiceQuestion | ScoreQuestion
102
211
 
212
+ /**
213
+ * The OPEN end of the question union (§8B): a System One type the library does not model (e.g.
214
+ * Jev 1.14 `box`), sent as written with no client-side limits. `type` must be non-empty.
215
+ */
216
+ export interface RawQuestion {
217
+ type: string
218
+ instructions: string
219
+ /** Sent verbatim when present, absent otherwise. */
220
+ criteria?: unknown
221
+ }
222
+
223
+ /** Every question `evaluate` accepts: the modelled three plus a raw one. */
224
+ export type AnyQuestion = Question | RawQuestion
225
+
226
+ const MODELLED_TYPES = new Set(["noul", "choice", "score"])
227
+
228
+ /** True when `q` is a raw question (its type is not noul/choice/score). Not a type guard: every
229
+ * modelled question is structurally a `RawQuestion` too. */
230
+ export function isRawQuestion(q: AnyQuestion): boolean {
231
+ return !MODELLED_TYPES.has(q?.type as string)
232
+ }
233
+
234
+ /** A raw question, sent as written. */
235
+ export function raw(type: string, instructions: string, criteria?: unknown): RawQuestion {
236
+ return criteria === undefined ? { type, instructions } : { type, instructions, criteria }
237
+ }
238
+
103
239
  /** A noul question. Pass `criteria` only when you mean it — absent ≠ empty on the wire. */
104
240
  export function noul(instructions: string, criteria?: NoulCriteria): NoulQuestion {
105
241
  return criteria === undefined ? { type: "noul", instructions } : { type: "noul", instructions, criteria }
@@ -125,7 +261,14 @@ export function choiceOver(instructions: string, items: Record<string, string>):
125
261
  }
126
262
 
127
263
  /** The wire form of one question — the object itself, minus an absent `criteria`. */
128
- export function questionWire(q: Question): Record<string, unknown> {
264
+ export function questionWire(question: AnyQuestion): Record<string, unknown> {
265
+ if (isRawQuestion(question)) {
266
+ const r = question as RawQuestion
267
+ return r.criteria === undefined
268
+ ? { type: r.type, instructions: r.instructions }
269
+ : { type: r.type, instructions: r.instructions, criteria: r.criteria }
270
+ }
271
+ const q = question as Question
129
272
  switch (q.type) {
130
273
  case "noul":
131
274
  return q.criteria === undefined
@@ -139,7 +282,12 @@ export function questionWire(q: Question): Record<string, unknown> {
139
282
  }
140
283
 
141
284
  /** Client-side limits, enforced BEFORE the request. The error names the key and the limit. */
142
- function validateQuestion(key: string, q: Question): void {
285
+ function validateQuestion(key: string, question: AnyQuestion): void {
286
+ const q = question as Question
287
+ if (isRawQuestion(question)) {
288
+ if (!question?.type) throw new Error(`classifier: question "${key}": a raw question needs a type`)
289
+ return
290
+ }
143
291
  if (q.type === "choice") {
144
292
  const n = Object.keys(q.criteria ?? {}).length
145
293
  if (n < 1 || n > MAX_CHOICE_OPTIONS) {
@@ -192,7 +340,7 @@ export function canonicalJson(value: unknown): string {
192
340
  * ways across our own seven runtimes). Do not re-widen this: a caller who needs their state
193
341
  * pinned canonicalises it themselves before handing it over.
194
342
  */
195
- export function canonicalRequest(model: string, questions: Record<string, Question>): string {
343
+ export function canonicalRequest(model: string, questions: Record<string, AnyQuestion>): string {
196
344
  const qs: Record<string, unknown> = {}
197
345
  for (const [key, q] of Object.entries(questions)) {
198
346
  if (!q) throw new Error(`classifier: question "${key}" is missing`)
@@ -241,6 +389,22 @@ export interface ScoreAnswer {
241
389
  */
242
390
  export type DecisionAnswer = NoulAnswer | ChoiceAnswer | ScoreAnswer
243
391
 
392
+ /**
393
+ * An answer of a type the library does not model (the open union, §8B). It may appear in
394
+ * `Decision.answers` at runtime (read it with `decision.raw(key)` or `isRawAnswer`); `value` is the
395
+ * whole answer object as decoded, `type` included. It is kept out of `DecisionAnswer` so existing
396
+ * `switch (a.type)` narrowing keeps compiling.
397
+ */
398
+ export interface RawAnswer {
399
+ type: string
400
+ value: Record<string, unknown>
401
+ }
402
+
403
+ /** True when an answer is a raw (unmodelled-type) answer. */
404
+ export function isRawAnswer(a: unknown): a is RawAnswer {
405
+ return !!a && typeof a === "object" && !MODELLED_TYPES.has((a as any).type) && "value" in (a as any)
406
+ }
407
+
244
408
  /** The wire's usage block. `cost` is absent on some backends. */
245
409
  export interface ClassifierUsage {
246
410
  inputTokens: number
@@ -271,8 +435,18 @@ export class Decision {
271
435
  * BACKEND DOES NOT TRANSFER TO ANOTHER.
272
436
  */
273
437
  readonly calibrated: boolean,
438
+ /** True when the decision came from `ClassifierOptions.cache` and no request was sent. */
439
+ readonly cached: boolean = false,
274
440
  ) {}
275
441
 
442
+ /** Typed read of a raw (unmodelled-type) answer. Throws on an absent key or a modelled type. */
443
+ raw(key: string): RawAnswer {
444
+ const a: unknown = this.answers[key]
445
+ if (a === undefined) throw new Error(`classifier: no answer "${key}" in this decision`)
446
+ if (!isRawAnswer(a)) throw new Error(`classifier: answer "${key}" is a ${(a as any).type} answer, not raw`)
447
+ return a
448
+ }
449
+
276
450
  /** Typed read. Throws (never returns undefined) on a wrong-type or absent key. */
277
451
  noul(key: string): NoulAnswer {
278
452
  return this.typed(key, "noul")
@@ -345,7 +519,9 @@ export function decodeDecision(raw: unknown, fallbackModel: string): Decision {
345
519
  }
346
520
  break
347
521
  default:
348
- throw new Error(`classifier: answer "${key}": unknown type ${JSON.stringify(a.type)}`)
522
+ // The union is OPEN (§8B): an unmodelled type (e.g. Jev 1.14 "box") decodes to a raw
523
+ // answer instead of failing the whole decision.
524
+ ;(answers as Record<string, unknown>)[key] = { type: String(a.type ?? ""), value: { ...a } } satisfies RawAnswer
349
525
  }
350
526
  }
351
527
  const usage: ClassifierUsage = {
@@ -376,7 +552,12 @@ function strings(v: unknown): Record<string, string> {
376
552
 
377
553
  // ---------------------------------------------------------------- options
378
554
 
379
- /** `"systemone" | "llm" | "custom" | "static"` — the same slot as the client's `style`. */
555
+ /**
556
+ * `"systemone" | "llm" | "custom" | "static"` — the same slot as the client's `style`.
557
+ *
558
+ * `"llm"` is DEPRECATED (ADR 0038 D3: System One is the single product wire). It keeps working
559
+ * byte-identically, as an alias, for at least one minor release; there is no runtime warning.
560
+ */
380
561
  export type ClassifierStyle = "systemone" | "llm" | "custom" | "static"
381
562
 
382
563
  /**
@@ -387,7 +568,7 @@ export type ClassifierStyle = "systemone" | "llm" | "custom" | "static"
387
568
  */
388
569
  export interface RecordedDecision {
389
570
  state: unknown
390
- questions: Record<string, Question>
571
+ questions: Record<string, AnyQuestion>
391
572
  /** The recorded backend response body, verbatim. */
392
573
  response: unknown
393
574
  }
@@ -395,7 +576,7 @@ export interface RecordedDecision {
395
576
  /** The host's own backend (`style: "custom"`). Every wire option is ignored. */
396
577
  export type EvaluateFn = (
397
578
  state: unknown,
398
- questions: Record<string, Question>,
579
+ questions: Record<string, AnyQuestion>,
399
580
  signal?: AbortSignal,
400
581
  ) => Promise<Decision> | Decision
401
582
 
@@ -406,7 +587,7 @@ export type EvaluateFn = (
406
587
  export interface ClassifierOptions {
407
588
  /** Default `"systemone"`. */
408
589
  style?: ClassifierStyle
409
- /** A named `baseUrl` + `model` + `apiKeyEnv` pairing (`"typesafe"` | `"openrouter"`), set as a
590
+ /** A named backend row (`"typesafe"` | `"openrouter"` | `"cloudflare"` | `"vercel"` | `"custom"`), set as a
410
591
  * UNIT. Default `"typesafe"`. The three individual options still win where given, for a
411
592
  * self-hosted origin — but a known-bad mixture is refused at construction, not 700 ms later
412
593
  * as an "Unknown model" 400. */
@@ -451,12 +632,37 @@ export interface ClassifierOptions {
451
632
  /** Emits `classifier.evaluate` events into the SAME §8 sink, and carries the
452
633
  * degenerate-criteria warning as `classifier.warning`. */
453
634
  onMetric?: (ev: MetricEvent) => void
454
- /** `style: "llm"` only — the §8 Client to emulate over. */
635
+ /** `style: "llm"` only — the §8 Client to emulate over.
636
+ * @deprecated `style: "llm"` is deprecated (System One is the single wire); it still works. */
455
637
  client?: Client
456
638
  /** `style: "custom"` only — the host's own function. */
457
639
  evaluate?: EvaluateFn
458
640
  /** `style: "static"` only — the recorded corpus. */
459
641
  decisions?: RecordedDecision[]
642
+ /** Overrides the backend table's support row for this classifier. Required with `backend: "custom"`. */
643
+ supports?: ClassifierSupports
644
+ /** Opt-in answer cache (§8B). Absent ⇒ no caching. Consulted by style `systemone` only; only
645
+ * replies that pass `validateDecision` are stored. Methods may return values or Promises. */
646
+ cache?: ClassifierCache
647
+ }
648
+
649
+ /** An injected answer cache. Values are opaque strings: the served model under a model key, the
650
+ * System One reply body under a decision key. */
651
+ export interface ClassifierCache {
652
+ get(key: string): string | undefined | null | Promise<string | undefined | null>
653
+ set(key: string, value: string): void | Promise<void>
654
+ }
655
+
656
+ /** One state's outcome from `states(...).evaluate`: exactly one of `decision` / `error` is set. */
657
+ export interface BatchResult {
658
+ decision?: Decision
659
+ error?: Error
660
+ }
661
+
662
+ /** A named question (`judge.noul(...)` etc. build these). */
663
+ export interface NamedClassifierQuestion {
664
+ name: string
665
+ question: AnyQuestion
460
666
  }
461
667
 
462
668
  // ---------------------------------------------------------------- classifier
@@ -467,7 +673,13 @@ export class Classifier {
467
673
  private readonly baseUrl: string
468
674
  private readonly model: string
469
675
  private readonly apiKeyEnv: string
676
+ /** The caller NAMED apiKeyEnv: sent wherever baseUrl points. Otherwise the preset's own key,
677
+ * and only to its own host (classifierKeyEnv, #155). */
678
+ private readonly explicitKeyEnv: boolean
470
679
  private readonly timeoutMs: number
680
+ private readonly backend: ClassifierBackend
681
+ private readonly preset: Readonly<ClassifierBackendPreset>
682
+ private readonly supports: ClassifierSupports
471
683
  private readonly corpus = new Map<string, unknown>()
472
684
  /** The once-per-question-key set for the degenerate warning, so a per-turn judge does not
473
685
  * flood the sink. */
@@ -475,13 +687,25 @@ export class Classifier {
475
687
 
476
688
  constructor(private readonly opts: ClassifierOptions) {
477
689
  this.style = opts.style ?? "systemone"
478
- const preset = CLASSIFIER_BACKENDS[opts.backend ?? "typesafe"]
479
- this.baseUrl = opts.baseUrl ?? preset.baseUrl
690
+ this.backend = opts.backend ?? "typesafe"
691
+ if (!Object.prototype.hasOwnProperty.call(CLASSIFIER_BACKENDS, this.backend)) {
692
+ throw new Error(
693
+ `classifier: unknown backend "${this.backend}" (want "typesafe", "openrouter", "cloudflare", "vercel" or "custom")`,
694
+ )
695
+ }
696
+ const preset = CLASSIFIER_BACKENDS[this.backend]
697
+ this.preset = preset
698
+ if (this.backend === "custom" && this.style === "systemone" && (!opts.baseUrl || !opts.supports)) {
699
+ throw new Error('classifier: backend "custom" requires baseUrl and supports')
700
+ }
701
+ this.baseUrl = opts.baseUrl || preset.baseUrl
480
702
  this.model = opts.model ?? preset.model
481
703
  this.apiKeyEnv = opts.apiKeyEnv ?? preset.apiKeyEnv
704
+ this.explicitKeyEnv = opts.apiKeyEnv !== undefined
482
705
  const mismatch = backendMismatch(this.baseUrl, this.model)
483
706
  if (mismatch) throw new Error(mismatch)
484
707
  this.timeoutMs = opts.timeoutMs ?? DEFAULT_CLASSIFIER_TIMEOUT_MS
708
+ this.supports = opts.supports ? { ...opts.supports } : (preset.supports?.(this.model) ?? { images: false })
485
709
  switch (this.style) {
486
710
  case "systemone":
487
711
  break
@@ -511,12 +735,67 @@ export class Classifier {
511
735
  * The whole contract: a state plus typed questions in, a Decision out. Questions are
512
736
  * INDEPENDENT — one answer is never context for another.
513
737
  */
514
- async evaluate(state: unknown, questions: Record<string, Question>, signal?: AbortSignal): Promise<Decision> {
738
+ async evaluate(state: unknown, questions: Record<string, AnyQuestion>, signal?: AbortSignal): Promise<Decision> {
739
+ return this.run(state, questions, signal, false)
740
+ }
741
+
742
+ /**
743
+ * Binds one state (a VALUE, not a session: nothing is sent until `evaluate`). Its `evaluate`
744
+ * runs the same path as `evaluate` PLUS fail-closed validation (`validateDecision`).
745
+ */
746
+ state(value: unknown, opts: { signal?: AbortSignal } = {}): { evaluate(...questions: NamedClassifierQuestion[]): Promise<Decision> } {
747
+ return {
748
+ evaluate: async (...qs: NamedClassifierQuestion[]) => this.run(value, namedQuestions(qs), opts.signal, true),
749
+ }
750
+ }
751
+
752
+ /**
753
+ * Binds a batch of states. Its `evaluate` runs `state(v).evaluate` per state, at most 16 in
754
+ * flight, and returns one `{decision | error}` per state IN ORDER. One state's failure never
755
+ * fails the batch; only a question-set error (duplicate name) throws, before any request.
756
+ */
757
+ states(...values: unknown[]): { evaluate(...questions: NamedClassifierQuestion[]): Promise<BatchResult[]> } {
758
+ return {
759
+ evaluate: async (...qs: NamedClassifierQuestion[]) => {
760
+ const questions = namedQuestions(qs)
761
+ const out: BatchResult[] = new Array(values.length)
762
+ let next = 0
763
+ const worker = async () => {
764
+ while (next < values.length) {
765
+ const i = next++
766
+ try {
767
+ out[i] = { decision: await this.run(values[i], questions, undefined, true) }
768
+ } catch (e) {
769
+ out[i] = { error: e instanceof Error ? e : new Error(String(e)) }
770
+ }
771
+ }
772
+ }
773
+ await Promise.all(Array.from({ length: Math.min(DEFAULT_BATCH_CONCURRENCY, values.length) }, worker))
774
+ return out
775
+ },
776
+ }
777
+ }
778
+
779
+ /** The one path. `validate` = fail closed on `validateDecision` (the value builders). */
780
+ private async run(
781
+ state: unknown,
782
+ questions: Record<string, AnyQuestion>,
783
+ signal: AbortSignal | undefined,
784
+ validate: boolean,
785
+ ): Promise<Decision> {
515
786
  const start = Date.now()
516
787
  const keys = Object.keys(questions ?? {}).sort()
788
+ // L1: count + hash of the canonical request on every evaluate event ("" when unencodable).
789
+ let qHash = ""
790
+ try {
791
+ qHash = sha256Hex(canonicalRequest(this.model, questions ?? {}))
792
+ } catch {
793
+ qHash = ""
794
+ }
795
+ const l1 = { questions: keys.length, questionsHash: qHash }
517
796
  if (keys.length === 0) {
518
797
  const err = new Error("classifier: no questions to evaluate")
519
- this.emitError(start, err)
798
+ this.emitError(start, err, l1)
520
799
  throw err
521
800
  }
522
801
  try {
@@ -529,7 +808,7 @@ export class Classifier {
529
808
  validateQuestion(key, q)
530
809
  }
531
810
  } catch (e) {
532
- this.emitError(start, e)
811
+ this.emitError(start, e, l1)
533
812
  throw e
534
813
  }
535
814
  // Detection, never repair (ADR 0020/0021): the request goes out BYTE-UNCHANGED and the
@@ -549,10 +828,14 @@ export class Classifier {
549
828
  decision = await this.evaluateLLM(state, questions, signal)
550
829
  break
551
830
  default:
552
- decision = await this.evaluateSystemOne(state, questions, signal)
831
+ decision = await this.evaluateWire(state, questions, signal, validate)
832
+ }
833
+ if (validate && this.style !== "systemone") {
834
+ const verr = validateDecision(questions, decision)
835
+ if (verr) throw verr
553
836
  }
554
837
  } catch (e) {
555
- this.emitError(start, e)
838
+ this.emitError(start, e, l1)
556
839
  throw e
557
840
  }
558
841
  this.emit({
@@ -562,6 +845,8 @@ export class Classifier {
562
845
  ms: Date.now() - start,
563
846
  promptTokens: decision.usage.inputTokens,
564
847
  completionTokens: decision.usage.outputTokens,
848
+ ...l1,
849
+ cached: decision.cached === true,
565
850
  })
566
851
  return decision
567
852
  }
@@ -574,7 +859,7 @@ export class Classifier {
574
859
  */
575
860
  async evaluateBatch(
576
861
  states: readonly unknown[],
577
- questions: Record<string, Question>,
862
+ questions: Record<string, AnyQuestion>,
578
863
  opts: { signal?: AbortSignal; concurrency?: number } = {},
579
864
  ): Promise<Decision[]> {
580
865
  if (!states || states.length === 0) throw new Error("classifier: no states to evaluate")
@@ -606,9 +891,9 @@ export class Classifier {
606
891
  * nothing about the request. Repairing would invent option descriptions the caller did not
607
892
  * write, and the library has no way to know what the options mean.
608
893
  */
609
- private reportDegenerate(keys: string[], questions: Record<string, Question>): void {
894
+ private reportDegenerate(keys: string[], questions: Record<string, AnyQuestion>): void {
610
895
  for (const key of keys) {
611
- const q = questions[key]
896
+ const q = questions[key] as Question
612
897
  if (q.type !== "choice") continue
613
898
  const reason = degenerateCriteria(q.criteria)
614
899
  if (!reason || this.warned.has(key)) continue
@@ -627,14 +912,22 @@ export class Classifier {
627
912
  // ---------------------------------------------------------------- backends
628
913
 
629
914
  /**
630
- * Assembles the request: the canonical model + questions, plus state VERBATIM as the host
631
- * supplied it, then the §8 Gap 1 pipeline in §8 order (base → requestParams merge →
632
- * bodyTransform → marshal).
915
+ * Assembles the request for the backend's wire: the canonical model + questions, plus the state
916
+ * (VERBATIM unless it carried content parts) and, on systemone, `assets`; then the §8 Gap 1
917
+ * pipeline in §8 order (base → requestParams merge → bodyTransform → marshal).
633
918
  */
634
- private body(state: unknown, questions: Record<string, Question>): string {
919
+ private body(p: PreparedRequest, questions: Record<string, AnyQuestion>): string {
635
920
  const qs: Record<string, unknown> = {}
636
921
  for (const [key, q] of Object.entries(questions)) qs[key] = questionWire(q)
637
- let body: Record<string, any> = { model: this.model, questions: qs, state }
922
+ let body: Record<string, any>
923
+ if (this.preset.wire === "cloudflare") {
924
+ const input: Record<string, unknown> = { questions: qs, state: p.state }
925
+ if (p.images.length > 0) input.images = p.images.map((im) => `data:image/${im.format};base64,${im.data}`)
926
+ body = { model: this.model, input }
927
+ } else {
928
+ body = { model: this.model, questions: qs, state: p.state }
929
+ if (p.assets) body.assets = p.assets
930
+ }
638
931
  // A requestParams key WINS on collision.
639
932
  if (this.opts.requestParams) body = { ...body, ...this.opts.requestParams }
640
933
  if (this.opts.bodyTransform) body = this.opts.bodyTransform(body) || body
@@ -642,29 +935,76 @@ export class Classifier {
642
935
  }
643
936
 
644
937
  /** Identifies a recorded decision by the canonical request AND the state. */
645
- private staticKey(state: unknown, questions: Record<string, Question>): string {
938
+ private staticKey(state: unknown, questions: Record<string, AnyQuestion>): string {
646
939
  return canonicalRequest(this.model, questions) + "\u0000" + canonicalJson(state)
647
940
  }
648
941
 
649
- private evaluateStatic(state: unknown, questions: Record<string, Question>): Decision {
942
+ private evaluateStatic(state: unknown, questions: Record<string, AnyQuestion>): Decision {
650
943
  const raw = this.corpus.get(this.staticKey(state, questions))
651
944
  if (raw === undefined) throw new Error("classifier: static: no recorded decision for this request+state")
652
945
  return decodeDecision(raw, this.model)
653
946
  }
654
947
 
655
- private async evaluateSystemOne(
948
+ /** Lift images, apply the support table and the limits — all before any request. */
949
+ private prepare(state: unknown): PreparedRequest {
950
+ const { state: sent, images } = liftImages(state)
951
+ if (images.length > 0 && !this.supports.images) throw new ClassifierUnsupported(this.backend, this.model, "images")
952
+ checkImages(images)
953
+ let assets: Record<string, unknown> | undefined
954
+ if (images.length > 0) {
955
+ assets = {}
956
+ for (const im of images) assets[im.ref] = { type: "image", format: im.format, data_base64: im.data }
957
+ }
958
+ return { state: sent, images, assets }
959
+ }
960
+
961
+ /** The systemone style on every backend: lift, check, cache, post, decode. */
962
+ private async evaluateWire(
656
963
  state: unknown,
657
- questions: Record<string, Question>,
658
- signal?: AbortSignal,
964
+ questions: Record<string, AnyQuestion>,
965
+ signal: AbortSignal | undefined,
966
+ validate: boolean,
659
967
  ): Promise<Decision> {
660
- const text = await this.post(this.body(state, questions), signal)
968
+ const p = this.prepare(state)
969
+ const cache = this.opts.cache
970
+ let modelKey = ""
971
+ if (cache) {
972
+ modelKey = classifierModelCacheKey(this.baseUrl, this.model)
973
+ const served = await cache.get(modelKey)
974
+ if (typeof served === "string") {
975
+ const hit = await cache.get(classifierDecisionCacheKey(this.baseUrl, served, p.state, p.assets, questions))
976
+ if (typeof hit === "string") {
977
+ try {
978
+ const d = decodeDecision(JSON.parse(hit), served)
979
+ if (!validateDecision(questions, d)) return new Decision(d.model, d.answers, d.usage, d.calibrated, true)
980
+ } catch {
981
+ // a corrupt entry is a miss
982
+ }
983
+ }
984
+ }
985
+ }
986
+ const extra: Record<string, string> =
987
+ p.images.length > 0 && this.preset.imagesHeader ? { [CLASSIFIER_IMAGES_HEADER]: "true" } : {}
988
+ let text = await this.post(this.body(p, questions), signal, extra)
661
989
  let parsed: unknown
662
990
  try {
663
991
  parsed = JSON.parse(text)
664
992
  } catch (e) {
665
993
  throw new Error(`classifier: response was not JSON: ${(e as Error).message}`)
666
994
  }
667
- return decodeDecision(parsed, this.model)
995
+ if (this.preset.wire === "cloudflare") {
996
+ parsed = unwrapCloudflare(parsed)
997
+ text = canonicalJson(parsed)
998
+ }
999
+ const d = decodeDecision(parsed, this.model)
1000
+ const verr = validateDecision(questions, d)
1001
+ if (validate && verr) throw verr
1002
+ if (cache && !verr) {
1003
+ const served = d.model || this.model
1004
+ await cache.set(modelKey, served)
1005
+ await cache.set(classifierDecisionCacheKey(this.baseUrl, served, p.state, p.assets, questions), text)
1006
+ }
1007
+ return d
668
1008
  }
669
1009
 
670
1010
  /**
@@ -676,8 +1016,12 @@ export class Classifier {
676
1016
  * is never echoed back (a gateway happily reflects a bad Authorization header into its own
677
1017
  * 401 text).
678
1018
  */
679
- private async post(payload: string, signal?: AbortSignal): Promise<string> {
680
- const endpoint = this.baseUrl.replace(/\/+$/, "") + "/systemone"
1019
+ private async post(payload: string, signal?: AbortSignal, extraHeaders: Record<string, string> = {}): Promise<string> {
1020
+ // `endpoint` is what errors name: the CONFIGURED base, ${ENV} unexpanded, so an account id in
1021
+ // a templated URL never reaches an error. `url` is what is dialled, expanded at call time.
1022
+ let endpoint = this.baseUrl.replace(/\/+$/, "")
1023
+ if (this.preset.wire !== "cloudflare") endpoint += "/systemone"
1024
+ const url = endpoint.includes("${") ? expandEnv(endpoint) : endpoint
681
1025
  const retries = this.opts.retries ?? 2
682
1026
  const base = this.opts.retryBaseMs ?? 500
683
1027
  const classify = this.opts.onError ?? ((info: ErrorInfo): ErrorTier => (info.retryable ? "retry" : "fail"))
@@ -700,9 +1044,9 @@ export class Classifier {
700
1044
  let res: Response | undefined
701
1045
  let thrown: unknown
702
1046
  try {
703
- res = await doFetch(endpoint, {
1047
+ res = await doFetch(url, {
704
1048
  method: "POST",
705
- headers: this.requestHeaders(),
1049
+ headers: this.requestHeaders(extraHeaders, url),
706
1050
  body: payload,
707
1051
  signal: timer.signal,
708
1052
  })
@@ -720,7 +1064,7 @@ export class Classifier {
720
1064
  retryAfter = res.headers.get("retry-after")
721
1065
  lastErr = new Error(`classifier: POST ${endpoint}: HTTP ${res.status}${cause(res.status, text)}`)
722
1066
  } else {
723
- lastErr = new Error(`classifier: POST ${endpoint}: ${errText(thrown)}`)
1067
+ lastErr = new Error(`classifier: POST ${endpoint}: ${errText(thrown).split(url).join(endpoint)}`)
724
1068
  }
725
1069
  const retryable = res ? res.status === 408 || isRetryableStatus(res.status, this.opts.retryableStatuses) : true
726
1070
  if (attempt >= retries) throw lastErr
@@ -732,10 +1076,13 @@ export class Classifier {
732
1076
  }
733
1077
 
734
1078
  /** Credential and `${ENV_VAR}` headers resolve AT CALL TIME. Never logged, never returned. */
735
- private requestHeaders(): Record<string, string> {
1079
+ private requestHeaders(extra: Record<string, string>, url: string): Record<string, string> {
736
1080
  const headers: Record<string, string> = { "Content-Type": "application/json" }
737
- const key = process.env[this.apiKeyEnv]
1081
+ const keyEnv = this.explicitKeyEnv ? this.apiKeyEnv : classifierKeyEnv(this.backend, url)
1082
+ const key = keyEnv ? process.env[keyEnv] : undefined
738
1083
  if (key) headers["Authorization"] = `Bearer ${key}`
1084
+ // The image opt-in header goes BEFORE host headers, which win.
1085
+ for (const [k, v] of Object.entries(extra)) headers[k] = v
739
1086
  for (const [k, v] of Object.entries(this.opts.headers ?? {})) headers[k] = expandEnv(v)
740
1087
  return headers
741
1088
  }
@@ -748,7 +1095,7 @@ export class Classifier {
748
1095
  */
749
1096
  private async evaluateLLM(
750
1097
  state: unknown,
751
- questions: Record<string, Question>,
1098
+ questions: Record<string, AnyQuestion>,
752
1099
  signal?: AbortSignal,
753
1100
  ): Promise<Decision> {
754
1101
  const qs: Record<string, unknown> = {}
@@ -789,7 +1136,7 @@ export class Classifier {
789
1136
  this.opts.onMetric?.(ev)
790
1137
  }
791
1138
 
792
- private emitError(start: number, e: unknown): void {
1139
+ private emitError(start: number, e: unknown, l1: { questions: number; questionsHash: string }): void {
793
1140
  this.emit({
794
1141
  event: "classifier.evaluate",
795
1142
  model: this.model,
@@ -798,6 +1145,8 @@ export class Classifier {
798
1145
  promptTokens: 0,
799
1146
  completionTokens: 0,
800
1147
  error: errText(e),
1148
+ ...l1,
1149
+ cached: false,
801
1150
  })
802
1151
  }
803
1152
  }
@@ -883,3 +1232,330 @@ function sleep(ms: number): Promise<void> {
883
1232
  })
884
1233
  }
885
1234
 
1235
+
1236
+ // ---------------------------------------------------------------- backends, images, cache, validation
1237
+ // SPEC.md §8B "Backends, images, cache and validation" (change add-classifier-backends, ADR 0038).
1238
+ // Every default here is a PROPOSED default, owner to confirm (design.md PD-1..PD-15).
1239
+
1240
+ /** Ordered named questions -> the questions map. A duplicate name is an error naming it. */
1241
+ function namedQuestions(qs: readonly NamedClassifierQuestion[]): Record<string, AnyQuestion> {
1242
+ const out: Record<string, AnyQuestion> = {}
1243
+ for (const { name, question } of qs) {
1244
+ if (Object.prototype.hasOwnProperty.call(out, name)) throw new Error(`duplicate question name ${JSON.stringify(name)}`)
1245
+ out[name] = question
1246
+ }
1247
+ return out
1248
+ }
1249
+
1250
+ function sha256Hex(s: string): string {
1251
+ return createHash("sha256").update(s, "utf8").digest("hex")
1252
+ }
1253
+
1254
+ interface LiftedImage {
1255
+ ref: string
1256
+ /** The MIME type until `checkImages` normalises it to `png` | `jpeg`. */
1257
+ format: string
1258
+ data: string
1259
+ }
1260
+
1261
+ interface PreparedRequest {
1262
+ state: unknown
1263
+ images: LiftedImage[]
1264
+ assets?: Record<string, unknown>
1265
+ }
1266
+
1267
+ function containsPart(v: unknown): boolean {
1268
+ if (Array.isArray(v)) return v.some(containsPart)
1269
+ if (v !== null && typeof v === "object") {
1270
+ if (isContentPart(v)) return true
1271
+ return Object.values(v as Record<string, unknown>).some(containsPart)
1272
+ }
1273
+ return false
1274
+ }
1275
+
1276
+ /**
1277
+ * The §8B lifting walk. A state with no content part is returned VERBATIM (the same value), so a
1278
+ * text-only state is byte-identical to before. Otherwise depth-first, object keys in ASCII order,
1279
+ * arrays in order: an image part with inline data becomes a reference (its key when unused, else
1280
+ * the smallest unused `image<n>`), a text part becomes its text, any other part is an error.
1281
+ */
1282
+ function liftImages(state: unknown): { state: unknown; images: LiftedImage[] } {
1283
+ if (!containsPart(state)) return { state, images: [] }
1284
+ const used = new Set<string>()
1285
+ const images: LiftedImage[] = []
1286
+ const nextRef = () => {
1287
+ for (let n = 1; ; n++) if (!used.has(`image${n}`)) return `image${n}`
1288
+ }
1289
+ const walk = (v: unknown, key: string): unknown => {
1290
+ if (Array.isArray(v)) return v.map((e) => walk(e, ""))
1291
+ if (v === null || typeof v !== "object") return v
1292
+ if (isContentPart(v)) {
1293
+ const part = v as ContentPart
1294
+ if (part.type === "text") return part.text
1295
+ if (part.type === "image") {
1296
+ if (!part.data) throw new Error("classifier: state: an image part needs inline data (a URL image cannot be sent)")
1297
+ const ref = key === "" || used.has(key) ? nextRef() : key
1298
+ used.add(ref)
1299
+ images.push({ ref, format: part.mimeType, data: part.data })
1300
+ return ref
1301
+ }
1302
+ throw new Error(`classifier: state: ${part.type} parts are not supported`)
1303
+ }
1304
+ const obj = v as Record<string, unknown>
1305
+ const out: Record<string, unknown> = {}
1306
+ for (const k of Object.keys(obj).sort()) out[k] = walk(obj[k], k)
1307
+ return out
1308
+ }
1309
+ return { state: walk(state, ""), images }
1310
+ }
1311
+
1312
+ const IMAGE_FORMATS: Record<string, string> = { "image/png": "png", "image/jpeg": "jpeg", "image/jpg": "jpeg" }
1313
+
1314
+ /** Format, per-image and per-request limits, in collection order; normalises `format`. */
1315
+ function checkImages(images: LiftedImage[]): void {
1316
+ let total = 0
1317
+ for (const im of images) {
1318
+ const format = IMAGE_FORMATS[im.format]
1319
+ if (!format) throw new Error(`classifier: image "${im.ref}": type "${im.format}" is not supported (PNG or JPEG only)`)
1320
+ im.format = format
1321
+ if (im.data.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(im.data)) {
1322
+ throw new Error(`classifier: image "${im.ref}": data is not base64`)
1323
+ }
1324
+ const raw = Buffer.from(im.data, "base64")
1325
+ if (raw.length > CLASSIFIER_IMAGE_MAX_BYTES) {
1326
+ throw new Error(`classifier: image "${im.ref}": ${raw.length} bytes, over 16 MiB`)
1327
+ }
1328
+ const size = imageSize(format, raw)
1329
+ if (!size) throw new Error(`classifier: image "${im.ref}": not a readable ${format.toUpperCase()}`)
1330
+ const [w, h] = size
1331
+ const ok = (n: number) => n >= CLASSIFIER_IMAGE_MIN_SIDE && n <= CLASSIFIER_IMAGE_MAX_SIDE
1332
+ if (!ok(w) || !ok(h)) {
1333
+ throw new Error(
1334
+ `classifier: image "${im.ref}": ${w}x${h}; each side must be ${CLASSIFIER_IMAGE_MIN_SIDE}..${CLASSIFIER_IMAGE_MAX_SIDE} px`,
1335
+ )
1336
+ }
1337
+ total += raw.length
1338
+ }
1339
+ if (total > CLASSIFIER_REQUEST_IMAGE_BYTES) {
1340
+ throw new Error(`classifier: image "${images[images.length - 1].ref}": request images total ${total} bytes, over 32 MiB`)
1341
+ }
1342
+ }
1343
+
1344
+ /** Width/height from a PNG IHDR or the first JPEG SOF marker; undefined when unreadable. */
1345
+ function imageSize(format: string, b: Uint8Array): [number, number] | undefined {
1346
+ const be16 = (i: number) => (b[i] << 8) | b[i + 1]
1347
+ const be32 = (i: number) => ((b[i] << 24) >>> 0) + (b[i + 1] << 16) + (b[i + 2] << 8) + b[i + 3]
1348
+ if (format === "png") {
1349
+ const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
1350
+ if (b.length < 24 || sig.some((x, i) => b[i] !== x)) return undefined
1351
+ if (String.fromCharCode(b[12], b[13], b[14], b[15]) !== "IHDR") return undefined
1352
+ return [be32(16), be32(20)]
1353
+ }
1354
+ if (b.length < 4 || b[0] !== 0xff || b[1] !== 0xd8) return undefined
1355
+ let i = 2
1356
+ while (i + 3 < b.length) {
1357
+ if (b[i] !== 0xff) return undefined
1358
+ const m = b[i + 1]
1359
+ if (m === 0xff) {
1360
+ i++
1361
+ continue
1362
+ }
1363
+ if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) {
1364
+ if (i + 8 >= b.length) return undefined
1365
+ return [be16(i + 7), be16(i + 5)]
1366
+ }
1367
+ i += 2 + be16(i + 2)
1368
+ }
1369
+ return undefined
1370
+ }
1371
+
1372
+ /**
1373
+ * `: <code> <message>` from errors[0] of a failed envelope (SPEC §8B), through the same redaction
1374
+ * and cap as cause(). Cloudflare reports a missing balance, a bad model id and a malformed
1375
+ * request alike as HTTP 200 + success:false, so this is the only thing that tells them apart.
1376
+ * `""` when there is nothing to show.
1377
+ */
1378
+ function cloudflareCause(env: unknown): string {
1379
+ const errs = env !== null && typeof env === "object" ? (env as any).errors : undefined
1380
+ const e0 = Array.isArray(errs) ? errs[0] : undefined
1381
+ if (e0 === null || typeof e0 !== "object") return ""
1382
+ const parts: string[] = []
1383
+ if ((typeof e0.code === "number" || typeof e0.code === "string") && String(e0.code) !== "") parts.push(String(e0.code))
1384
+ if (typeof e0.message === "string" && e0.message !== "") parts.push(e0.message)
1385
+ const s = capErrorBody(redactErrorBody(parts.join(" ")))
1386
+ return s ? ": " + s : ""
1387
+ }
1388
+
1389
+ /** The System One reply inside a Cloudflare `/ai/run` envelope. Only errors[0] is ever echoed. */
1390
+ function unwrapCloudflare(env: unknown): unknown {
1391
+ if (env === null || typeof env !== "object" || Array.isArray(env) || (env as any).success !== true) {
1392
+ throw new Error("classifier: cloudflare: envelope not successful" + cloudflareCause(env))
1393
+ }
1394
+ let result = (env as any).result
1395
+ if (result !== null && typeof result === "object" && !Array.isArray(result) && typeof result.state === "string") {
1396
+ if (result.state !== "Completed") throw new Error(`classifier: cloudflare: run state "${result.state}"`)
1397
+ result = result.result
1398
+ }
1399
+ return result
1400
+ }
1401
+
1402
+ /** §8B model key: which model an endpoint + requested model (alias) served last. */
1403
+ export function classifierModelCacheKey(endpoint: string, model: string): string {
1404
+ return sha256Hex(canonicalJson({ endpoint, kind: "model", model }))
1405
+ }
1406
+
1407
+ /** §8B decision key, on the SERVED model. No assets ⇒ the `assets` member is omitted. */
1408
+ export function classifierDecisionCacheKey(
1409
+ endpoint: string,
1410
+ servedModel: string,
1411
+ state: unknown,
1412
+ assets: Record<string, unknown> | undefined,
1413
+ questions: Record<string, AnyQuestion>,
1414
+ ): string {
1415
+ return sha256Hex(classifierDecisionCacheKeyBytes(endpoint, servedModel, state, assets, questions))
1416
+ }
1417
+
1418
+ /** The canonical JSON a decision key hashes (exported for conformance tests). */
1419
+ export function classifierDecisionCacheKeyBytes(
1420
+ endpoint: string,
1421
+ servedModel: string,
1422
+ state: unknown,
1423
+ assets: Record<string, unknown> | undefined,
1424
+ questions: Record<string, AnyQuestion>,
1425
+ ): string {
1426
+ const qs: Record<string, unknown> = {}
1427
+ for (const [k, q] of Object.entries(questions)) qs[k] = questionWire(q)
1428
+ const m: Record<string, unknown> = { endpoint, kind: "decision", model: servedModel, questions: qs, state }
1429
+ if (assets && Object.keys(assets).length > 0) m.assets = assets
1430
+ return canonicalJson(m)
1431
+ }
1432
+
1433
+ /**
1434
+ * In-process TTL cache with a size cap (oldest-inserted out first). `ttlMs` 0 = no expiry; `max`
1435
+ * 0 = unbounded. toolnexus persists nothing itself: implement `ClassifierCache` to persist.
1436
+ */
1437
+ export class MemoryCache implements ClassifierCache {
1438
+ private readonly m = new Map<string, { v: string; at: number }>()
1439
+ constructor(
1440
+ readonly ttlMs = 0,
1441
+ readonly max = 0,
1442
+ private readonly now: () => number = Date.now,
1443
+ ) {}
1444
+ get(key: string): string | undefined {
1445
+ const e = this.m.get(key)
1446
+ if (!e) return undefined
1447
+ if (this.ttlMs > 0 && this.now() - e.at > this.ttlMs) {
1448
+ this.m.delete(key)
1449
+ return undefined
1450
+ }
1451
+ return e.v
1452
+ }
1453
+ set(key: string, value: string): void {
1454
+ this.m.delete(key) // a re-set is a fresh insertion
1455
+ this.m.set(key, { v: value, at: this.now() })
1456
+ if (this.max > 0) while (this.m.size > this.max) this.m.delete(this.m.keys().next().value as string)
1457
+ }
1458
+ get size(): number {
1459
+ return this.m.size
1460
+ }
1461
+ }
1462
+
1463
+ /**
1464
+ * V1: fail closed on a reply that does not answer exactly the questions asked, within their
1465
+ * schema. First failure wins, in the §8B order. Returns the error, or undefined when valid.
1466
+ */
1467
+ export function validateDecision(
1468
+ questions: Record<string, AnyQuestion>,
1469
+ decision: { answers: Record<string, unknown> },
1470
+ ): Error | undefined {
1471
+ const bad = (msg: string) => new Error(`classifier: invalid decision: ${msg}`)
1472
+ const answers = decision.answers ?? {}
1473
+ const has = (o: object, k: string) => Object.prototype.hasOwnProperty.call(o, k)
1474
+ const qKeys = Object.keys(questions).sort()
1475
+ for (const k of qKeys) if (!has(answers, k)) return bad(`no answer "${k}"`)
1476
+ const extra = Object.keys(answers)
1477
+ .filter((k) => !has(questions, k))
1478
+ .sort()
1479
+ if (extra.length > 0) return bad(`unexpected answer "${extra[0]}"`)
1480
+ const in01 = (p: unknown) => typeof p === "number" && !Number.isNaN(p) && p >= 0 && p <= 1
1481
+ const dist = (k: string, probs: Record<string, number>, offered: (o: string) => boolean): Error | undefined => {
1482
+ let sum = 0
1483
+ const pk = Object.keys(probs ?? {}).sort()
1484
+ for (const o of pk) {
1485
+ if (!offered(o)) return bad(`answer "${k}": probability for "${o}", which was not offered`)
1486
+ if (!in01(probs[o])) return bad(`answer "${k}": probability ${probs[o]} outside [0,1]`)
1487
+ sum += probs[o]
1488
+ }
1489
+ if (pk.length > 0 && Math.abs(sum - 1) > 0.02) return bad(`answer "${k}": probabilities sum to ${sum.toFixed(4)}`)
1490
+ return undefined
1491
+ }
1492
+ for (const k of qKeys) {
1493
+ const raw = questions[k]
1494
+ const q = raw as Question
1495
+ const a = answers[k] as any
1496
+ const qType = raw.type
1497
+ const aType = a?.type
1498
+ if (aType !== qType) return bad(`answer "${k}" is a ${aType} answer, not ${qType}`)
1499
+ if (isRawQuestion(raw)) continue
1500
+ if (q.type === "noul") {
1501
+ if (!in01(a.noul)) return bad(`answer "${k}": noul ${a.noul} outside [0,1]`)
1502
+ } else if (q.type === "choice") {
1503
+ const offered = (o: string) => has(q.criteria ?? {}, o)
1504
+ if (!offered(a.choice)) return bad(`answer "${k}": choice "${a.choice}" was not offered`)
1505
+ const err = dist(k, a.probabilities, offered)
1506
+ if (err) return err
1507
+ if (!in01(a.confidence)) return bad(`answer "${k}": confidence ${a.confidence} outside [0,1]`)
1508
+ } else {
1509
+ const n = (q.criteria ?? []).length
1510
+ if (typeof a.score !== "number" || Number.isNaN(a.score) || a.score < 0 || a.score > n - 1) {
1511
+ return bad(`answer "${k}": score ${a.score} outside [0,${n - 1}]`)
1512
+ }
1513
+ const offered = (o: string) => Array.from({ length: n }, (_, i) => String(i)).includes(o)
1514
+ const err = dist(k, a.probabilities, offered)
1515
+ if (err) return err
1516
+ if (!in01(a.confidence)) return bad(`answer "${k}": confidence ${a.confidence} outside [0,1]`)
1517
+ }
1518
+ }
1519
+ return undefined
1520
+ }
1521
+
1522
+ /** jevx's INCLUSIVE cut-points (T1). They sit alongside `Bands` (exclusive), which stays the
1523
+ * default of `ask` / `gate`. */
1524
+ export interface Thresholds {
1525
+ yes: number
1526
+ no: number
1527
+ minConfidence: number
1528
+ }
1529
+
1530
+ /** 0.8 / 0.2 / 0.6 (jevx's builtin profile). */
1531
+ export const DEFAULT_THRESHOLDS: Readonly<Thresholds> = Object.freeze({ yes: 0.8, no: 0.2, minConfidence: 0.6 })
1532
+
1533
+ /**
1534
+ * The band of one answer under `Thresholds`: noul `p >= yes` ⇒ yes, `p <= no` ⇒ no, else
1535
+ * uncertain; choice/score `confidence >= minConfidence` ⇒ yes, else uncertain. No near-uniform
1536
+ * demotion (that is `Bands`' rule). A raw answer is uncertain.
1537
+ */
1538
+ export function thresholdBand(answer: DecisionAnswer | RawAnswer, t: Thresholds = DEFAULT_THRESHOLDS): "yes" | "no" | "uncertain" {
1539
+ const a = answer as any
1540
+ if (a.type === "noul") {
1541
+ if (a.noul >= t.yes) return "yes"
1542
+ if (a.noul <= t.no) return "no"
1543
+ } else if ((a.type === "choice" || a.type === "score") && a.confidence >= t.minConfidence) {
1544
+ return "yes"
1545
+ }
1546
+ return "uncertain"
1547
+ }
1548
+
1549
+ /**
1550
+ * Split a question set into chunks of `size`, keys in ASCII order. A helper, not automatic:
1551
+ * joint calibration across a split is unproven, so the host decides to split and merges.
1552
+ */
1553
+ export function chunkQuestions<Q extends AnyQuestion>(questions: Record<string, Q>, size: number): Record<string, Q>[] {
1554
+ if (!(size >= 1)) throw new Error(`classifier: chunk size must be at least 1, got ${size}`)
1555
+ const keys = Object.keys(questions).sort()
1556
+ const out: Record<string, Q>[] = []
1557
+ for (let i = 0; i < keys.length; i += size) {
1558
+ out.push(Object.fromEntries(keys.slice(i, i + size).map((k) => [k, questions[k]])))
1559
+ }
1560
+ return out
1561
+ }