@wenathlan/getry 2.0.164 → 2.0.166

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/json.ts ADDED
@@ -0,0 +1,932 @@
1
+ /**
2
+ * json — consolidated json engine logics of the getry family.
3
+ * @module json
4
+ * @family wave/logicfold2
5
+ * One keyed file per family: the former variant arms (jsonpolicy, jsonschema)
6
+ * retired into this seat with every export preserved under the same name.
7
+ */
8
+
9
+ /* ===== block: json ===== */
10
+ /* ── @section jsonparse ───────────────────────────────────────────── */
11
+
12
+ /**
13
+ * jsonparse.ts — the extraction and validation of the json verdict from
14
+ * the model answer (root layer).
15
+ *
16
+ * the model answers prose that wraps the verdict: fences, preamble, trailing
17
+ * notes. the extractor finds the first balanced json object in the text
18
+ * (brace counting that respects strings and escapes), the validator checks
19
+ * the object against the decision kind (the value belongs to the options,
20
+ * the yesno coerces, the score clamps to the range, the confidence lands in
21
+ * 0-1) and answers a typed failure when nothing parses. pure and multi-mode:
22
+ * the same call runs in the browser and in node, with zero DOM and zero
23
+ * storage.
24
+ */
25
+
26
+ /** extracts the first balanced json object of a model answer.
27
+ *
28
+ * @param text the raw answer text.
29
+ * @returns the parsed object.
30
+ * @throws decisionerror with code bad-verdict when nothing parses.
31
+ */
32
+ export function extractjsonobject(text: string): Record<string, unknown> {
33
+ const start = text.indexOf("{");
34
+ if (start < 0) throw new decisionerror("bad-verdict", null, "the answer carries no json object");
35
+ let depth = 0;
36
+ let instring = false;
37
+ let escaped = false;
38
+ for (let index = start; index < text.length; index += 1) {
39
+ const char = text[index];
40
+ if (escaped) {
41
+ escaped = false;
42
+ continue;
43
+ }
44
+ if (char === "\\") {
45
+ if (instring) escaped = true;
46
+ continue;
47
+ }
48
+ if (char === '"') instring = !instring;
49
+ if (instring) continue;
50
+ if (char === "{") depth += 1;
51
+ if (char === "}") {
52
+ depth -= 1;
53
+ if (depth === 0) {
54
+ const slice = text.slice(start, index + 1);
55
+ try {
56
+ const parsed: unknown = JSON.parse(slice);
57
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
58
+ throw new decisionerror("bad-verdict", null, "the json verdict must be an object");
59
+ return parsed as Record<string, unknown>;
60
+ } catch (error) {
61
+ if (error instanceof decisionerror) throw error;
62
+ throw new decisionerror("bad-verdict", null, "the json verdict does not parse");
63
+ }
64
+ }
65
+ }
66
+ }
67
+ throw new decisionerror("bad-verdict", null, "the json verdict never closes");
68
+ }
69
+
70
+ /** the shape a model verdict must carry. */
71
+ export type VerdictObject = { value: unknown; confidence: unknown };
72
+
73
+ /**
74
+ * validates and coerces one parsed object against the decision kind.
75
+ *
76
+ * @param request the decision request (kind, options, range).
77
+ * @param object the parsed json object.
78
+ * @returns the typed value and the clamped confidence.
79
+ * @throws decisionerror with code bad-verdict naming the failing part.
80
+ */
81
+ export function validateverdict(
82
+ request: Pick<DecisionRequest, "kind" | "options" | "range">,
83
+ object: Record<string, unknown>
84
+ ): { value: string | number | boolean; confidence: number } {
85
+ const rawvalue = object.value;
86
+ const rawconfidence = typeof object.confidence === "number" ? object.confidence : Number(object.confidence);
87
+ if (!Number.isFinite(rawconfidence)) throw new decisionerror("bad-verdict", "confidence", "the verdict carries no numeric confidence");
88
+ const confidence = Math.min(1, Math.max(0, rawconfidence));
89
+ if (request.kind === "choice") {
90
+ if (typeof rawvalue !== "string") throw new decisionerror("bad-verdict", "value", "a choice verdict answers a string");
91
+ const options = request.options ?? [];
92
+ const match = options.find((option) => option.toLowerCase() === rawvalue.trim().toLowerCase());
93
+ if (!match) throw new decisionerror("bad-verdict", rawvalue, "the choice answer leaves the declared options");
94
+ return { value: match, confidence };
95
+ }
96
+ if (request.kind === "yesno") {
97
+ if (typeof rawvalue === "boolean") return { value: rawvalue, confidence };
98
+ if (typeof rawvalue === "string") {
99
+ const normalized = rawvalue.trim().toLowerCase();
100
+ if (normalized === "yes" || normalized === "true") return { value: true, confidence };
101
+ if (normalized === "no" || normalized === "false") return { value: false, confidence };
102
+ }
103
+ throw new decisionerror("bad-verdict", String(rawvalue), "a yesno verdict answers yes/no");
104
+ }
105
+ const score = typeof rawvalue === "number" ? rawvalue : Number(rawvalue);
106
+ if (!Number.isFinite(score)) throw new decisionerror("bad-verdict", "value", "a score verdict answers a number");
107
+ const range = request.range ?? { min: 0, max: 1 };
108
+ return { value: Math.min(range.max, Math.max(range.min, score)), confidence };
109
+ }
110
+
111
+
112
+ /* ── @section jsondecision ───────────────────────────────────────────── */
113
+
114
+ /**
115
+ * jsondecision.ts — the typed decision domain of the json model (root
116
+ * layer).
117
+ *
118
+ * the json model answers a structured question with a structured verdict:
119
+ * the caller declares the decision kind (a choice among options, a score in
120
+ * a range, a yes/no), the policy that gates the confidence and the fallback
121
+ * that answers when the gate closes — every constant arrives as a parameter,
122
+ * so a caller tunes its own decision layer without touching this file (the
123
+ * competitor practice: the confidence policy of the decision gates, the
124
+ * typed choices of the type-safe generation tools). pure and multi-mode:
125
+ * the same call runs in the browser (the chat second layer) and in node
126
+ * (the unit tests), with zero DOM and zero storage.
127
+ */
128
+
129
+ /** the machine readable failure codes of the decision domain. */
130
+ export type decisionerrorcode = "bad-kind" | "bad-request" | "bad-policy" | "bad-verdict";
131
+
132
+ /** the typed decision failure, traceable to the code and the detail. */
133
+ export class decisionerror extends Error {
134
+ /** machine readable failure code. */
135
+ readonly code: decisionerrorcode;
136
+ /** the detail the failure is about (when known). */
137
+ readonly detail: string | null;
138
+
139
+ constructor(code: decisionerrorcode, detail: string | null, message?: string) {
140
+ super(message ?? `decision ${code}${detail === null ? "" : ` (${detail})`}`);
141
+ this.name = "decisionerror";
142
+ this.code = code;
143
+ this.detail = detail;
144
+ }
145
+ }
146
+
147
+ /** the decision kinds the model answers. */
148
+ export type DecisionKind = "choice" | "score" | "yesno";
149
+
150
+ /** the policy the verdict must satisfy before the caller acts on it. */
151
+ export type DecisionPolicy = {
152
+ /** the confidence floor (0-1); below it the fallback answers. */
153
+ minconfidence: number;
154
+ /** what answers when the gate closes (absent: the verdict fails honestly). */
155
+ fallback?: { value: string | number | boolean; reason: string };
156
+ };
157
+
158
+ /** the request the model answers. */
159
+ export type DecisionRequest = {
160
+ /** the decision kind. */
161
+ kind: DecisionKind;
162
+ /** the question posed to the model. */
163
+ prompt: string;
164
+ /** the options of a choice decision (required for kind choice). */
165
+ options?: readonly string[];
166
+ /** the inclusive range of a score decision (required for kind score). */
167
+ range?: { min: number; max: number };
168
+ /** the confidence policy (required — the caller tunes its own gate). */
169
+ policy: DecisionPolicy;
170
+ };
171
+
172
+ /** the verdict the second layer answers. */
173
+ export type DecisionVerdict =
174
+ | { ok: true; kind: DecisionKind; value: string | number | boolean; confidence: number }
175
+ | { ok: false; kind: DecisionKind; reason: string };
176
+
177
+ /**
178
+ * validates one decision request before the model pays for it.
179
+ *
180
+ * @param request the request to validate.
181
+ * @returns the request typed as validated.
182
+ * @throws decisionerror with the failing part.
183
+ */
184
+ export function validatedecision(request: DecisionRequest): DecisionRequest {
185
+ if (!request || typeof request !== "object") throw new decisionerror("bad-request", null, "decision request must be an object");
186
+ if (request.kind !== "choice" && request.kind !== "score" && request.kind !== "yesno")
187
+ throw new decisionerror("bad-kind", String((request as { kind?: unknown }).kind));
188
+ if (typeof request.prompt !== "string" || request.prompt.trim() === "")
189
+ throw new decisionerror("bad-request", "prompt", "the decision prompt must be a non-empty string");
190
+ const policy = request.policy;
191
+ if (!policy || typeof policy !== "object" || !Number.isFinite(policy.minconfidence) || policy.minconfidence < 0 || policy.minconfidence > 1)
192
+ throw new decisionerror("bad-policy", null, "the confidence policy must carry a minconfidence between 0 and 1");
193
+ if (request.kind === "choice" && (!Array.isArray(request.options) || request.options.length < 2))
194
+ throw new decisionerror("bad-request", "options", "a choice decision carries at least two options");
195
+ if (request.kind === "score") {
196
+ const range = request.range;
197
+ if (!range || !Number.isFinite(range.min) || !Number.isFinite(range.max) || range.min >= range.max)
198
+ throw new decisionerror("bad-request", "range", "a score decision carries an increasing min/max range");
199
+ }
200
+ return request;
201
+ }
202
+
203
+ /**
204
+ * gates one verdict against the policy: the confidence floor decides
205
+ * whether the caller acts on the model or on the fallback.
206
+ *
207
+ * @param kind the decision kind of the request.
208
+ * @param value the parsed verdict value.
209
+ * @param confidence the model confidence (0-1).
210
+ * @param policy the policy of the request.
211
+ * @returns the verdict (the fallback answers when the gate closes).
212
+ */
213
+ export function gateverdict(
214
+ kind: DecisionKind,
215
+ value: string | number | boolean,
216
+ confidence: number,
217
+ policy: DecisionPolicy
218
+ ): DecisionVerdict {
219
+ if (confidence >= policy.minconfidence) return { ok: true, kind, value, confidence };
220
+ if (policy.fallback) return { ok: true, kind, value: policy.fallback.value, confidence };
221
+ return { ok: false, kind, reason: `confidence ${confidence} below the policy floor ${policy.minconfidence}` };
222
+ }
223
+
224
+
225
+ /* ── @section jsonengine ───────────────────────────────────────────── */
226
+
227
+ /**
228
+ * jsonengine.ts — the second layer of the json model (root layer).
229
+ *
230
+ * the flow the platform runs: the caller declares a typed decision, the
231
+ * engine builds the instruction, one LLM round answers through the injected
232
+ * link (the local gateway client is the natural provider — the engine takes
233
+ * the completer as a parameter so nothing here is bound to one endpoint or
234
+ * one vendor), the answer parses to the verdict and the policy gates it.
235
+ * one repair round re-asks when the first answer does not parse. the recipe
236
+ * table arrives as a parameter (which instruction per decision kind), the
237
+ * competitor practice of the decision gates: declare the recipes, route the
238
+ * kind, measure the confidence, fall back honestly. pure and multi-mode:
239
+ * the same call runs in the browser and in node, with zero DOM and zero
240
+ * storage.
241
+ */
242
+
243
+ /** one recipe of the decision router: the instruction the kind asks with. */
244
+ export type DecisionRecipe = {
245
+ /** the decision kind the recipe serves. */
246
+ kind: DecisionRequest["kind"];
247
+ /** the extra instruction riding the system side of the round. */
248
+ instruction: string;
249
+ };
250
+
251
+ /** the injected LLM link: one round over the configured gateway. */
252
+ export type Completer = (messages: Array<{ role: "system" | "user" | "assistant"; content: string }>) => Promise<string>;
253
+
254
+ /** the trail of one decide run (the internal cognition of the verdict). */
255
+ export type DecisionTrail = {
256
+ /** the rounds the engine paid (1 or 2 with the repair). */
257
+ rounds: number;
258
+ /** the raw answers per round. */
259
+ answers: string[];
260
+ /** the recipe instruction that rode the round. */
261
+ instruction: string;
262
+ };
263
+
264
+ /**
265
+ * builds the default instruction of one kind (the recipe table may
266
+ * override it per kind).
267
+ *
268
+ * @param request the validated request.
269
+ * @returns the instruction text.
270
+ */
271
+ export function defaultinstruction(request: DecisionRequest): string {
272
+ if (request.kind === "choice")
273
+ return `Answer only a JSON object {"value": one of [${(request.options ?? []).join(" | ")}], "confidence": 0..1}. No prose.`;
274
+ if (request.kind === "score")
275
+ return `Answer only a JSON object {"value": a number between ${(request.range ?? { min: 0, max: 1 }).min} and ${
276
+ (request.range ?? { min: 0, max: 1 }).max
277
+ }, "confidence": 0..1}. No prose.`;
278
+ return 'Answer only a JSON object {"value": true or false, "confidence": 0..1}. No prose.';
279
+ }
280
+
281
+ /**
282
+ * creates the second layer: the caller injects the LLM link and the recipe
283
+ * table; the returned decide runs the flow per request.
284
+ *
285
+ * @param config the completer (the gateway round) and the recipe table.
286
+ * @returns the decide function with the typed verdict and trail.
287
+ */
288
+ export function createjsonmodel(config: {
289
+ complete: Completer;
290
+ recipes?: readonly DecisionRecipe[];
291
+ }): (request: DecisionRequest) => Promise<{ verdict: DecisionVerdict; trail: DecisionTrail }> {
292
+ if (!config || typeof config.complete !== "function")
293
+ throw new decisionerror("bad-policy", null, "the json model carries an injected completer");
294
+ const recipes = config.recipes ?? [];
295
+
296
+ return async (request) => {
297
+ const validated = validatedecision(request);
298
+ const recipe = recipes.find((entry) => entry.kind === validated.kind);
299
+ const instruction = recipe?.instruction ?? defaultinstruction(validated);
300
+ const messages: Array<{ role: "system" | "user" | "assistant"; content: string }> = [
301
+ { role: "system", content: instruction },
302
+ { role: "user", content: validated.prompt },
303
+ ];
304
+ const trail: DecisionTrail = { rounds: 0, answers: [], instruction };
305
+
306
+ let lasterror: unknown = null;
307
+ for (let round = 0; round < 2; round += 1) {
308
+ trail.rounds += 1;
309
+ const answer = await config.complete(messages);
310
+ trail.answers.push(answer);
311
+ try {
312
+ const object = extractjsonobject(answer);
313
+ const parsed = validateverdict(validated, object);
314
+ return { verdict: gateverdict(validated.kind, parsed.value, parsed.confidence, validated.policy), trail };
315
+ } catch (error) {
316
+ lasterror = error;
317
+ if (round === 0) messages.push({ role: "assistant", content: answer }, { role: "user", content: "Answer again with only the JSON object. No prose, no fences." });
318
+ }
319
+ }
320
+ const detail = lasterror instanceof decisionerror ? lasterror.detail : null;
321
+ return { verdict: { ok: false, kind: validated.kind, reason: `the model never answered a parseable verdict${detail ? ` (${detail})` : ""}` }, trail };
322
+ };
323
+ }
324
+
325
+ /* ===== block: jsonpolicy ===== */
326
+ /**
327
+ * jsonpolicy.ts — the confidence policies of the json model (root layer):
328
+ * the muse-jev-playbook practices, typed — policy tables per use (every
329
+ * threshold a parameter, zero hardcode), the honest measurement (expected
330
+ * against received, bucketed by confidence band), the shadow → active
331
+ * rollout ladder and the reference router with the rotation by hit history.
332
+ * pure and multi-mode: browser and node, zero DOM and zero storage — the
333
+ * log lives in memory and serializes to JSONL.
334
+ */
335
+
336
+
337
+ /** the machine readable failure codes of the policy domain. */
338
+ export type policyerrorcode = "bad-table" | "bad-band" | "bad-record" | "bad-router" | "bad-rollout";
339
+
340
+ /** the typed policy failure, traceable to the code and the detail. */
341
+ export class policyerror extends Error {
342
+ /** machine readable failure code. */
343
+ readonly code: policyerrorcode;
344
+ /** the detail the failure is about (when known). */
345
+ readonly detail: string | null;
346
+
347
+ constructor(code: policyerrorcode, detail: string | null, message?: string) {
348
+ super(message ?? `policy ${code}${detail === null ? "" : ` (${detail})`}`);
349
+ this.name = "policyerror";
350
+ this.code = code;
351
+ this.detail = detail;
352
+ }
353
+ }
354
+
355
+ /** the confidence bands of the playbook: act, surface, escalate. */
356
+ export type ConfidenceBand = "act" | "surface" | "escalate";
357
+
358
+ /** the band thresholds: >= high acts, < low escalates, between surfaces. */
359
+ export type BandThresholds = { high: number; low: number };
360
+
361
+ /** validates one band table (low below high, both inside 0-1). */
362
+ function validbands(bands: BandThresholds): BandThresholds {
363
+ const { high, low } = bands;
364
+ if (!Number.isFinite(high) || !Number.isFinite(low) || low < 0 || high > 1 || low >= high)
365
+ throw new policyerror("bad-band", null, "the band thresholds carry a low below high, both inside 0-1");
366
+ return bands;
367
+ }
368
+
369
+ /**
370
+ * confidenceband — one confidence against the band table (the playbook
371
+ * default: >= 0.80 acts, 0.50-0.79 surfaces, < 0.50 escalates).
372
+ */
373
+ export function confidenceband(confidence: number, bands: BandThresholds = { high: 0.8, low: 0.5 }): ConfidenceBand {
374
+ validbands(bands);
375
+ if (!Number.isFinite(confidence)) throw new policyerror("bad-band", String(confidence), "the confidence must be a number");
376
+ if (confidence >= bands.high) return "act";
377
+ if (confidence < bands.low) return "escalate";
378
+ return "surface";
379
+ }
380
+
381
+ /** one use of the decision layer: its name, the policy and the honest note. */
382
+ export type PolicyEntry = { name: string; policy: DecisionPolicy; note?: string };
383
+
384
+ /**
385
+ * buildpolicytable — validates the caller's table (unique non-empty names,
386
+ * a finite confidence floor, a typed fallback) and freezes it.
387
+ * @throws policyerror with code bad-table on the first broken entry.
388
+ */
389
+ export function buildpolicytable(entries: readonly PolicyEntry[]): readonly PolicyEntry[] {
390
+ const seen = new Set<string>();
391
+ for (const entry of entries) {
392
+ if (!entry || typeof entry.name !== "string" || entry.name.trim() === "")
393
+ throw new policyerror("bad-table", null, "every table entry carries a non-empty name");
394
+ const key = entry.name.trim().toLowerCase();
395
+ if (seen.has(key)) throw new policyerror("bad-table", entry.name, "the table names a use twice");
396
+ seen.add(key);
397
+ const policy = entry.policy;
398
+ if (!policy || !Number.isFinite(policy.minconfidence) || policy.minconfidence < 0 || policy.minconfidence > 1)
399
+ throw new policyerror("bad-table", entry.name, "the policy floor must sit between 0 and 1");
400
+ const fallback = policy.fallback?.value;
401
+ if (fallback !== undefined && typeof fallback !== "string" && typeof fallback !== "number" && typeof fallback !== "boolean")
402
+ throw new policyerror("bad-table", entry.name, "the fallback answers a string, number or boolean");
403
+ }
404
+ return entries.map((entry) => ({ ...entry, policy: { ...entry.policy } }));
405
+ }
406
+
407
+ /**
408
+ * defaultpolicytable — the playbook per-question minimums as parameters:
409
+ * the choice trust floor, the cache reuse, the subagent spawn and the retry
410
+ * stop. the overrides replace an entry wholesale; nothing here is law.
411
+ */
412
+ export function defaultpolicytable(overrides: Record<string, DecisionPolicy> = {}): readonly PolicyEntry[] {
413
+ const base: PolicyEntry[] = [
414
+ { name: "choice", policy: { minconfidence: 0.55 }, note: "below the floor the choice winner is not trusted" },
415
+ { name: "reuse", policy: { minconfidence: 0.65 }, note: "the cached artifact answers instead of redoing the work" },
416
+ { name: "subagent", policy: { minconfidence: 0.75 }, note: "spawn a specialist only above this" },
417
+ { name: "stopretry", policy: { minconfidence: 0.55 }, note: "stop retrying a failed approach above this" },
418
+ ];
419
+ const kept = base.filter((entry) => overrides[entry.name] === undefined);
420
+ const replaced = Object.entries(overrides).map(([name, policy]) => ({ name, policy }));
421
+ return buildpolicytable([...kept, ...replaced]);
422
+ }
423
+
424
+ /** policyfor — the policy of one use; unknown names fail honestly. */
425
+ export function policyfor(table: readonly PolicyEntry[], name: string): DecisionPolicy {
426
+ const entry = table.find((candidate) => candidate.name.toLowerCase() === name.trim().toLowerCase());
427
+ if (!entry) throw new policyerror("bad-table", name, "the table carries no such use");
428
+ return entry.policy;
429
+ }
430
+
431
+ /** the rollout mode of one decision (shadow logs, active enforces). */
432
+ export type DecisionMode = "shadow" | "active";
433
+
434
+ /**
435
+ * one honest line of the decision log (expected against received — the
436
+ * question, the action, the confidence, the mode, the wall time).
437
+ */
438
+ export type MeasurementRecord = {
439
+ at: number;
440
+ question: string;
441
+ action: string;
442
+ confidence: number;
443
+ mode: DecisionMode;
444
+ /** what the caller expected (absent: the line still counts by band). */
445
+ expected?: string | undefined;
446
+ /** what actually happened. */
447
+ received?: string | undefined;
448
+ ms?: number | undefined;
449
+ };
450
+
451
+ /**
452
+ * the per-question rollup: the band counts plus the judged high band
453
+ * (expected met received) and the chronic medium share.
454
+ */
455
+ export type MeasurementSummary = {
456
+ question: string;
457
+ total: number;
458
+ bands: Record<ConfidenceBand, number>;
459
+ judgedhigh: number;
460
+ righthigh: number;
461
+ judged: number;
462
+ mediumshare: number;
463
+ };
464
+
465
+ /** the honest measurement handle (in memory, JSONL out). */
466
+ export type HonestMeasurement = {
467
+ record: (entry: MeasurementRecord) => MeasurementRecord;
468
+ summary: (question?: string) => MeasurementSummary[];
469
+ tojsonl: () => string;
470
+ };
471
+
472
+ /**
473
+ * createmeasurement — the honest log: the caller records expected against
474
+ * received per decision and the summary says which question packs earn
475
+ * enforcement (calibration, not marketing).
476
+ */
477
+ export function createmeasurement(config: { cap?: number; bands?: BandThresholds } = {}): HonestMeasurement {
478
+ const cap = config.cap ?? 500;
479
+ if (!Number.isInteger(cap) || cap < 1) throw new policyerror("bad-record", null, "the log cap must be a positive integer");
480
+ const bands = validbands(config.bands ?? { high: 0.8, low: 0.5 });
481
+ const lines: MeasurementRecord[] = [];
482
+
483
+ const normalized = (entry: MeasurementRecord): MeasurementRecord => {
484
+ if (!entry || typeof entry.question !== "string" || entry.question.trim() === "" || typeof entry.action !== "string" || entry.action.trim() === "")
485
+ throw new policyerror("bad-record", null, "the log line names a question and an action");
486
+ if (!Number.isFinite(entry.confidence) || entry.confidence < 0 || entry.confidence > 1)
487
+ throw new policyerror("bad-record", entry.question, "the logged confidence must sit between 0 and 1");
488
+ return {
489
+ ...entry,
490
+ at: Number.isFinite(entry.at) ? entry.at : Date.now(),
491
+ question: entry.question.trim(),
492
+ action: entry.action.trim(),
493
+ confidence: entry.confidence,
494
+ mode: entry.mode === "active" ? "active" : "shadow",
495
+ };
496
+ };
497
+
498
+ return {
499
+ record(entry) {
500
+ lines.push(normalized(entry));
501
+ if (lines.length > cap) lines.splice(0, lines.length - cap);
502
+ return lines[lines.length - 1];
503
+ },
504
+ summary(question) {
505
+ const rows = question ? lines.filter((line) => line.question === question) : lines;
506
+ const groups = new Map<string, MeasurementRecord[]>();
507
+ for (const line of rows) {
508
+ const bucket = groups.get(line.question);
509
+ if (bucket) bucket.push(line);
510
+ else groups.set(line.question, [line]);
511
+ }
512
+ const out: MeasurementSummary[] = [];
513
+ for (const [name, bucket] of groups) {
514
+ const bandcount: Record<ConfidenceBand, number> = { act: 0, surface: 0, escalate: 0 };
515
+ let judgedhigh = 0;
516
+ let righthigh = 0;
517
+ let judged = 0;
518
+ let medium = 0;
519
+ for (const line of bucket) {
520
+ bandcount[confidenceband(line.confidence, bands)] += 1;
521
+ if (line.expected !== undefined && line.received !== undefined) {
522
+ judged += 1;
523
+ if (line.confidence >= bands.high) {
524
+ judgedhigh += 1;
525
+ if (line.expected === line.received) righthigh += 1;
526
+ }
527
+ if (line.confidence >= bands.low && line.confidence < bands.high) medium += 1;
528
+ }
529
+ }
530
+ out.push({ question: name, total: bucket.length, bands: bandcount, judgedhigh, righthigh, judged, mediumshare: judged === 0 ? 0 : medium / judged });
531
+ }
532
+ return out;
533
+ },
534
+ tojsonl() {
535
+ return lines.map((line) => JSON.stringify(line)).join("\n");
536
+ },
537
+ };
538
+ }
539
+
540
+ /** the rollout verdict for one question pack. */
541
+ export type RolloutVerdict = "promote" | "keep-shadow" | "demote" | "insufficient";
542
+
543
+ /** the ladder thresholds (the playbook defaults, all parameters). */
544
+ export type RolloutPolicy = {
545
+ minjudged: number;
546
+ promoteaccuracy: number;
547
+ minjudgedkill: number;
548
+ killaccuracy: number;
549
+ chronicmedium: number;
550
+ };
551
+
552
+ /** validates one rollout policy. */
553
+ function validrollout(policy: RolloutPolicy): RolloutPolicy {
554
+ const finite01 = (value: number) => Number.isFinite(value) && value > 0 && value <= 1;
555
+ if (!Number.isInteger(policy.minjudged) || policy.minjudged < 1 || !Number.isInteger(policy.minjudgedkill) || policy.minjudgedkill < 1)
556
+ throw new policyerror("bad-rollout", null, "the rollout carries positive integer windows");
557
+ if (!finite01(policy.promoteaccuracy) || !finite01(policy.killaccuracy) || !finite01(policy.chronicmedium))
558
+ throw new policyerror("bad-rollout", null, "the rollout rates must sit inside 0-1");
559
+ return policy;
560
+ }
561
+
562
+ /**
563
+ * reviewrollout — the ladder done honestly: promote only when the high band
564
+ * is right on the log (9 of 10 over the window), demote on kill accuracy or
565
+ * a chronically medium question (the question is malformed, not the
566
+ * threshold), insufficient while the evidence is short.
567
+ */
568
+ export function reviewrollout(
569
+ summary: MeasurementSummary,
570
+ policy: Partial<RolloutPolicy> = {}
571
+ ): { question: string; verdict: RolloutVerdict; reason: string } {
572
+ const full = validrollout({ minjudged: 20, promoteaccuracy: 0.9, minjudgedkill: 30, killaccuracy: 0.9, chronicmedium: 0.5, ...policy });
573
+ const accuracy = summary.judgedhigh === 0 ? 0 : summary.righthigh / summary.judgedhigh;
574
+ const percent = `${(accuracy * 100).toFixed(0)}%`;
575
+ if (summary.judgedhigh >= full.minjudgedkill && accuracy < full.killaccuracy)
576
+ return { question: summary.question, verdict: "demote", reason: `high-band accuracy ${percent} over ${summary.judgedhigh} judged` };
577
+ if (summary.mediumshare >= full.chronicmedium && summary.judgedhigh < full.minjudgedkill)
578
+ return { question: summary.question, verdict: "demote", reason: `chronic medium confidence (${(summary.mediumshare * 100).toFixed(0)}% of judged) — the question is malformed` };
579
+ if (summary.judgedhigh >= full.minjudged && accuracy >= full.promoteaccuracy)
580
+ return { question: summary.question, verdict: "promote", reason: `high-band accuracy ${percent} over ${summary.judgedhigh} judged` };
581
+ if (summary.judgedhigh < full.minjudged)
582
+ return { question: summary.question, verdict: "insufficient", reason: `only ${summary.judgedhigh} judged high-band lines (need ${full.minjudged})` };
583
+ return { question: summary.question, verdict: "keep-shadow", reason: `high-band accuracy ${percent} below the promote bar` };
584
+ }
585
+
586
+ /** one reference route: the question form and the recipe that serves it. */
587
+ export type RouterRoute = { form: string; recipe: string; alternate?: string | undefined };
588
+
589
+ /** the serving stats of one route (the rotation feeds on them). */
590
+ export type RouterStats = { hit: number; miss: number; accuracy: number | null; serving: string };
591
+
592
+ /** the reference router handle (route, record, stats). */
593
+ export type ReferenceRouter = {
594
+ route: (form: string) => string;
595
+ record: (form: string, hit: boolean) => void;
596
+ stats: (form: string) => RouterStats;
597
+ };
598
+
599
+ /**
600
+ * createreferencerouter — which recipe answers which question form, with
601
+ * the rotation by hit history: when a form's accuracy falls below the
602
+ * accuracy floor (default 0.6) over the judged window (default 5), the
603
+ * alternate recipe takes the route — the complement of the engine's static
604
+ * recipe table.
605
+ */
606
+ export function createreferencerouter(config: { routes: readonly RouterRoute[]; minaccuracy?: number; minjudged?: number }): ReferenceRouter {
607
+ if (!config || !Array.isArray(config.routes) || config.routes.length === 0)
608
+ throw new policyerror("bad-router", null, "the router carries at least one route");
609
+ const minaccuracy = config.minaccuracy ?? 0.6;
610
+ const minjudged = config.minjudged ?? 5;
611
+ if (!Number.isFinite(minaccuracy) || minaccuracy <= 0 || minaccuracy >= 1 || !Number.isInteger(minjudged) || minjudged < 1)
612
+ throw new policyerror("bad-router", null, "the router floor sits inside 0-1 and the window is a positive integer");
613
+
614
+ const table = new Map<string, RouterRoute>();
615
+ for (const route of config.routes) {
616
+ if (!route || typeof route.form !== "string" || route.form.trim() === "" || typeof route.recipe !== "string" || route.recipe.trim() === "")
617
+ throw new policyerror("bad-router", null, "every route names a form and a recipe");
618
+ const key = route.form.trim().toLowerCase();
619
+ if (table.has(key)) throw new policyerror("bad-router", route.form, "the router maps a form twice");
620
+ if (route.alternate !== undefined && (typeof route.alternate !== "string" || route.alternate.trim() === ""))
621
+ throw new policyerror("bad-router", route.form, "the alternate recipe is a non-empty string");
622
+ table.set(key, { form: key, recipe: route.recipe.trim(), alternate: route.alternate?.trim() });
623
+ }
624
+
625
+ const history = new Map<string, { hit: number; miss: number }>();
626
+ const keyof = (form: string): string => {
627
+ const key = form.trim().toLowerCase();
628
+ if (!table.has(key)) throw new policyerror("bad-router", form, "the router carries no such form");
629
+ return key;
630
+ };
631
+ const servingfor = (key: string): string => {
632
+ const route = table.get(key);
633
+ if (!route) throw new policyerror("bad-router", key, "the router carries no such form");
634
+ const book = history.get(key);
635
+ const judged = book ? book.hit + book.miss : 0;
636
+ const accuracy = book && judged > 0 ? book.hit / judged : 1;
637
+ const rotate = book !== undefined && judged >= minjudged && accuracy < minaccuracy && typeof route.alternate === "string";
638
+ return rotate && route.alternate ? route.alternate : route.recipe;
639
+ };
640
+
641
+ return {
642
+ route(form) {
643
+ return servingfor(keyof(form));
644
+ },
645
+ record(form, hit) {
646
+ const key = keyof(form);
647
+ const book = history.get(key) ?? { hit: 0, miss: 0 };
648
+ if (hit) book.hit += 1;
649
+ else book.miss += 1;
650
+ history.set(key, book);
651
+ },
652
+ stats(form) {
653
+ const key = keyof(form);
654
+ const book = history.get(key);
655
+ const judged = book ? book.hit + book.miss : 0;
656
+ return { hit: book?.hit ?? 0, miss: book?.miss ?? 0, accuracy: book && judged > 0 ? book.hit / judged : null, serving: servingfor(key) };
657
+ },
658
+ };
659
+ }
660
+
661
+ /* ===== block: jsonschema ===== */
662
+ /**
663
+ * jsonschema.ts — the declarative schema bridge of the json model (root
664
+ * layer) — the typellm practice applied: a minimal declared schema (string,
665
+ * integer, number, boolean, enum capped at 24, one object level) compiles
666
+ * into the instruction the model receives and the validator that checks the
667
+ * answer, so the second layer accepts declared shapes beyond the three
668
+ * fixed kinds. one repair round mirrors the engine; the confidence rides
669
+ * the object (the compiler appends the field when the schema does not
670
+ * declare it) and the policy gate closes on it. pure and multi-mode: the
671
+ * same call runs in the browser (the chat second layer) and in node (the
672
+ * unit tests), with zero DOM and zero storage.
673
+ */
674
+
675
+
676
+ /** the machine readable failure codes of the schema domain. */
677
+ export type schemaerrorcode = "bad-schema";
678
+
679
+ /** the typed schema failure, traceable to the declared part. */
680
+ export class schemaerror extends Error {
681
+ /** machine readable failure code. */
682
+ readonly code: schemaerrorcode;
683
+ /** the property the declaration fails on (when known). */
684
+ readonly detail: string | null;
685
+
686
+ constructor(detail: string | null, message?: string) {
687
+ super(message ?? `schema bad-schema${detail === null ? "" : ` (${detail})`}`);
688
+ this.name = "schemaerror";
689
+ this.code = "bad-schema";
690
+ this.detail = detail;
691
+ }
692
+ }
693
+
694
+ /** the enum cap of the type-safe generation practice (typellm: 24 values). */
695
+ export const ENUM_CAP = 24;
696
+
697
+ /** one declared property: a primitive, an enum or a boolean. */
698
+ export type SchemaField = {
699
+ /** the primitive the answer must carry. */
700
+ type: "string" | "integer" | "number" | "boolean";
701
+ /** what the model decides for this property. */
702
+ instructions?: string | undefined;
703
+ /** the closed candidate set (string, integer and number types only). */
704
+ enum?: readonly (string | number)[] | undefined;
705
+ /** absent is accepted when optional (the declared null). */
706
+ optional?: boolean | undefined;
707
+ };
708
+
709
+ /** the declared object shape: one level of named properties. */
710
+ export type JsonObjectSchema = {
711
+ /** what the object as a whole decides. */
712
+ instructions?: string | undefined;
713
+ /** the named properties (at least one). */
714
+ properties: Record<string, SchemaField>;
715
+ };
716
+
717
+ /** the compiled schema: the instruction and the validator of one shape. */
718
+ export type CompiledSchema = {
719
+ /** the system instruction the model answers against. */
720
+ instruction: string;
721
+ /** the declared property names in declaration order. */
722
+ fields: readonly string[];
723
+ /**
724
+ * validates one parsed object against the shape.
725
+ * @returns the typed record and the clamped confidence.
726
+ * @throws decisionerror with code bad-verdict naming the failing property.
727
+ */
728
+ validate: (object: Record<string, unknown>) => { value: Record<string, unknown>; confidence: number };
729
+ };
730
+
731
+ /** describes one field for the instruction (the definition, not the label). */
732
+ function describefield(field: SchemaField): string {
733
+ const base = field.enum ? `one of [${field.enum.join(" | ")}]` : field.type === "integer" ? "an integer" : field.type === "number" ? "a number" : field.type === "boolean" ? "true or false" : "a string";
734
+ const note = field.instructions ? ` — ${field.instructions}` : "";
735
+ const optional = field.optional ? " (optional)" : "";
736
+ return `${base}${note}${optional}`;
737
+ }
738
+
739
+ /** validates one declared field. */
740
+ function validfield(name: string, field: SchemaField): void {
741
+ if (!field || (field.type !== "string" && field.type !== "integer" && field.type !== "number" && field.type !== "boolean"))
742
+ throw new schemaerror(name, "the property type is one of string, integer, number, boolean");
743
+ if (field.instructions !== undefined && typeof field.instructions !== "string")
744
+ throw new schemaerror(name, "the instructions are a string");
745
+ if (field.enum !== undefined) {
746
+ if (field.type === "boolean") throw new schemaerror(name, "a boolean carries no enum — true and false are the enum");
747
+ if (!Array.isArray(field.enum) || field.enum.length === 0) throw new schemaerror(name, "the enum carries at least one candidate");
748
+ if (field.enum.length > ENUM_CAP) throw new schemaerror(name, `the enum carries at most ${ENUM_CAP} candidates`);
749
+ const seen = new Set(field.enum.map((option) => String(option)));
750
+ if (seen.size !== field.enum.length) throw new schemaerror(name, "the enum repeats a candidate");
751
+ const wrongtype = field.enum.find(
752
+ (option) => (field.type === "string" && typeof option !== "string") || (field.type !== "string" && typeof option !== "number")
753
+ );
754
+ if (wrongtype !== undefined) throw new schemaerror(name, `the enum candidate ${String(wrongtype)} leaves the declared type`);
755
+ if (field.type === "integer" && field.enum.some((option) => typeof option === "number" && !Number.isInteger(option)))
756
+ throw new schemaerror(name, "an integer enum carries integers only");
757
+ }
758
+ }
759
+
760
+ /**
761
+ * compilejsonschema — one declared shape to the instruction plus the
762
+ * validator: the bridge that lets the second layer answer schemas beyond
763
+ * the fixed kinds. the compiler appends the confidence property when the
764
+ * schema does not declare one (the gate feeds on it) and refuses an
765
+ * optional confidence (the gate cannot read an absent floor).
766
+ *
767
+ * @param schema the declared object shape.
768
+ * @returns the compiled instruction, fields and validator.
769
+ * @throws schemaerror on the first broken declaration.
770
+ */
771
+ export function compilejsonschema(schema: JsonObjectSchema): CompiledSchema {
772
+ if (!schema || typeof schema !== "object" || !schema.properties || typeof schema.properties !== "object")
773
+ throw new schemaerror(null, "the schema carries a properties record");
774
+ if (schema.instructions !== undefined && typeof schema.instructions !== "string")
775
+ throw new schemaerror(null, "the schema instructions are a string");
776
+ const names = Object.keys(schema.properties);
777
+ if (names.length === 0) throw new schemaerror(null, "the schema declares at least one property");
778
+ for (const name of names) {
779
+ if (name === "confidence" && schema.properties[name]?.optional)
780
+ throw new schemaerror(name, "the confidence property is the gate — declare it required");
781
+ validfield(name, schema.properties[name]);
782
+ }
783
+
784
+ const header = `Answer only a JSON object with exactly these properties${schema.instructions ? ` — ${schema.instructions}` : ""}. No prose, no fences.`;
785
+ const lines = names.map((name) => `- "${name}": ${describefield(schema.properties[name])}`);
786
+ if (!names.includes("confidence")) lines.push('- "confidence": a number between 0 and 1 — your self graded confidence in the answer.');
787
+ const instruction = [header, ...lines].join("\n");
788
+
789
+ return {
790
+ instruction,
791
+ fields: names,
792
+ validate(object) {
793
+ if (!object || typeof object !== "object" || Array.isArray(object))
794
+ throw new decisionerror("bad-verdict", null, "the schema verdict must be an object");
795
+ const value: Record<string, unknown> = {};
796
+ for (const name of names) {
797
+ const field = schema.properties[name];
798
+ const raw = object[name];
799
+ if (raw === undefined || raw === null) {
800
+ if (field?.optional) continue;
801
+ throw new decisionerror("bad-verdict", name, "the schema verdict misses a required property");
802
+ }
803
+ if (field?.type === "string") {
804
+ if (typeof raw !== "string") throw new decisionerror("bad-verdict", name, "the property answers a string");
805
+ const text = raw.trim();
806
+ if (field.enum && !field.enum.some((option) => option === text)) throw new decisionerror("bad-verdict", name, "the answer leaves the declared enum");
807
+ value[name] = text;
808
+ } else if (field?.type === "integer") {
809
+ if (typeof raw !== "number" || !Number.isInteger(raw)) throw new decisionerror("bad-verdict", name, "the property answers an integer");
810
+ if (field.enum && !field.enum.some((option) => option === raw)) throw new decisionerror("bad-verdict", name, "the answer leaves the declared enum");
811
+ value[name] = raw;
812
+ } else if (field?.type === "number") {
813
+ if (typeof raw !== "number" || !Number.isFinite(raw)) throw new decisionerror("bad-verdict", name, "the property answers a number");
814
+ if (field.enum && !field.enum.some((option) => option === raw)) throw new decisionerror("bad-verdict", name, "the answer leaves the declared enum");
815
+ value[name] = raw;
816
+ } else {
817
+ if (typeof raw !== "boolean") throw new decisionerror("bad-verdict", name, "the property answers true or false");
818
+ value[name] = raw;
819
+ }
820
+ }
821
+ for (const key of Object.keys(object)) {
822
+ if (key !== "confidence" && !names.includes(key))
823
+ throw new decisionerror("bad-verdict", key, "the answer carries an out-of-schema property");
824
+ }
825
+ const rawconfidence = typeof object.confidence === "number" ? object.confidence : Number(object.confidence);
826
+ if (!Number.isFinite(rawconfidence)) throw new decisionerror("bad-verdict", "confidence", "the verdict carries no numeric confidence");
827
+ return { value, confidence: Math.min(1, Math.max(0, rawconfidence)) };
828
+ },
829
+ };
830
+ }
831
+
832
+ /* --------------------------- the second layer -------------------------- */
833
+
834
+ /** the policy the schema verdict must satisfy (the record is the value). */
835
+ export type SchemaPolicy = {
836
+ /** the confidence floor (0-1); below it the fallback answers. */
837
+ minconfidence: number;
838
+ /** what answers when the gate closes (absent: the verdict fails honestly). */
839
+ fallback?: { value: Record<string, unknown>; reason: string } | undefined;
840
+ };
841
+
842
+ /** the verdict of one schema round (the record replaces the scalar value). */
843
+ export type SchemaVerdict =
844
+ | { ok: true; value: Record<string, unknown>; confidence: number }
845
+ | { ok: false; reason: string };
846
+
847
+ /**
848
+ * createschemamodel — the second layer over a declared shape: the same
849
+ * grammar as the engine (an injected completer, one repair round, the
850
+ * trail) with the schema instruction and the schema validator.
851
+ *
852
+ * @param config the completer, the declared schema and the policy.
853
+ * @returns the decide function over the schema.
854
+ */
855
+ export function createschemamodel(config: { complete: Completer; schema: JsonObjectSchema; policy: SchemaPolicy }): (prompt: string) => Promise<{ verdict: SchemaVerdict; trail: DecisionTrail }> {
856
+ if (!config || typeof config.complete !== "function")
857
+ throw new decisionerror("bad-policy", null, "the schema model carries an injected completer");
858
+ const compiled = compilejsonschema(config.schema);
859
+ const policy = config.policy;
860
+ if (!policy || !Number.isFinite(policy.minconfidence) || policy.minconfidence < 0 || policy.minconfidence > 1)
861
+ throw new decisionerror("bad-policy", null, "the schema policy floor must sit between 0 and 1");
862
+
863
+ return async (prompt) => {
864
+ if (typeof prompt !== "string" || prompt.trim() === "")
865
+ throw new decisionerror("bad-request", null, "the schema prompt must be a non-empty string");
866
+ const messages: Array<{ role: "system" | "user" | "assistant"; content: string }> = [
867
+ { role: "system", content: compiled.instruction },
868
+ { role: "user", content: prompt },
869
+ ];
870
+ const trail: DecisionTrail = { rounds: 0, answers: [], instruction: compiled.instruction };
871
+
872
+ let lasterror: unknown = null;
873
+ for (let round = 0; round < 2; round += 1) {
874
+ trail.rounds += 1;
875
+ const answer = await config.complete(messages);
876
+ trail.answers.push(answer);
877
+ try {
878
+ const parsed = compiled.validate(extractjsonobject(answer));
879
+ if (parsed.confidence >= policy.minconfidence)
880
+ return { verdict: { ok: true, value: parsed.value, confidence: parsed.confidence }, trail };
881
+ if (policy.fallback)
882
+ return { verdict: { ok: true, value: policy.fallback.value, confidence: parsed.confidence }, trail };
883
+ return { verdict: { ok: false, reason: `confidence ${parsed.confidence} below the policy floor ${policy.minconfidence}` }, trail };
884
+ } catch (error) {
885
+ lasterror = error;
886
+ if (round === 0)
887
+ messages.push({ role: "assistant", content: answer }, { role: "user", content: "Answer again with only the JSON object. No prose, no fences." });
888
+ }
889
+ }
890
+ const detail = lasterror instanceof decisionerror ? lasterror.detail : null;
891
+ return { verdict: { ok: false, reason: `the model never answered a schema-valid object${detail ? ` (${detail})` : ""}` }, trail };
892
+ };
893
+ }
894
+
895
+ /* ----------------------------- the bridge ------------------------------ */
896
+
897
+ /**
898
+ * schemafromdecision — the fixed kinds as declared schemas (the reverse
899
+ * bridge): the engine requests and the schema requests interoperate over
900
+ * the same object grammar.
901
+ *
902
+ * @param request the fixed-kind shape (kind, options, range).
903
+ * @returns the equivalent declared schema.
904
+ */
905
+ export function schemafromdecision(request: Pick<DecisionRequest, "kind" | "options" | "range">): JsonObjectSchema {
906
+ if (request.kind === "choice") {
907
+ if (!Array.isArray(request.options) || request.options.length < 2)
908
+ throw new schemaerror("options", "a choice schema carries at least two candidates");
909
+ return {
910
+ properties: {
911
+ value: { type: "string", enum: [...request.options], instructions: "the chosen option" },
912
+ confidence: { type: "number", instructions: "between 0 and 1" },
913
+ },
914
+ };
915
+ }
916
+ if (request.kind === "score") {
917
+ const range = request.range ?? { min: 0, max: 1 };
918
+ return {
919
+ instructions: `a number between ${range.min} and ${range.max}`,
920
+ properties: {
921
+ value: { type: "number", instructions: `between ${range.min} and ${range.max}` },
922
+ confidence: { type: "number", instructions: "between 0 and 1" },
923
+ },
924
+ };
925
+ }
926
+ return {
927
+ properties: {
928
+ value: { type: "boolean", instructions: "true or false" },
929
+ confidence: { type: "number", instructions: "between 0 and 1" },
930
+ },
931
+ };
932
+ }