@llm4ts/flow 2.4.2 → 2.6.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.
Files changed (82) hide show
  1. package/dist/Classified.d.ts +2 -0
  2. package/dist/Classified.d.ts.map +1 -1
  3. package/dist/Classified.js +4 -0
  4. package/dist/Classified.js.map +1 -1
  5. package/dist/CostReport.d.ts +110 -0
  6. package/dist/CostReport.d.ts.map +1 -0
  7. package/dist/CostReport.js +313 -0
  8. package/dist/CostReport.js.map +1 -0
  9. package/dist/EstimatedUsage.d.ts.map +1 -1
  10. package/dist/EstimatedUsage.js +4 -0
  11. package/dist/EstimatedUsage.js.map +1 -1
  12. package/dist/Flow.d.ts +14 -0
  13. package/dist/Flow.d.ts.map +1 -1
  14. package/dist/Flow.js +45 -2
  15. package/dist/Flow.js.map +1 -1
  16. package/dist/FlowContext.d.ts +7 -0
  17. package/dist/FlowContext.d.ts.map +1 -1
  18. package/dist/FlowContext.js.map +1 -1
  19. package/dist/FlowEvents.d.ts +44 -1
  20. package/dist/FlowEvents.d.ts.map +1 -1
  21. package/dist/FlowEvents.js +35 -0
  22. package/dist/FlowEvents.js.map +1 -1
  23. package/dist/GitTool.d.ts +7 -0
  24. package/dist/GitTool.d.ts.map +1 -1
  25. package/dist/GitTool.js +15 -1
  26. package/dist/GitTool.js.map +1 -1
  27. package/dist/Judgment.d.ts +88 -0
  28. package/dist/Judgment.d.ts.map +1 -0
  29. package/dist/Judgment.js +281 -0
  30. package/dist/Judgment.js.map +1 -0
  31. package/dist/JudgmentDataset.d.ts +79 -0
  32. package/dist/JudgmentDataset.d.ts.map +1 -0
  33. package/dist/JudgmentDataset.js +164 -0
  34. package/dist/JudgmentDataset.js.map +1 -0
  35. package/dist/JudgmentEval.d.ts +143 -0
  36. package/dist/JudgmentEval.d.ts.map +1 -0
  37. package/dist/JudgmentEval.js +242 -0
  38. package/dist/JudgmentEval.js.map +1 -0
  39. package/dist/JudgmentLog.d.ts +46 -0
  40. package/dist/JudgmentLog.d.ts.map +1 -0
  41. package/dist/JudgmentLog.js +62 -0
  42. package/dist/JudgmentLog.js.map +1 -0
  43. package/dist/JudgmentTypes.d.ts +13 -0
  44. package/dist/JudgmentTypes.d.ts.map +1 -0
  45. package/dist/JudgmentTypes.js +11 -0
  46. package/dist/JudgmentTypes.js.map +1 -0
  47. package/dist/ProgramJudge.d.ts +15 -2
  48. package/dist/ProgramJudge.d.ts.map +1 -1
  49. package/dist/ProgramJudge.js +58 -3
  50. package/dist/ProgramJudge.js.map +1 -1
  51. package/dist/Replay.d.ts.map +1 -1
  52. package/dist/Replay.js +9 -4
  53. package/dist/Replay.js.map +1 -1
  54. package/dist/Review.d.ts +35 -0
  55. package/dist/Review.d.ts.map +1 -1
  56. package/dist/Review.js +92 -13
  57. package/dist/Review.js.map +1 -1
  58. package/dist/Reviewer.d.ts +8 -0
  59. package/dist/Reviewer.d.ts.map +1 -1
  60. package/dist/Reviewer.js +12 -1
  61. package/dist/Reviewer.js.map +1 -1
  62. package/dist/TransientRetry.d.ts.map +1 -1
  63. package/dist/TransientRetry.js +1 -0
  64. package/dist/TransientRetry.js.map +1 -1
  65. package/package.json +8 -3
  66. package/src/Classified.ts +5 -0
  67. package/src/CostReport.ts +402 -0
  68. package/src/EstimatedUsage.ts +15 -0
  69. package/src/Flow.ts +77 -2
  70. package/src/FlowContext.ts +7 -0
  71. package/src/FlowEvents.ts +42 -0
  72. package/src/GitTool.ts +19 -1
  73. package/src/Judgment.ts +418 -0
  74. package/src/JudgmentDataset.ts +254 -0
  75. package/src/JudgmentEval.ts +306 -0
  76. package/src/JudgmentLog.ts +96 -0
  77. package/src/JudgmentTypes.ts +15 -0
  78. package/src/ProgramJudge.ts +89 -4
  79. package/src/Replay.ts +23 -17
  80. package/src/Review.ts +163 -13
  81. package/src/Reviewer.ts +15 -1
  82. package/src/TransientRetry.ts +1 -0
