toolnexus 0.20.0 → 0.21.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/judge.ts ADDED
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Simple judgments (SPEC.md §8B "Simple judgments — ask / gate", change `add-judge-adapters`).
3
+ * A thin layer over any `Classifier`: named question builders, a role-carrying `State`,
4
+ * `ask` (answers by name with bands) and `gate` / `Policy` (rules that escalate instead of
5
+ * deciding when unsure). The wire is unchanged — the builders produce exactly the §8B
6
+ * `evaluate(state, questions)` inputs.
7
+ *
8
+ * A gate never authorises; it only declines to decide.
9
+ */
10
+ import {
11
+ Classifier,
12
+ Decision,
13
+ type DecisionAnswer,
14
+ type NoulCriteria,
15
+ type Question,
16
+ type RecordedDecision,
17
+ noul as noulQ,
18
+ choice as choiceQ,
19
+ score as scoreQ,
20
+ } from "./classifier.js"
21
+ import type { Request } from "./types.js"
22
+
23
+ // ---------------------------------------------------------------- questions
24
+
25
+ /** One named question of an ordered question list. */
26
+ export interface NamedQuestion {
27
+ name: string
28
+ question: Question
29
+ }
30
+
31
+ /** `noul` question with a name. Name the state field it judges in `instructions`. */
32
+ export function noul(name: string, instructions: string, criteria?: NoulCriteria): NamedQuestion {
33
+ return { name, question: noulQ(instructions, criteria) }
34
+ }
35
+ /** `choice` question with a name; `options` maps option -> what picking it MEANS. */
36
+ export function choice(name: string, instructions: string, options: Record<string, string>): NamedQuestion {
37
+ return { name, question: choiceQ(instructions, options) }
38
+ }
39
+ /** `score` question with a name; `levels` is the ordered rubric. */
40
+ export function score(name: string, instructions: string, levels: string[]): NamedQuestion {
41
+ return { name, question: scoreQ(instructions, levels) }
42
+ }
43
+
44
+ /** Ordered list -> the §8B questions map. A duplicate name is an error naming it. */
45
+ export function questionMap(questions: readonly NamedQuestion[]): Record<string, Question> {
46
+ const out: Record<string, Question> = {}
47
+ for (const { name, question } of questions) {
48
+ if (Object.prototype.hasOwnProperty.call(out, name)) throw new Error(`duplicate question name ${JSON.stringify(name)}`)
49
+ out[name] = question
50
+ }
51
+ return out
52
+ }
53
+
54
+ // ---------------------------------------------------------------- state
55
+
56
+ /** `role` next to the data's fields; a non-object value goes under `data`. */
57
+ export function State(role: string, data: unknown): Record<string, unknown> {
58
+ if (data !== null && typeof data === "object" && !Array.isArray(data)) return { role, ...(data as object) }
59
+ return { role, data }
60
+ }
61
+
62
+ /** Sugar: `{context, message, ...extra}`. */
63
+ export function context(ctx: string, message: string, extra: Record<string, unknown> = {}): Record<string, unknown> {
64
+ return { context: ctx, message, ...extra }
65
+ }
66
+
67
+ // ---------------------------------------------------------------- bands + answers
68
+
69
+ export type Band = "yes" | "no" | "uncertain"
70
+ export interface Bands {
71
+ low: number
72
+ high: number
73
+ }
74
+ /** Default cut-points; exclusive on the confident side. */
75
+ export const DEFAULT_BANDS: Readonly<Bands> = Object.freeze({ low: 0.3, high: 0.7 })
76
+
77
+ /** One answer from `ask`: the §8B answer plus `band` (noul) or `sure` (choice/score). */
78
+ export type Answer = DecisionAnswer & {
79
+ /** noul only. */
80
+ band?: Band
81
+ /** choice / score only. */
82
+ sure?: boolean
83
+ /** The one number: noul probability, score value, or choice confidence. */
84
+ value(): number
85
+ /** The picked option of a choice answer ("" otherwise). JS keeps §8B's `choice` string FIELD on a
86
+ * choice answer (a method of the same name would shadow it), so this is `pick()`. */
87
+ pick(): string
88
+ }
89
+ export type Answers = Record<string, Answer>
90
+
91
+ function noulBand(p: number, b: Bands): Band {
92
+ return p < b.low ? "no" : p > b.high ? "yes" : "uncertain"
93
+ }
94
+
95
+ function wrap(a: DecisionAnswer, b: Bands): Answer {
96
+ const extra =
97
+ a.type === "noul"
98
+ ? { band: noulBand(a.noul, b) }
99
+ : { sure: a.confidence > b.high && !(a.type === "choice" && a.nearUniform) }
100
+ const value = () => (a.type === "noul" ? a.noul : a.type === "score" ? a.score : a.confidence)
101
+ const pick = () => (a.type === "choice" ? a.choice : "")
102
+ const out = { ...a, ...extra } as Answer
103
+ Object.defineProperty(out, "value", { value, enumerable: false })
104
+ Object.defineProperty(out, "pick", { value: pick, enumerable: false })
105
+ return out
106
+ }
107
+
108
+ /** Band / sure every answer of a decision. */
109
+ export function answersOf(decision: Decision, bands: Bands = DEFAULT_BANDS): Answers {
110
+ return Object.fromEntries(Object.entries(decision.answers).map(([k, a]) => [k, wrap(a, bands)]))
111
+ }
112
+
113
+ /** Evaluate once; answers by name, each with a band (noul) or `sure` (choice/score). */
114
+ export async function ask(
115
+ classifier: Classifier,
116
+ state: unknown,
117
+ questions: readonly NamedQuestion[],
118
+ bands: Bands = DEFAULT_BANDS,
119
+ ): Promise<Answers> {
120
+ return answersOf(await classifier.evaluate(state, questionMap(questions)), bands)
121
+ }
122
+
123
+ // ---------------------------------------------------------------- gate / policy
124
+
125
+ export interface Rule {
126
+ question: string
127
+ below?: number
128
+ at_least?: number
129
+ is?: string
130
+ action: string
131
+ target?: string
132
+ }
133
+
134
+ export interface GateOutcome {
135
+ action: string
136
+ target: string
137
+ escalated: boolean
138
+ /** Present iff escalated: a §10 `input` Request. */
139
+ request?: Request
140
+ answers: Answers
141
+ }
142
+
143
+ export interface Policy {
144
+ rules: Rule[]
145
+ /** The no-rule-fired action; empty escalates with reason "no rule fired". */
146
+ default?: string
147
+ bands?: Bands
148
+ /** Skip a rule whose answer is uncertain instead of escalating on it. */
149
+ skipUncertain?: boolean
150
+ }
151
+
152
+ function unsure(a: Answer): boolean {
153
+ return a.type === "noul" ? a.band === "uncertain" : !a.sure
154
+ }
155
+
156
+ function escalate(answers: Answers, id: string, question: string, reason: string, prompt: string): GateOutcome {
157
+ return {
158
+ action: "needs_input",
159
+ target: "",
160
+ escalated: true,
161
+ answers,
162
+ request: { id, kind: "input", prompt, data: { question, reason, answers } },
163
+ }
164
+ }
165
+
166
+ function applyRules(answers: Answers, rules: readonly Rule[], skipUncertain: boolean): GateOutcome | undefined {
167
+ for (const [i, r] of rules.entries()) {
168
+ const a = answers[r.question]
169
+ if (!a || unsure(a)) {
170
+ if (a && skipUncertain) continue
171
+ const reason = `${a ? "uncertain" : "missing"} answer "${r.question}"`
172
+ return escalate(
173
+ answers,
174
+ `gate:${i}:${r.question}`,
175
+ r.question,
176
+ reason,
177
+ `Classifier is unsure about "${r.question}" (${reason}). Decide rule ${i} (${r.action}).`,
178
+ )
179
+ }
180
+ const key = JSON.stringify(r.question)
181
+ let fired = false
182
+ let misfit = ""
183
+ if (r.is !== undefined) {
184
+ if (a.type === "choice") fired = a.choice === r.is
185
+ else misfit = `is-rule on ${a.type} answer ${key}`
186
+ } else if (a.type === "choice") {
187
+ misfit = `numeric rule on choice answer ${key}`
188
+ } else {
189
+ const v = a.type === "noul" ? a.noul : a.score
190
+ if (r.below !== undefined) fired = v < r.below
191
+ else if (r.at_least !== undefined) fired = v >= r.at_least
192
+ else misfit = `rule on ${key} has no below / at_least / is`
193
+ }
194
+ // A rule that does not fit its answer escalates; it is never skipped.
195
+ if (misfit)
196
+ return escalate(
197
+ answers,
198
+ `gate:${i}:${r.question}`,
199
+ r.question,
200
+ misfit,
201
+ `Rule ${i} (${r.action}) does not fit its answer (${misfit}). Decide.`,
202
+ )
203
+ if (fired) return { action: r.action, target: r.target ?? "", escalated: false, answers }
204
+ }
205
+ return undefined
206
+ }
207
+
208
+ /** Pure half of `gate`: first-match rules; an unsure or missing answer on rule i escalates and wins. */
209
+ export function applyGate(answers: Answers, rules: readonly Rule[]): GateOutcome {
210
+ return applyRules(answers, rules, false) ?? { action: "", target: "", escalated: false, answers }
211
+ }
212
+
213
+ /** Pure half of a `Policy`. */
214
+ export function applyPolicy(answers: Answers, policy: Policy): GateOutcome {
215
+ const hit = applyRules(answers, policy.rules, policy.skipUncertain ?? false)
216
+ if (hit) return hit
217
+ if (policy.default) return { action: policy.default, target: "", escalated: false, answers }
218
+ return escalate(answers, "gate:default", "", "no rule fired", "No rule fired. Decide the action.")
219
+ }
220
+
221
+ /** Ask, then apply rules; no rule fired -> empty action (use a `Policy` for a declared default). */
222
+ export async function gate(
223
+ classifier: Classifier,
224
+ state: unknown,
225
+ questions: readonly NamedQuestion[],
226
+ rules: readonly Rule[],
227
+ bands: Bands = DEFAULT_BANDS,
228
+ ): Promise<GateOutcome> {
229
+ return applyGate(await ask(classifier, state, questions, bands), rules)
230
+ }
231
+
232
+ /** Ask, then apply a `Policy` (rules, default, bands, skipUncertain). */
233
+ export async function decide(
234
+ classifier: Classifier,
235
+ state: unknown,
236
+ questions: readonly NamedQuestion[],
237
+ policy: Policy,
238
+ ): Promise<GateOutcome> {
239
+ return applyPolicy(await ask(classifier, state, questions, policy.bands ?? DEFAULT_BANDS), policy)
240
+ }
241
+
242
+ // ---------------------------------------------------------------- static + tape
243
+
244
+ /** One-line `static` classifier from recorded decisions. */
245
+ export function staticClassifier(decisions: RecordedDecision[] | RecordedDecision, model?: string): Classifier {
246
+ return new Classifier({ style: "static", decisions: Array.isArray(decisions) ? decisions : [decisions], model })
247
+ }
248
+
249
+ /** The recorded-body form of a Decision, decodable by `static`. */
250
+ function toRaw(d: Decision): Record<string, unknown> {
251
+ const answers: Record<string, unknown> = {}
252
+ for (const [k, a] of Object.entries(d.answers)) {
253
+ if (a.type === "choice") {
254
+ const { nearUniform: _derived, ...rest } = a
255
+ answers[k] = rest
256
+ } else answers[k] = { ...a }
257
+ }
258
+ const usage: Record<string, number> = { input_tokens: d.usage.inputTokens, output_tokens: d.usage.outputTokens }
259
+ if (d.usage.cost !== undefined) usage.cost = d.usage.cost
260
+ return { model: d.model, answers, usage, calibrated: d.calibrated }
261
+ }
262
+
263
+ /**
264
+ * Records live decisions keyed by a caller-given call name, and replays them with no network.
265
+ * `tape.classifier(name)` returns a Classifier: recording (live given) or replaying (no live).
266
+ */
267
+ export class Tape {
268
+ readonly entries: Record<string, RecordedDecision>
269
+
270
+ constructor(
271
+ private readonly live?: Classifier,
272
+ entries: Record<string, RecordedDecision> = {},
273
+ ) {
274
+ this.entries = { ...entries }
275
+ }
276
+
277
+ /** A replay-only tape from saved entries. */
278
+ static replay(entries: Record<string, RecordedDecision>): Tape {
279
+ return new Tape(undefined, entries)
280
+ }
281
+
282
+ classifier(name: string): Classifier {
283
+ const live = this.live
284
+ if (live) {
285
+ return new Classifier({
286
+ style: "custom",
287
+ evaluate: async (state, questions, signal) => {
288
+ const d = await live.evaluate(state, questions, signal)
289
+ this.entries[name] = { state, questions, response: toRaw(d) }
290
+ return d
291
+ },
292
+ })
293
+ }
294
+ // Replay: resolved when evaluated (a miss fails then, naming the key, and sends nothing).
295
+ return new Classifier({
296
+ style: "custom",
297
+ evaluate: async (state, questions, signal) => {
298
+ const rec = this.entries[name]
299
+ if (!rec) throw new Error(`tape: no recorded decision for call "${name}"`)
300
+ const raw = rec.response as { model?: string }
301
+ return staticClassifier(rec, raw?.model).evaluate(state, questions, signal)
302
+ },
303
+ })
304
+ }
305
+ }