@llm4ts/flow 2.5.0 → 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.
- package/dist/Classified.d.ts +2 -0
- package/dist/Classified.d.ts.map +1 -1
- package/dist/Classified.js +4 -0
- package/dist/Classified.js.map +1 -1
- package/dist/CostReport.d.ts +110 -0
- package/dist/CostReport.d.ts.map +1 -0
- package/dist/CostReport.js +313 -0
- package/dist/CostReport.js.map +1 -0
- package/dist/EstimatedUsage.d.ts.map +1 -1
- package/dist/EstimatedUsage.js +4 -0
- package/dist/EstimatedUsage.js.map +1 -1
- package/dist/Flow.d.ts +14 -0
- package/dist/Flow.d.ts.map +1 -1
- package/dist/Flow.js +45 -2
- package/dist/Flow.js.map +1 -1
- package/dist/FlowContext.d.ts +7 -0
- package/dist/FlowContext.d.ts.map +1 -1
- package/dist/FlowContext.js.map +1 -1
- package/dist/FlowEvents.d.ts +44 -1
- package/dist/FlowEvents.d.ts.map +1 -1
- package/dist/FlowEvents.js +35 -0
- package/dist/FlowEvents.js.map +1 -1
- package/dist/GitTool.d.ts +7 -0
- package/dist/GitTool.d.ts.map +1 -1
- package/dist/GitTool.js +15 -1
- package/dist/GitTool.js.map +1 -1
- package/dist/Judgment.d.ts +88 -0
- package/dist/Judgment.d.ts.map +1 -0
- package/dist/Judgment.js +281 -0
- package/dist/Judgment.js.map +1 -0
- package/dist/JudgmentDataset.d.ts +79 -0
- package/dist/JudgmentDataset.d.ts.map +1 -0
- package/dist/JudgmentDataset.js +164 -0
- package/dist/JudgmentDataset.js.map +1 -0
- package/dist/JudgmentEval.d.ts +143 -0
- package/dist/JudgmentEval.d.ts.map +1 -0
- package/dist/JudgmentEval.js +242 -0
- package/dist/JudgmentEval.js.map +1 -0
- package/dist/JudgmentLog.d.ts +46 -0
- package/dist/JudgmentLog.d.ts.map +1 -0
- package/dist/JudgmentLog.js +62 -0
- package/dist/JudgmentLog.js.map +1 -0
- package/dist/JudgmentTypes.d.ts +13 -0
- package/dist/JudgmentTypes.d.ts.map +1 -0
- package/dist/JudgmentTypes.js +11 -0
- package/dist/JudgmentTypes.js.map +1 -0
- package/dist/ProgramJudge.d.ts +15 -2
- package/dist/ProgramJudge.d.ts.map +1 -1
- package/dist/ProgramJudge.js +58 -3
- package/dist/ProgramJudge.js.map +1 -1
- package/dist/Replay.d.ts.map +1 -1
- package/dist/Replay.js +9 -4
- package/dist/Replay.js.map +1 -1
- package/dist/Review.d.ts +35 -0
- package/dist/Review.d.ts.map +1 -1
- package/dist/Review.js +92 -13
- package/dist/Review.js.map +1 -1
- package/dist/Reviewer.d.ts +8 -0
- package/dist/Reviewer.d.ts.map +1 -1
- package/dist/Reviewer.js +12 -1
- package/dist/Reviewer.js.map +1 -1
- package/dist/TransientRetry.d.ts.map +1 -1
- package/dist/TransientRetry.js +1 -0
- package/dist/TransientRetry.js.map +1 -1
- package/package.json +8 -3
- package/src/Classified.ts +5 -0
- package/src/CostReport.ts +402 -0
- package/src/EstimatedUsage.ts +15 -0
- package/src/Flow.ts +77 -2
- package/src/FlowContext.ts +7 -0
- package/src/FlowEvents.ts +42 -0
- package/src/GitTool.ts +19 -1
- package/src/Judgment.ts +418 -0
- package/src/JudgmentDataset.ts +254 -0
- package/src/JudgmentEval.ts +306 -0
- package/src/JudgmentLog.ts +96 -0
- package/src/JudgmentTypes.ts +15 -0
- package/src/ProgramJudge.ts +89 -4
- package/src/Replay.ts +23 -17
- package/src/Review.ts +163 -13
- package/src/Reviewer.ts +15 -1
- 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
|
-
|
|
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* () {
|
package/src/Judgment.ts
ADDED
|
@@ -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
|
+
})
|