package/src/GitTool.ts CHANGED
@@ -90,6 +90,17 @@ const problem = (result: ProcessResult): string => {
90
90
  return detail.length === 0 ? `process exited with code ${result.exitCode}` : detail
91
91
  }
92
92
 
93
+ /**
94
+ * The runner's own bookkeeping under `workDir/.llm4ts/`: the run trace and
95
+ * the cost ledger. `commitAll` never stages them — they grow while the run
96
+ * commits and belong to the machine, not the repository — while the rest of
97
+ * `.llm4ts/` (plans, forked packs) is committed as before.
98
+ */
99
+ export const runnerBookkeeping: ReadonlyArray<string> = [
100
+ ".llm4ts/trace-*.jsonl",
101
+ ".llm4ts/costs.jsonl"
102
+ ]
103
+
93
104
  export const makeGitTool = (
94
105
  process: ProcessExecutorShape,
95
106
  workDir: string,
@@ -200,7 +211,14 @@ export const makeGitTool = (
200
211
  const checkout = (name: string): Effect.Effect<void, FlowError> =>
201
212
  write("git checkout", runOrFail(["checkout", name]).pipe(Effect.asVoid))
202
213
 
203
- const addAll = runOrFail(["add", "-A"]).pipe(Effect.asVoid)
214
+ // Stage everything, then take the runner's bookkeeping back out of the
215
+ // index: `reset -- <glob>` leaves an ignored or unmatched glob alone and
216
+ // keeps a previously committed trace at its HEAD version, whereas an
217
+ // `:(exclude)` pathspec makes `add` refuse the ignored files outright.
218
+ const addAll = runOrFail(["add", "-A"]).pipe(
219
+ Effect.andThen(runOrFail(["reset", "-q", "--", ...runnerBookkeeping])),
220
+ Effect.asVoid
221
+ )
204
222
 
205
223
  const commitStaged = (message: string): Effect.Effect<CommitResult, FlowError> =>
206
224
  Effect.gen(function* () {
@@ -0,0 +1,418 @@
1
+ import * as Effect from "effect/Effect"
2
+ import * as Schema from "effect/Schema"
3
+ import { ProviderError } from "@llm4ts/core/Errors"
4
+ import type { LlmServiceShape } from "@llm4ts/core/LlmService"
5
+ import type { JsonSchema } from "@llm4ts/core/Models"
6
+ import type {
7
+ JudgmentBackendError,
8
+ JudgmentInput,
9
+ JudgmentShape
10
+ } from "@llm4ts/core/judgment/Judgment"
11
+ import { makeLlmJudgment } from "@llm4ts/core/judgment/LlmJudgment"
12
+ import {
13
+ ChoiceAnswer,
14
+ JudgmentResult,
15
+ ScoreAnswer,
16
+ TruthAnswer,
17
+ confidenceOf,
18
+ expectedScore,
19
+ origins,
20
+ renderState,
21
+ type Answer,
22
+ type AnswerOrigin,
23
+ type JudgmentBackend,
24
+ type Question
25
+ } from "@llm4ts/core/judgment/Schemas"
26
+ import { ParseError } from "@llm4ts/core/Errors"
27
+ import type { FlowContextShape } from "./FlowContext.ts"
28
+ import { FlowLlmError, PersistenceError, type FlowError } from "./FlowError.ts"
29
+ import { Info, type FlowEventsShape } from "./FlowEvents.ts"
30
+ import type { PlainFileStoreShape } from "./Persistence.ts"
31
+ import { type Decision } from "./JudgmentTypes.ts"
32
+
33
+ export { Decision, JudgmentMode } from "./JudgmentTypes.ts"
34
+
35
+ /**
36
+ * Judgment policy (ADR 0017): core answers questions and reports their
37
+ * origin; this module decides what a flow does with the number. Thresholds
38
+ * are keyed first by calibration evidence (`measured`, then `claimed`) and
39
+ * otherwise by extraction method, because a model that writes its own
40
+ * probabilities (`verbalized`) is held to a higher bar than one whose
41
+ * log-probabilities were read (`logprobs`), and a reasoning model's typed
42
+ * reply (`reasoning`) sits between. An answer whose `support` (mass the
43
+ * backend actually placed on the offered options) is below `minSupport` is
44
+ * held whatever its confidence. Jev's advice, kept: questions and
45
+ * thresholds in one place.
46
+ */
47
+
48
+ export class CertaintyBands extends Schema.Class<CertaintyBands>("CertaintyBands")({
49
+ /** At or above: act automatically. */
50
+ act: Schema.Number,
51
+ /** Below: do not act; escalate or hold. Between the two: proceed with caution. */
52
+ hold: Schema.Number
53
+ }) {}
54
+
55
+ const thresholds = (act: number, hold: number) =>
56
+ CertaintyBands.pipe(
57
+ Schema.withConstructorDefault(Effect.succeed(CertaintyBands.make({ act, hold })))
58
+ )
59
+
60
+ export class JudgmentPolicy extends Schema.Class<JudgmentPolicy>("JudgmentPolicy")({
61
+ /** Calibration evidence produced in this project: trusted most. */
62
+ measured: thresholds(0.8, 0.5),
63
+ /** The provider claims calibration (TypeSafe). */
64
+ claimed: thresholds(0.85, 0.5),
65
+ /** No evidence; keyed by how the numbers were extracted. */
66
+ logprobs: thresholds(0.9, 0.6),
67
+ sampled: thresholds(0.9, 0.6),
68
+ reasoning: thresholds(0.9, 0.6),
69
+ verbalized: thresholds(0.95, 0.7),
70
+ /** Below this share of mass on the offered options, hold regardless. */
71
+ minSupport: Schema.Number.pipe(Schema.withConstructorDefault(Effect.succeed(0.5)))
72
+ }) {
73
+ thresholds(origin: AnswerOrigin): CertaintyBands {
74
+ switch (origin.calibration) {
75
+ case "measured":
76
+ return this.measured
77
+ case "claimed":
78
+ return this.claimed
79
+ case "none":
80
+ return origin.method === "hosted" ? this.claimed : this[origin.method]
81
+ }
82
+ }
83
+ }
84
+
85
+ export const defaultJudgmentPolicy = JudgmentPolicy.make({})
86
+
87
+ /**
88
+ * How sure an answer is, whatever its kind. A Choice or Score carries its
89
+ * confidence (the peak probability). A Truth's certainty is its distance
90
+ * from even, scaled so 0.5 is 0 and either extreme is 1: `|2·truth − 1|`.
91
+ */
92
+ export const certaintyOf = (answer: Answer): number =>
93
+ answer.type === "truth" ? Math.abs(2 * answer.truth - 1) : answer.confidence
94
+
95
+ /**
96
+ * The three bands Jev documents: act, proceed with caution, or hold. An
97
+ * answer rebuilt from too little mass on the offered options is always held.
98
+ */
99
+ export const decide = (
100
+ answer: Answer,
101
+ policy: JudgmentPolicy = defaultJudgmentPolicy
102
+ ): Decision => {
103
+ if (answer.support < policy.minSupport) {
104
+ return "hold"
105
+ }
106
+ const certainty = certaintyOf(answer)
107
+ const bands = policy.thresholds(answer.origin)
108
+ return certainty >= bands.act ? "act" : certainty < bands.hold ? "hold" : "caution"
109
+ }
110
+
111
+ /** The context's judgment service, or one derived from its reasoning seat. */
112
+ export const judgmentOf = (context: FlowContextShape): JudgmentShape =>
113
+ context.judgment ?? makeLlmJudgment(context.reasoning)
114
+
115
+ const backendFailure = (error: JudgmentBackendError): FlowError =>
116
+ FlowLlmError.from(ProviderError.make({ message: error.message, cause: error }))
117
+
118
+ // Escalation asks a reasoning model the same question in its own words and
119
+ // decodes a typed reply, so the schema per kind mirrors the answer shape.
120
+ class EscalatedChoice extends Schema.Class<EscalatedChoice>("EscalatedChoice")({
121
+ choice: Schema.String,
122
+ probabilities: Schema.Record(Schema.String, Schema.Number)
123
+ }) {}
124
+ class EscalatedScore extends Schema.Class<EscalatedScore>("EscalatedScore")({
125
+ probabilities: Schema.Record(Schema.String, Schema.Number)
126
+ }) {}
127
+ class EscalatedTruth extends Schema.Class<EscalatedTruth>("EscalatedTruth")({
128
+ truth: Schema.Number
129
+ }) {}
130
+
131
+ const numberMap = (keys: ReadonlyArray<string>): JsonSchema => ({
132
+ type: "object",
133
+ properties: Object.fromEntries(keys.map((key) => [key, { type: "number" }])),
134
+ required: [...keys],
135
+ additionalProperties: false
136
+ })
137
+
138
+ const escalationPrompt = (state: JudgmentInput["state"], question: Question): string => {
139
+ const header = `State:\n${renderState(state)}\n\nThink carefully, then answer with JSON only.\n`
140
+ switch (question.type) {
141
+ case "choice":
142
+ return (
143
+ `${header}Question: ${question.instructions}\nOptions:\n` +
144
+ Object.entries(question.criteria)
145
+ .map(([key, description]) => `- ${key}: ${JSON.stringify(description)}`)
146
+ .join("\n") +
147
+ '\nReply: {"choice": <option key>, "probabilities": {<a probability per option, summing to 1>}}'
148
+ )
149
+ case "score":
150
+ return (
151
+ `${header}Rate: ${question.instructions}\nLevels:\n` +
152
+ question.criteria.map((level, index) => `- ${index}: ${JSON.stringify(level)}`).join("\n") +
153
+ '\nReply: {"probabilities": {<a probability per level index, summing to 1>}}'
154
+ )
155
+ case "truth":
156
+ return (
157
+ `${header}Statement: ${question.instructions}\n` +
158
+ (question.criteria === undefined
159
+ ? ""
160
+ : `True means: ${JSON.stringify(question.criteria.true)}\nFalse means: ${JSON.stringify(question.criteria.false)}\n`) +
161
+ 'Reply: {"truth": <probability between 0 and 1 that the statement holds>}'
162
+ )
163
+ }
164
+ }
165
+
166
+ /** Renormalize over the offered keys; fails typed when nothing was placed on them. */
167
+ const normalized = (
168
+ keys: ReadonlyArray<string>,
169
+ raw: Readonly<Record<string, number>>
170
+ ): Effect.Effect<Record<string, number>, ParseError> => {
171
+ const kept = keys.map((key) => [key, Math.max(0, raw[key] ?? 0)] as const)
172
+ const total = kept.reduce((sum, [, value]) => sum + value, 0)
173
+ return total > 0
174
+ ? Effect.succeed(Object.fromEntries(kept.map(([key, value]) => [key, value / total])))
175
+ : Effect.fail(
176
+ ParseError.make({
177
+ message: "the reasoning seat placed no probability on any offered option",
178
+ raw: JSON.stringify(raw)
179
+ })
180
+ )
181
+ }
182
+
183
+ const escalate = (
184
+ reasoning: LlmServiceShape,
185
+ backend: JudgmentBackend,
186
+ state: JudgmentInput["state"],
187
+ question: Question
188
+ ): Effect.Effect<Answer, FlowError> => {
189
+ const prompt = escalationPrompt(state, question)
190
+ const origin = origins.escalated(backend)
191
+ switch (question.type) {
192
+ case "choice": {
193
+ const keys = Object.keys(question.criteria)
194
+ return reasoning
195
+ .executeStructured(prompt, EscalatedChoice, {
196
+ type: "object",
197
+ properties: {
198
+ choice: { type: "string", enum: [...keys] },
199
+ probabilities: numberMap(keys)
200
+ },
201
+ required: ["choice", "probabilities"]
202
+ })
203
+ .pipe(
204
+ Effect.flatMap((reply) =>
205
+ // A choice the model gave no mass contradicts itself: fail rather
206
+ // than invent certainty, and the caller keeps its earlier answer.
207
+ keys.includes(reply.choice) && !((reply.probabilities[reply.choice] ?? 0) > 0)
208
+ ? Effect.fail(
209
+ ParseError.make({
210
+ message: `the reasoning seat chose "${reply.choice}" but gave it no probability`,
211
+ raw: JSON.stringify(reply.probabilities)
212
+ })
213
+ )
214
+ : Effect.map(normalized(keys, reply.probabilities), (probabilities) =>
215
+ ChoiceAnswer.make({
216
+ type: "choice",
217
+ choice: keys.includes(reply.choice) ? reply.choice : (keys[0] ?? ""),
218
+ probabilities,
219
+ confidence: confidenceOf(probabilities),
220
+ origin
221
+ })
222
+ )
223
+ ),
224
+ Effect.mapError(FlowLlmError.from)
225
+ )
226
+ }
227
+ case "score": {
228
+ const keys = question.criteria.map((_, index) => String(index))
229
+ return reasoning
230
+ .executeStructured(prompt, EscalatedScore, {
231
+ type: "object",
232
+ properties: { probabilities: numberMap(keys) },
233
+ required: ["probabilities"]
234
+ })
235
+ .pipe(
236
+ Effect.flatMap((reply) =>
237
+ Effect.map(normalized(keys, reply.probabilities), (probabilities) =>
238
+ ScoreAnswer.make({
239
+ type: "score",
240
+ score: expectedScore(probabilities),
241
+ legend: Object.fromEntries(
242
+ question.criteria.map((level, index) => [String(index), level])
243
+ ),
244
+ probabilities,
245
+ confidence: confidenceOf(probabilities),
246
+ origin
247
+ })
248
+ )
249
+ ),
250
+ Effect.mapError(FlowLlmError.from)
251
+ )
252
+ }
253
+ case "truth":
254
+ return reasoning
255
+ .executeStructured(prompt, EscalatedTruth, {
256
+ type: "object",
257
+ properties: { truth: { type: "number" } },
258
+ required: ["truth"]
259
+ })
260
+ .pipe(
261
+ Effect.map((reply) =>
262
+ TruthAnswer.make({
263
+ type: "truth",
264
+ truth: Math.max(0, Math.min(1, reply.truth)),
265
+ origin
266
+ })
267
+ ),
268
+ Effect.mapError(FlowLlmError.from)
269
+ )
270
+ }
271
+ }
272
+
273
+ export interface JudgeOrEscalateOptions {
274
+ readonly judgment: JudgmentShape
275
+ readonly reasoning: LlmServiceShape
276
+ readonly events: FlowEventsShape
277
+ readonly request: JudgmentInput
278
+ readonly policy?: JudgmentPolicy
279
+ }
280
+
281
+ /**
282
+ * Run the judgment; every answer the policy would `hold`, and every question
283
+ * that failed, is asked again of the reasoning seat and replaced with an
284
+ * `escalated` answer. An escalation that fails keeps what it had, so the
285
+ * caller always sees the most informed answer available.
286
+ */
287
+ export const judgeOrEscalate = Effect.fn("@llm4ts/flow/Judgment.judgeOrEscalate")(function* (
288
+ options: JudgeOrEscalateOptions
289
+ ): Effect.fn.Return<JudgmentResult, FlowError> {
290
+ const policy = options.policy ?? defaultJudgmentPolicy
291
+ const result = yield* options.judgment
292
+ .judge(options.request)
293
+ .pipe(Effect.mapError(backendFailure))
294
+ const answers: Record<string, Answer> = { ...result.answers }
295
+ const failures = [...result.failures]
296
+ for (const [key, question] of Object.entries(options.request.questions)) {
297
+ const current = answers[key]
298
+ const reason =
299
+ current === undefined
300
+ ? (failures.find((failure) => failure.key === key)?.reason ?? "no answer")
301
+ : decide(current, policy) === "hold"
302
+ ? current.support < policy.minSupport
303
+ ? `${current.origin.method} support ${current.support.toFixed(2)} below ${policy.minSupport}`
304
+ : `${current.origin.method} certainty ${certaintyOf(current).toFixed(2)} below hold`
305
+ : undefined
306
+ if (reason === undefined) {
307
+ continue
308
+ }
309
+ yield* options.events.publish(
310
+ Info.make({ message: `judgment '${key}' escalated to the reasoning seat: ${reason}` })
311
+ )
312
+ const escalated = yield* escalate(
313
+ options.reasoning,
314
+ result.backend,
315
+ options.request.state,
316
+ question
317
+ ).pipe(
318
+ Effect.map((answer): Answer | undefined => answer),
319
+ Effect.catch((error) =>
320
+ options.events
321
+ .publish(
322
+ Info.make({
323
+ message: `judgment '${key}' escalation failed (${error._tag}); keeping the original answer`
324
+ })
325
+ )
326
+ .pipe(Effect.as<Answer | undefined>(undefined))
327
+ )
328
+ )
329
+ if (escalated !== undefined) {
330
+ answers[key] = escalated
331
+ const index = failures.findIndex((failure) => failure.key === key)
332
+ if (index >= 0) {
333
+ failures.splice(index, 1)
334
+ }
335
+ }
336
+ }
337
+ return JudgmentResult.make({
338
+ answers,
339
+ failures,
340
+ backend: result.backend,
341
+ ...(result.usage === undefined ? {} : { usage: result.usage }),
342
+ ...(result.model === undefined ? {} : { model: result.model })
343
+ })
344
+ })
345
+
346
+ class JudgmentCacheEntry extends Schema.Class<JudgmentCacheEntry>("JudgmentCacheEntry")({
347
+ fingerprint: Schema.String,
348
+ result: JudgmentResult
349
+ }) {}
350
+
351
+ const hexDigest = (buffer: ArrayBuffer): string =>
352
+ Array.from(new Uint8Array(buffer))
353
+ .map((byte) => byte.toString(16).padStart(2, "0"))
354
+ .join("")
355
+
356
+ /**
357
+ * A stable digest of state, questions and the judgment's identity (backend
358
+ * plus checkpoint): the cache key for one request. A retrained or swapped
359
+ * local model therefore never reuses its predecessor's answers.
360
+ */
361
+ export const judgmentFingerprint = (
362
+ request: JudgmentInput,
363
+ identity: string
364
+ ): Effect.Effect<string, PersistenceError> =>
365
+ Effect.tryPromise({
366
+ try: async () =>
367
+ hexDigest(
368
+ await globalThis.crypto.subtle.digest(
369
+ "SHA-256",
370
+ new TextEncoder().encode(
371
+ JSON.stringify({ identity, state: request.state, questions: request.questions })
372
+ )
373
+ )
374
+ ),
375
+ catch: (error) =>
376
+ PersistenceError.make({
377
+ message: `failed to fingerprint judgment: ${error instanceof Error ? error.message : String(error)}`,
378
+ cause: error
379
+ })
380
+ })
381
+
382
+ /**
383
+ * Answers persisted like `cachedReview`: a re-run re-judges only what
384
+ * changed (state, questions, or backend). A cached result carries the
385
+ * origin it was answered with, so policy still applies as before.
386
+ */
387
+ export const cachedJudgment = Effect.fn("@llm4ts/flow/Judgment.cached")(function* (
388
+ files: PlainFileStoreShape,
389
+ path: string,
390
+ judgment: JudgmentShape,
391
+ request: JudgmentInput
392
+ ): Effect.fn.Return<JudgmentResult, FlowError> {
393
+ const fingerprint = yield* judgmentFingerprint(request, judgment.identity)
394
+ const contents = yield* files.read(path).pipe(Effect.catch(() => Effect.succeed(undefined)))
395
+ const entry =
396
+ contents === undefined
397
+ ? undefined
398
+ : yield* Schema.decodeUnknownEffect(Schema.fromJsonString(JudgmentCacheEntry))(contents).pipe(
399
+ Effect.option,
400
+ Effect.map((option) => (option._tag === "Some" ? option.value : undefined))
401
+ )
402
+ if (entry?.fingerprint === fingerprint) {
403
+ return entry.result
404
+ }
405
+ const result = yield* judgment.judge(request).pipe(Effect.mapError(backendFailure))
406
+ const encoded = yield* Schema.encodeEffect(Schema.fromJsonString(JudgmentCacheEntry))(
407
+ JudgmentCacheEntry.make({ fingerprint, result })
408
+ ).pipe(
409
+ Effect.mapError((error) =>
410
+ PersistenceError.make({
411
+ message: `failed to encode judgment cache: ${String(error)}`,
412
+ cause: error
413
+ })
414
+ )
415
+ )
416
+ yield* files.writeAtomic(path, encoded)
417
+ return result
418
+ })
@@ -0,0 +1,254 @@
1
+ import * as Effect from "effect/Effect"
2
+ import * as Schema from "effect/Schema"
3
+ import { State, ScoreQuestion, TruthQuestion, truth } from "@llm4ts/core/judgment/Schemas"
4
+ import type { JudgmentObservation } from "./JudgmentLog.ts"
5
+ import type { PlainFileStoreShape } from "./Persistence.ts"
6
+ import type { Reviewer } from "./Reviewer.ts"
7
+
8
+ export const DatasetDecision = Schema.Literals([
9
+ "review-prescreen",
10
+ "satisfied-probe",
11
+ "program-judge"
12
+ ])
13
+ export type DatasetDecision = typeof DatasetDecision.Type
14
+ const Text = Schema.String.check(Schema.makeFilter((value) => value.trim().length > 0))
15
+ const DatasetQuestion = Schema.Union([TruthQuestion, ScoreQuestion])
16
+ const baseFields = {
17
+ id: Text,
18
+ decision: DatasetDecision,
19
+ state: State,
20
+ question: DatasetQuestion,
21
+ source: Text
22
+ }
23
+ const correctQuestion = (item: {
24
+ readonly decision: DatasetDecision
25
+ readonly question: typeof DatasetQuestion.Type
26
+ }) => item.question.type === (item.decision === "program-judge" ? "score" : "truth")
27
+
28
+ export const validLabel = (question: typeof DatasetQuestion.Type, label: unknown): boolean =>
29
+ question.type === "truth"
30
+ ? typeof label === "boolean"
31
+ : typeof label === "number" &&
32
+ Number.isInteger(label) &&
33
+ label >= 0 &&
34
+ label < question.criteria.length
35
+
36
+ const LabelledRecord = Schema.Struct({
37
+ ...baseFields,
38
+ label: Schema.Union([Schema.Boolean, Schema.Int]),
39
+ labelledBy: Text,
40
+ /** Human-supplied ISO 8601 timestamp. */
41
+ labelledAt: Schema.String.check(
42
+ Schema.makeFilter(
43
+ (value) => /^\d{4}-\d{2}-\d{2}T/.test(value) && Number.isFinite(Date.parse(value))
44
+ )
45
+ )
46
+ }).check(
47
+ Schema.makeFilter(correctQuestion),
48
+ Schema.makeFilter(
49
+ (item) =>
50
+ validLabel(item.question, item.label) ||
51
+ `Invalid label for ${item.id} (${item.question.type})`
52
+ )
53
+ )
54
+
55
+ export class LabelledItem extends Schema.Class<LabelledItem>("LabelledItem")(LabelledRecord) {}
56
+
57
+ /** Seeds omit the label; edits remain readable so promotion can report all refused ids. */
58
+ export class PendingItem extends Schema.Class<PendingItem>("PendingItem")(
59
+ Schema.Struct({
60
+ ...baseFields,
61
+ label: Schema.optionalKey(Schema.Json),
62
+ labelledBy: Schema.optionalKey(Schema.NullOr(Schema.String)),
63
+ labelledAt: Schema.optionalKey(Schema.NullOr(Schema.String))
64
+ }).check(Schema.makeFilter(correctQuestion))
65
+ ) {}
66
+
67
+ export class DatasetParseError extends Schema.TaggedError<DatasetParseError>()(
68
+ "DatasetParseError",
69
+ {
70
+ path: Schema.String,
71
+ line: Schema.Int,
72
+ message: Schema.String
73
+ }
74
+ ) {}
75
+
76
+ export class PromotionRefused extends Schema.TaggedError<PromotionRefused>()("PromotionRefused", {
77
+ ids: Schema.Array(Schema.String),
78
+ message: Schema.String
79
+ }) {}
80
+
81
+ /** A missing file is an empty set. Blank interior lines are malformed; a final newline is optional. */
82
+ export const readJsonLines = Effect.fn("JudgmentDataset.readJsonLines")(function* <A, I>(
83
+ files: PlainFileStoreShape,
84
+ path: string,
85
+ schema: Schema.Codec<A, I>
86
+ ) {
87
+ const contents = yield* files.read(path)
88
+ if (contents === undefined || contents === "") return []
89
+ const lines = contents.split("\n")
90
+ if (lines.at(-1) === "") lines.pop()
91
+ return yield* Effect.forEach(lines, (line, index) =>
92
+ Schema.decodeUnknownEffect(Schema.fromJsonString(schema))(line).pipe(
93
+ Effect.mapError(() =>
94
+ DatasetParseError.make({
95
+ path,
96
+ line: index + 1,
97
+ message: `Malformed JSONL record at ${path}:${index + 1}`
98
+ })
99
+ )
100
+ )
101
+ )
102
+ })
103
+
104
+ export const readDataset = (files: PlainFileStoreShape, path: string) =>
105
+ readJsonLines(files, path, LabelledItem)
106
+ export const readPending = (files: PlainFileStoreShape, path: string) =>
107
+ readJsonLines(files, path, PendingItem)
108
+
109
+ const encodeLines = Effect.fn("JudgmentDataset.encodeLines")(function* <A, I>(
110
+ path: string,
111
+ schema: Schema.Codec<A, I>,
112
+ items: ReadonlyArray<A>
113
+ ) {
114
+ const lines = yield* Effect.forEach(items, (item, index) =>
115
+ Schema.encodeEffect(Schema.fromJsonString(schema))(item).pipe(
116
+ Effect.mapError(() =>
117
+ DatasetParseError.make({
118
+ path,
119
+ line: index + 1,
120
+ message: `Invalid record for ${path}:${index + 1}`
121
+ })
122
+ )
123
+ )
124
+ )
125
+ return lines.length === 0 ? "" : `${lines.join("\n")}\n`
126
+ })
127
+
128
+ export const appendDataset = Effect.fn("JudgmentDataset.appendDataset")(function* (
129
+ files: PlainFileStoreShape,
130
+ path: string,
131
+ items: ReadonlyArray<LabelledItem>
132
+ ) {
133
+ yield* readDataset(files, path)
134
+ const encoded = yield* encodeLines(path, LabelledItem, items)
135
+ if (encoded === "") return
136
+ const previous = yield* files.read(path)
137
+ yield* files.append(path, `${previous && !previous.endsWith("\n") ? "\n" : ""}${encoded}`)
138
+ })
139
+
140
+ export const writePending = Effect.fn("JudgmentDataset.writePending")(function* (
141
+ files: PlainFileStoreShape,
142
+ path: string,
143
+ items: ReadonlyArray<PendingItem>
144
+ ) {
145
+ const encoded = yield* encodeLines(path, PendingItem, items)
146
+ yield* files.writeAtomic(path, encoded)
147
+ })
148
+
149
+ /** Escaping keeps source components unambiguous without a runtime-specific hash dependency. */
150
+ export const candidateId = (decision: DatasetDecision, source: string): string =>
151
+ `${decision}:${encodeURIComponent(source)}`
152
+
153
+ export const reviewCandidates = (
154
+ commits: ReadonlyArray<{ readonly sha: string; readonly diff: string }>,
155
+ lenses: ReadonlyArray<Reviewer>
156
+ ): ReadonlyArray<PendingItem> =>
157
+ commits.flatMap((commit) =>
158
+ lenses.map((lens) => {
159
+ const source = `commit:${commit.sha}:${lens.name}`
160
+ return PendingItem.make({
161
+ id: candidateId("review-prescreen", source),
162
+ decision: "review-prescreen",
163
+ state: { diff: commit.diff },
164
+ question: truth(lens.screeningStatement),
165
+ source
166
+ })
167
+ })
168
+ )
169
+
170
+ /** Line position distinguishes repeated observations within a run, even at the same timestamp. */
171
+ export const observationCandidates = Effect.fn("JudgmentDataset.observationCandidates")(function* (
172
+ decision: DatasetDecision,
173
+ observations: ReadonlyArray<JudgmentObservation>
174
+ ) {
175
+ return yield* Effect.forEach(
176
+ observations
177
+ .map((observation, index) => ({ observation, index }))
178
+ .filter(({ observation }) => observation.consumer === decision),
179
+ ({ observation, index }) => {
180
+ const source = `observation:${encodeURIComponent(observation.runId)}:${observation.consumer}:${encodeURIComponent(observation.key)}:${observation.at}:${index + 1}`
181
+ return Schema.decodeUnknownEffect(PendingItem)({
182
+ id: candidateId(decision, source),
183
+ decision,
184
+ state: observation.state,
185
+ question: observation.question,
186
+ source
187
+ }).pipe(
188
+ Effect.mapError(() =>
189
+ DatasetParseError.make({
190
+ path: source,
191
+ line: index + 1,
192
+ message: `Question kind does not match decision ${decision} at ${source}`
193
+ })
194
+ )
195
+ )
196
+ }
197
+ )
198
+ })
199
+
200
+ /** Existing human edits win; duplicates in the incoming batch are skipped too. */
201
+ export const mergeCandidates = (
202
+ pending: ReadonlyArray<PendingItem>,
203
+ dataset: ReadonlyArray<LabelledItem>,
204
+ candidates: ReadonlyArray<PendingItem>
205
+ ): ReadonlyArray<PendingItem> => {
206
+ const seen = new Set([...pending, ...dataset].map((item) => item.id))
207
+ return [
208
+ ...pending,
209
+ ...candidates.filter((item) => {
210
+ if (seen.has(item.id)) return false
211
+ seen.add(item.id)
212
+ return true
213
+ })
214
+ ]
215
+ }
216
+
217
+ export const isLabelledPending = (item: PendingItem): boolean => Schema.is(LabelledRecord)(item)
218
+
219
+ /**
220
+ * Validate the whole batch before any writes. An item nobody has labelled yet
221
+ * (label absent or null) simply stays pending, so labelling can proceed in
222
+ * batches; a label that is present but of the wrong kind or out of range
223
+ * refuses the whole promotion, naming the ids. Complete labels with
224
+ * incomplete attribution stay pending too.
225
+ */
226
+ export const preparePromotion = Effect.fn("JudgmentDataset.preparePromotion")(function* (
227
+ pending: ReadonlyArray<PendingItem>,
228
+ dataset: ReadonlyArray<LabelledItem>
229
+ ) {
230
+ const ids = pending
231
+ .filter(
232
+ (item) =>
233
+ item.label !== undefined && item.label !== null && !validLabel(item.question, item.label)
234
+ )
235
+ .map((item) => item.id)
236
+ if (ids.length > 0)
237
+ return yield* PromotionRefused.make({
238
+ ids,
239
+ message: `Refusing promotion: missing or invalid labels for ids: ${ids.join(", ")}`
240
+ })
241
+ const ready = pending.filter(isLabelledPending)
242
+ const labelled = yield* Effect.forEach(ready, (item) =>
243
+ Schema.decodeUnknownEffect(LabelledItem)(item)
244
+ )
245
+ const existing = new Set(dataset.map((item) => item.id))
246
+ return {
247
+ items: labelled.filter((item) => {
248
+ if (existing.has(item.id)) return false
249
+ existing.add(item.id)
250
+ return true
251
+ }),
252
+ remaining: pending.filter((item) => !isLabelledPending(item))
253
+ }
254
+ })