@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
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema"
|
|
2
|
+
import { expectedScore, truth, type Answer } from "@llm4ts/core/judgment/Schemas"
|
|
3
|
+
import { decide, type Decision } from "./Judgment.ts"
|
|
4
|
+
import { candidateId, DatasetDecision, LabelledItem } from "./JudgmentDataset.ts"
|
|
5
|
+
import type { ReviewPrescreenResult, ReviewResult, Severity } from "./Review.ts"
|
|
6
|
+
import type { Reviewer } from "./Reviewer.ts"
|
|
7
|
+
|
|
8
|
+
export interface EvalItem {
|
|
9
|
+
readonly item: LabelledItem
|
|
10
|
+
readonly answer: Answer | undefined
|
|
11
|
+
readonly latencyMs: number
|
|
12
|
+
readonly failure?: string
|
|
13
|
+
readonly decision?: Decision
|
|
14
|
+
readonly severities?: ReadonlyArray<Severity>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** One completed full-path result per lens; missing screens remain failed eval items. */
|
|
18
|
+
export const replayEvalItems = (
|
|
19
|
+
commit: { readonly sha: string; readonly diff: string },
|
|
20
|
+
runs: ReadonlyArray<{ readonly lens: Reviewer; readonly result: ReviewResult }>,
|
|
21
|
+
observations: ReviewPrescreenResult["observations"],
|
|
22
|
+
screeningMs: number,
|
|
23
|
+
labelledAt: string
|
|
24
|
+
): ReadonlyArray<EvalItem> =>
|
|
25
|
+
runs.map(({ lens, result }) => {
|
|
26
|
+
const observation = observations.find(({ key }) => key === lens.name)
|
|
27
|
+
const source = `commit:${commit.sha}:${lens.name}`
|
|
28
|
+
return {
|
|
29
|
+
item: LabelledItem.make({
|
|
30
|
+
id: candidateId("review-prescreen", source),
|
|
31
|
+
decision: "review-prescreen",
|
|
32
|
+
source,
|
|
33
|
+
state: observation?.state ?? { diff: commit.diff },
|
|
34
|
+
question: observation?.question ?? truth(lens.screeningStatement),
|
|
35
|
+
label: result.issues.length > 0,
|
|
36
|
+
labelledBy: "outcome",
|
|
37
|
+
labelledAt
|
|
38
|
+
}),
|
|
39
|
+
answer: observation?.answer,
|
|
40
|
+
...(observation === undefined
|
|
41
|
+
? { failure: "Pre-screen returned no matching answer." }
|
|
42
|
+
: { decision: observation.decision }),
|
|
43
|
+
severities: result.issues.map(({ severity }) => severity),
|
|
44
|
+
latencyMs: screeningMs / runs.length
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const SeverityCounts = Schema.Struct({
|
|
49
|
+
Critical: Schema.Int,
|
|
50
|
+
Warning: Schema.Int,
|
|
51
|
+
Info: Schema.Int
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
/** Lost issues and missed positive lenses have different units; verify both independently. */
|
|
55
|
+
export const replayMissesAgree = (
|
|
56
|
+
report: EvalReport,
|
|
57
|
+
lost: typeof SeverityCounts.Type,
|
|
58
|
+
missedPositiveLenses: number
|
|
59
|
+
): boolean =>
|
|
60
|
+
report.missedIssues?.count === missedPositiveLenses &&
|
|
61
|
+
report.missedIssues.severities !== undefined &&
|
|
62
|
+
(["Critical", "Warning", "Info"] as const).every(
|
|
63
|
+
(severity) => report.missedIssues?.severities?.[severity] === lost[severity]
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
const Metric = Schema.NullOr(Schema.Number)
|
|
67
|
+
export const EvalMemory = Schema.Struct({ restMb: Schema.Number, peakMb: Schema.Number })
|
|
68
|
+
export const CalibrationBin = Schema.Struct({
|
|
69
|
+
lower: Schema.Number,
|
|
70
|
+
upper: Schema.Number,
|
|
71
|
+
count: Schema.Int,
|
|
72
|
+
meanLabelProbability: Metric
|
|
73
|
+
})
|
|
74
|
+
export const EvalMeasures = Schema.Struct({
|
|
75
|
+
items: Schema.Int,
|
|
76
|
+
answered: Schema.Int,
|
|
77
|
+
failed: Schema.Int,
|
|
78
|
+
accuracy: Metric,
|
|
79
|
+
ece: Metric,
|
|
80
|
+
brier: Metric,
|
|
81
|
+
bins: Schema.Array(CalibrationBin),
|
|
82
|
+
decisions: Schema.Struct({ act: Schema.Int, caution: Schema.Int, hold: Schema.Int }),
|
|
83
|
+
latencyMs: Schema.Struct({ p50: Metric, p95: Metric })
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
export class EvalReport extends Schema.Class<EvalReport>("EvalReport")({
|
|
87
|
+
overall: EvalMeasures,
|
|
88
|
+
decisions: Schema.Array(Schema.Struct({ decision: DatasetDecision, measures: EvalMeasures })),
|
|
89
|
+
missedIssues: Schema.optionalKey(
|
|
90
|
+
Schema.Struct({
|
|
91
|
+
count: Schema.Int,
|
|
92
|
+
positiveAnswered: Schema.Int,
|
|
93
|
+
rate: Metric,
|
|
94
|
+
severities: Schema.optionalKey(SeverityCounts)
|
|
95
|
+
})
|
|
96
|
+
),
|
|
97
|
+
memory: Schema.optionalKey(EvalMemory)
|
|
98
|
+
}) {}
|
|
99
|
+
|
|
100
|
+
const measured = ({ item, answer, failure, latencyMs, decision }: EvalItem) => {
|
|
101
|
+
if (failure !== undefined || answer === undefined) return undefined
|
|
102
|
+
if (
|
|
103
|
+
item.question.type === "truth" &&
|
|
104
|
+
answer.type === "truth" &&
|
|
105
|
+
typeof item.label === "boolean"
|
|
106
|
+
) {
|
|
107
|
+
return {
|
|
108
|
+
answer,
|
|
109
|
+
decision: decision ?? decide(answer),
|
|
110
|
+
latencyMs,
|
|
111
|
+
correct: answer.truth >= 0.5 === item.label,
|
|
112
|
+
probability: item.label ? answer.truth : 1 - answer.truth
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (item.question.type === "score" && answer.type === "score" && typeof item.label === "number") {
|
|
116
|
+
return {
|
|
117
|
+
answer,
|
|
118
|
+
decision: decision ?? decide(answer),
|
|
119
|
+
latencyMs,
|
|
120
|
+
correct: Math.round(expectedScore(answer.probabilities)) === item.label,
|
|
121
|
+
probability: answer.probabilities[String(item.label)] ?? 0
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return undefined
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Nearest rank, with no interpolation; no observations means no metric. */
|
|
128
|
+
const percentile = (sorted: ReadonlyArray<number>, fraction: number): number | null =>
|
|
129
|
+
sorted[Math.ceil(sorted.length * fraction) - 1] ?? null
|
|
130
|
+
|
|
131
|
+
const measures = (items: ReadonlyArray<EvalItem>): typeof EvalMeasures.Type => {
|
|
132
|
+
const answered = items.flatMap((item) => {
|
|
133
|
+
const value = measured(item)
|
|
134
|
+
return value === undefined ? [] : [value]
|
|
135
|
+
})
|
|
136
|
+
const count = answered.length
|
|
137
|
+
const average = (sum: number): number | null => (count === 0 ? null : sum / count)
|
|
138
|
+
const bins = Array.from({ length: 10 }, (_, index) => {
|
|
139
|
+
const members = answered.filter(
|
|
140
|
+
({ probability }) => Math.min(9, Math.floor(probability * 10)) === index
|
|
141
|
+
)
|
|
142
|
+
return {
|
|
143
|
+
lower: index / 10,
|
|
144
|
+
upper: (index + 1) / 10,
|
|
145
|
+
count: members.length,
|
|
146
|
+
meanLabelProbability:
|
|
147
|
+
members.length === 0
|
|
148
|
+
? null
|
|
149
|
+
: members.reduce((sum, member) => sum + member.probability, 0) / members.length
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
const decisions = answered.map(({ decision }) => decision)
|
|
153
|
+
const latencies = answered.map(({ latencyMs }) => latencyMs).sort((a, b) => a - b)
|
|
154
|
+
return {
|
|
155
|
+
items: items.length,
|
|
156
|
+
answered: count,
|
|
157
|
+
failed: items.length - count,
|
|
158
|
+
accuracy: average(answered.filter(({ correct }) => correct).length),
|
|
159
|
+
// The labelled outcome occurred (target 1), regardless of the predicted class.
|
|
160
|
+
ece: average(
|
|
161
|
+
bins.reduce((sum, bin) => sum + bin.count * Math.abs(1 - (bin.meanLabelProbability ?? 1)), 0)
|
|
162
|
+
),
|
|
163
|
+
brier: average(answered.reduce((sum, value) => sum + (1 - value.probability) ** 2, 0)),
|
|
164
|
+
bins,
|
|
165
|
+
decisions: {
|
|
166
|
+
act: decisions.filter((decision) => decision === "act").length,
|
|
167
|
+
caution: decisions.filter((decision) => decision === "caution").length,
|
|
168
|
+
hold: decisions.filter((decision) => decision === "hold").length
|
|
169
|
+
},
|
|
170
|
+
latencyMs: { p50: percentile(latencies, 0.5), p95: percentile(latencies, 0.95) }
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Failures and mismatched answer kinds count as failed and are excluded from all metrics. */
|
|
175
|
+
export const evaluateJudgments = (
|
|
176
|
+
items: ReadonlyArray<EvalItem>,
|
|
177
|
+
memory?: typeof EvalMemory.Type
|
|
178
|
+
): EvalReport => {
|
|
179
|
+
const decisions = [...new Set(items.map(({ item }) => item.decision))].sort()
|
|
180
|
+
const positives = items
|
|
181
|
+
.filter(({ item }) => item.decision === "review-prescreen" && item.label === true)
|
|
182
|
+
.flatMap((item) => {
|
|
183
|
+
const value = measured(item)
|
|
184
|
+
return value === undefined ? [] : [{ ...value, severities: item.severities }]
|
|
185
|
+
})
|
|
186
|
+
const missed = positives.filter(
|
|
187
|
+
({ answer, decision }) => answer.type === "truth" && answer.truth < 0.5 && decision === "act"
|
|
188
|
+
)
|
|
189
|
+
const severities = missed.flatMap((item) => item.severities ?? [])
|
|
190
|
+
return EvalReport.make({
|
|
191
|
+
overall: measures(items),
|
|
192
|
+
decisions: decisions.map((decision) => ({
|
|
193
|
+
decision,
|
|
194
|
+
measures: measures(items.filter(({ item }) => item.decision === decision))
|
|
195
|
+
})),
|
|
196
|
+
...(decisions.includes("review-prescreen")
|
|
197
|
+
? {
|
|
198
|
+
missedIssues: {
|
|
199
|
+
count: missed.length,
|
|
200
|
+
positiveAnswered: positives.length,
|
|
201
|
+
rate: positives.length === 0 ? null : missed.length / positives.length,
|
|
202
|
+
...(items.every((item) => item.severities !== undefined)
|
|
203
|
+
? {
|
|
204
|
+
severities: {
|
|
205
|
+
Critical: severities.filter((severity) => severity === "Critical").length,
|
|
206
|
+
Warning: severities.filter((severity) => severity === "Warning").length,
|
|
207
|
+
Info: severities.filter((severity) => severity === "Info").length
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
: {})
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
: {}),
|
|
214
|
+
...(memory === undefined ? {} : { memory })
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export interface EvalReportMeta {
|
|
219
|
+
readonly date: string
|
|
220
|
+
readonly decision: string
|
|
221
|
+
readonly backend: string
|
|
222
|
+
readonly model: string
|
|
223
|
+
readonly memoryNote?: string
|
|
224
|
+
readonly title?: string
|
|
225
|
+
readonly latencyNote?: string
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const cell = (value: string): string => value.replace(/\|/g, "|").replace(/[\r\n]/g, " ")
|
|
229
|
+
const number = (value: number | null): string => (value === null ? "n/a" : value.toFixed(4))
|
|
230
|
+
|
|
231
|
+
export const renderEvalReport = (report: EvalReport, meta: EvalReportMeta): string => {
|
|
232
|
+
const rows = [
|
|
233
|
+
...report.decisions.map(({ decision, measures }) => ({ name: decision, measures })),
|
|
234
|
+
{ name: "Overall", measures: report.overall }
|
|
235
|
+
]
|
|
236
|
+
return [
|
|
237
|
+
`# ${cell(meta.title ?? "Judgment evaluation")}`,
|
|
238
|
+
"",
|
|
239
|
+
`- Date: ${cell(meta.date)}`,
|
|
240
|
+
`- Decision: ${cell(meta.decision)}`,
|
|
241
|
+
`- Backend identity: ${cell(meta.backend)}`,
|
|
242
|
+
`- Model: ${cell(meta.model)}`,
|
|
243
|
+
"",
|
|
244
|
+
"## Measures",
|
|
245
|
+
"",
|
|
246
|
+
"| Decision | Items | Answered | Failed | Accuracy | ECE (10 bins) | Brier | p50 ms | p95 ms |",
|
|
247
|
+
"| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
|
|
248
|
+
...rows.map(
|
|
249
|
+
({ name, measures: m }) =>
|
|
250
|
+
`| ${name} | ${m.items} | ${m.answered} | ${m.failed} | ${number(m.accuracy)} | ${number(m.ece)} | ${number(m.brier)} | ${number(m.latencyMs.p50)} | ${number(m.latencyMs.p95)} |`
|
|
251
|
+
),
|
|
252
|
+
"",
|
|
253
|
+
"Metrics use answered items only; n/a means no observations. Overall is item-weighted.",
|
|
254
|
+
"Accuracy: Truth >= 0.5; Score rounds the expected level index.",
|
|
255
|
+
"ECE bins the labelled-outcome probability p into [0, 0.1), ..., [0.9, 1]; target = 1.",
|
|
256
|
+
"ECE = sum(bin count / answered * |1 - mean p|); Brier = mean((1 - p)^2).",
|
|
257
|
+
"These are labelled-outcome metrics, not predicted-confidence ECE or multiclass Brier.",
|
|
258
|
+
meta.latencyNote ??
|
|
259
|
+
"Latency uses nearest-rank percentiles per independent question, excluding failures.",
|
|
260
|
+
"",
|
|
261
|
+
"## Default policy decisions",
|
|
262
|
+
"",
|
|
263
|
+
"| Decision | Act | Caution | Hold |",
|
|
264
|
+
"| --- | ---: | ---: | ---: |",
|
|
265
|
+
...rows.map(
|
|
266
|
+
({ name, measures: m }) =>
|
|
267
|
+
`| ${name} | ${m.decisions.act} | ${m.decisions.caution} | ${m.decisions.hold} |`
|
|
268
|
+
),
|
|
269
|
+
...(report.missedIssues === undefined
|
|
270
|
+
? []
|
|
271
|
+
: [
|
|
272
|
+
"",
|
|
273
|
+
"## Pre-screen missed issues",
|
|
274
|
+
"",
|
|
275
|
+
"| Missed positive items | Answered positive items | Missed rate |",
|
|
276
|
+
"| ---: | ---: | ---: |",
|
|
277
|
+
`| ${report.missedIssues.count} | ${report.missedIssues.positiveAnswered} | ${number(report.missedIssues.rate)} |`,
|
|
278
|
+
"",
|
|
279
|
+
"A miss requires label=true, truth<0.5 and decide=act (the lens would be skipped).",
|
|
280
|
+
...(report.missedIssues.severities === undefined
|
|
281
|
+
? [
|
|
282
|
+
"Severity breakdown unavailable: LabelledItem records presence, not issue counts or severity."
|
|
283
|
+
]
|
|
284
|
+
: [
|
|
285
|
+
"",
|
|
286
|
+
"| Lost Critical issues | Lost Warning issues | Lost Info issues |",
|
|
287
|
+
"| ---: | ---: | ---: |",
|
|
288
|
+
`| ${report.missedIssues.severities.Critical} | ${report.missedIssues.severities.Warning} | ${report.missedIssues.severities.Info} |`,
|
|
289
|
+
"",
|
|
290
|
+
"Severity counts are issues on missed positive lenses; one lens can report multiple issues."
|
|
291
|
+
])
|
|
292
|
+
]),
|
|
293
|
+
"",
|
|
294
|
+
"## Judgment seat resident memory",
|
|
295
|
+
"",
|
|
296
|
+
"| Rest MiB | Sampled peak MiB |",
|
|
297
|
+
"| ---: | ---: |",
|
|
298
|
+
`| ${number(report.memory?.restMb ?? null)} | ${number(report.memory?.peakMb ?? null)} |`,
|
|
299
|
+
"",
|
|
300
|
+
meta.memoryNote ??
|
|
301
|
+
(report.memory === undefined
|
|
302
|
+
? "Memory not supplied."
|
|
303
|
+
: "Memory supplied by the caller; peak is the maximum sampled RSS."),
|
|
304
|
+
""
|
|
305
|
+
].join("\n")
|
|
306
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import * as Clock from "effect/Clock"
|
|
2
|
+
import * as Effect from "effect/Effect"
|
|
3
|
+
import * as Ref from "effect/Ref"
|
|
4
|
+
import * as Schema from "effect/Schema"
|
|
5
|
+
import * as Semaphore from "effect/Semaphore"
|
|
6
|
+
import type * as Scope from "effect/Scope"
|
|
7
|
+
import * as Stream from "effect/Stream"
|
|
8
|
+
import { JudgmentObserved, type FlowEvent, type FlowEventHub } from "./FlowEvents.ts"
|
|
9
|
+
import type { PlainFileStoreShape } from "./Persistence.ts"
|
|
10
|
+
|
|
11
|
+
export class JudgmentObservation extends Schema.Class<JudgmentObservation>("JudgmentObservation")({
|
|
12
|
+
runId: Schema.String,
|
|
13
|
+
/** Epoch milliseconds, matching FlowRecorder's timestamp. */
|
|
14
|
+
at: Schema.Number,
|
|
15
|
+
consumer: JudgmentObserved.fields.consumer,
|
|
16
|
+
key: JudgmentObserved.fields.key,
|
|
17
|
+
state: JudgmentObserved.fields.state,
|
|
18
|
+
question: JudgmentObserved.fields.question,
|
|
19
|
+
answer: JudgmentObserved.fields.answer,
|
|
20
|
+
outcome: JudgmentObserved.fields.outcome,
|
|
21
|
+
decision: JudgmentObserved.fields.decision,
|
|
22
|
+
mode: JudgmentObserved.fields.mode,
|
|
23
|
+
judgmentIdentity: JudgmentObserved.fields.judgmentIdentity
|
|
24
|
+
}) {}
|
|
25
|
+
|
|
26
|
+
export const judgmentLogPath = (root: string, consumer: JudgmentObservation["consumer"]): string =>
|
|
27
|
+
`${root.replace(/[\\/]+$/, "")}/.llm4ts/judgments/${consumer}.jsonl`
|
|
28
|
+
|
|
29
|
+
export interface JudgmentLogShape {
|
|
30
|
+
readonly record: (event: FlowEvent) => Effect.Effect<void>
|
|
31
|
+
readonly consume: (hub: FlowEventHub) => Effect.Effect<void, never, Scope.Scope>
|
|
32
|
+
readonly awaitDrained: (hub: FlowEventHub) => Effect.Effect<void>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const codec = Schema.fromJsonString(JudgmentObservation)
|
|
36
|
+
|
|
37
|
+
/** A best-effort subscriber, with the same permanent degradation policy as FlowRecorder. */
|
|
38
|
+
export const makeJudgmentLog = Effect.fn("@llm4ts/flow/JudgmentLog.make")(function* (options: {
|
|
39
|
+
readonly files: PlainFileStoreShape
|
|
40
|
+
readonly root: string
|
|
41
|
+
readonly runId: string
|
|
42
|
+
}): Effect.fn.Return<JudgmentLogShape> {
|
|
43
|
+
const consumed = yield* Ref.make(0)
|
|
44
|
+
const degraded = yield* Ref.make(false)
|
|
45
|
+
const lock = yield* Semaphore.make(1)
|
|
46
|
+
const record = (event: FlowEvent): Effect.Effect<void> =>
|
|
47
|
+
event._tag !== "JudgmentObserved"
|
|
48
|
+
? Effect.void
|
|
49
|
+
: lock.withPermit(
|
|
50
|
+
Effect.gen(function* () {
|
|
51
|
+
if (yield* Ref.get(degraded)) return
|
|
52
|
+
const at = yield* Clock.currentTimeMillis
|
|
53
|
+
// Classified remains sealed until an explicit, audited declassify.
|
|
54
|
+
// Text uses its toString; JSON uses its toJSON. Serialize before
|
|
55
|
+
// schema traversal so even nested sealed values use that redaction,
|
|
56
|
+
// never their private payload. Already declassified plain text has
|
|
57
|
+
// no taint metadata; callers must retain the wrapper for secrets.
|
|
58
|
+
const observation = yield* Schema.decodeUnknownEffect(codec)(
|
|
59
|
+
JSON.stringify({ ...event, runId: options.runId, at })
|
|
60
|
+
)
|
|
61
|
+
const line = yield* Schema.encodeEffect(codec)(observation)
|
|
62
|
+
yield* options.files.append(judgmentLogPath(options.root, event.consumer), `${line}\n`)
|
|
63
|
+
}).pipe(Effect.catch(() => Ref.set(degraded, true)))
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
const awaitDrained = (hub: FlowEventHub): Effect.Effect<void> =>
|
|
67
|
+
Effect.gen(function* () {
|
|
68
|
+
const target = yield* hub.publishedCount
|
|
69
|
+
const drain: Effect.Effect<void> = Effect.suspend(() =>
|
|
70
|
+
Ref.get(consumed).pipe(
|
|
71
|
+
Effect.flatMap((count) =>
|
|
72
|
+
count >= target ? Effect.void : Effect.yieldNow.pipe(Effect.andThen(drain))
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
yield* drain
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
record,
|
|
81
|
+
awaitDrained,
|
|
82
|
+
consume: (hub) =>
|
|
83
|
+
Effect.gen(function* () {
|
|
84
|
+
yield* Ref.set(consumed, yield* hub.publishedCount)
|
|
85
|
+
const subscription = yield* hub.subscribe
|
|
86
|
+
yield* Stream.fromSubscription(subscription).pipe(
|
|
87
|
+
Stream.runForEach((event) =>
|
|
88
|
+
record(event).pipe(Effect.andThen(Ref.update(consumed, (count) => count + 1)))
|
|
89
|
+
),
|
|
90
|
+
Effect.forkScoped
|
|
91
|
+
)
|
|
92
|
+
// Registered after the consumer fiber: drain before scope interrupts it.
|
|
93
|
+
yield* Effect.addFinalizer(() => awaitDrained(hub))
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
})
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The two literals shared by the judgment policy and the flow events. They
|
|
5
|
+
* live apart from `Judgment.ts` so `FlowEvents.ts` can import them without
|
|
6
|
+
* a module cycle (the policy module publishes events).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Consumers observe by default; automation requires an explicit act mode. */
|
|
10
|
+
export const JudgmentMode = Schema.Literals(["observe", "advise", "act"])
|
|
11
|
+
export type JudgmentMode = typeof JudgmentMode.Type
|
|
12
|
+
|
|
13
|
+
/** The three bands Jev documents: act, proceed with caution, or hold. */
|
|
14
|
+
export const Decision = Schema.Literals(["act", "caution", "hold"])
|
|
15
|
+
export type Decision = typeof Decision.Type
|
package/src/ProgramJudge.ts
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import * as Effect from "effect/Effect"
|
|
2
2
|
import { Sample, type Dimension, type EvalResult } from "@llm4ts/core/eval/Eval"
|
|
3
|
-
import type
|
|
3
|
+
import { makeEvaluator, type Evaluator } from "@llm4ts/core/eval/Evaluator"
|
|
4
|
+
import { judgeWithJudgment } from "@llm4ts/core/eval/Judge"
|
|
5
|
+
import type { JudgmentShape } from "@llm4ts/core/judgment/Judgment"
|
|
4
6
|
import { capped, withShrink } from "./Context.ts"
|
|
5
7
|
import { FlowLlmError, type FlowError } from "./FlowError.ts"
|
|
6
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
FlowEvents,
|
|
10
|
+
Info,
|
|
11
|
+
JudgmentObserved,
|
|
12
|
+
publishJudgmentObserved,
|
|
13
|
+
type FlowEventsShape
|
|
14
|
+
} from "./FlowEvents.ts"
|
|
15
|
+
import { certaintyOf, decide, type JudgmentMode } from "./Judgment.ts"
|
|
7
16
|
import type { GitToolShape } from "./GitTool.ts"
|
|
8
17
|
import type { Pack } from "./Pack.ts"
|
|
9
18
|
import type { PlainFileStoreShape } from "./Persistence.ts"
|
|
@@ -49,6 +58,8 @@ export const groupFiles = (
|
|
|
49
58
|
export interface ProgramJudgeOptions {
|
|
50
59
|
readonly pack: Pack
|
|
51
60
|
readonly judge: Evaluator<Sample>
|
|
61
|
+
/** Observe alongside the generative judge by default; only act replaces its scores. */
|
|
62
|
+
readonly judgment?: { readonly judgment: JudgmentShape; readonly mode?: JudgmentMode }
|
|
52
63
|
readonly dimensions: ReadonlyArray<Dimension>
|
|
53
64
|
readonly git: GitToolShape
|
|
54
65
|
readonly files: PlainFileStoreShape
|
|
@@ -63,6 +74,70 @@ export interface ProgramJudgeOptions {
|
|
|
63
74
|
readonly fingerprint: (...parts: ReadonlyArray<string>) => string
|
|
64
75
|
}
|
|
65
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Compare typed answers with generative scores without changing the result in
|
|
79
|
+
* observe/advise. Failed questions have no answer to observe; backend failure
|
|
80
|
+
* keeps the generative result. Act uses core's judgment evaluator unchanged.
|
|
81
|
+
*/
|
|
82
|
+
export const withJudgment = (
|
|
83
|
+
generative: Evaluator<Sample>,
|
|
84
|
+
dimensions: ReadonlyArray<Dimension>,
|
|
85
|
+
options: NonNullable<ProgramJudgeOptions["judgment"]>,
|
|
86
|
+
events: FlowEventsShape
|
|
87
|
+
): Evaluator<Sample> => {
|
|
88
|
+
const mode = options.mode ?? "observe"
|
|
89
|
+
if (mode === "act") return judgeWithJudgment(options.judgment, dimensions)
|
|
90
|
+
return makeEvaluator(
|
|
91
|
+
Effect.fn("@llm4ts/flow/ProgramJudge.withJudgment")(function* (sample: Sample) {
|
|
92
|
+
const full = yield* generative.evaluate(sample)
|
|
93
|
+
const observing: JudgmentShape = {
|
|
94
|
+
...options.judgment,
|
|
95
|
+
judge: (request) =>
|
|
96
|
+
options.judgment.judge(request).pipe(
|
|
97
|
+
Effect.tap(
|
|
98
|
+
Effect.fnUntraced(function* (result) {
|
|
99
|
+
for (const dimension of dimensions) {
|
|
100
|
+
const answer = result.answers[dimension.name]
|
|
101
|
+
const score = full.scores.find((entry) => entry.name === dimension.name)
|
|
102
|
+
if (answer?.type !== "score" || score === undefined) continue
|
|
103
|
+
yield* publishJudgmentObserved(
|
|
104
|
+
events,
|
|
105
|
+
JudgmentObserved.make({
|
|
106
|
+
consumer: "program-judge",
|
|
107
|
+
state: request.state,
|
|
108
|
+
question: request.questions[dimension.name],
|
|
109
|
+
answer,
|
|
110
|
+
judgmentIdentity: options.judgment.identity,
|
|
111
|
+
key: dimension.name,
|
|
112
|
+
decision: decide(answer),
|
|
113
|
+
certainty: certaintyOf(answer),
|
|
114
|
+
support: answer.support,
|
|
115
|
+
origin: answer.origin,
|
|
116
|
+
outcome: { _tag: "ProgramJudge", score: score.score },
|
|
117
|
+
mode
|
|
118
|
+
})
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
yield* judgeWithJudgment(observing, dimensions)
|
|
126
|
+
.evaluate(sample)
|
|
127
|
+
.pipe(
|
|
128
|
+
Effect.catch(() =>
|
|
129
|
+
events.publish(
|
|
130
|
+
Info.make({
|
|
131
|
+
message: "program judgment unavailable; keeping the generative result"
|
|
132
|
+
})
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
return full
|
|
137
|
+
})
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
66
141
|
const join = (root: string, path: string): string =>
|
|
67
142
|
`${root.replace(/[\\/]+$/, "")}/${path.replace(/^[\\/]+/, "")}`
|
|
68
143
|
|
|
@@ -128,17 +203,27 @@ const judgeSlice = Effect.fn("@llm4ts/flow/ProgramJudge.judgeSlice")(function* (
|
|
|
128
203
|
): Effect.fn.Return<ReviewResult, FlowError, FlowEvents> {
|
|
129
204
|
const events = yield* FlowEvents
|
|
130
205
|
const rubric = rubricText(options.dimensions)
|
|
206
|
+
const judge =
|
|
207
|
+
options.judgment === undefined
|
|
208
|
+
? options.judge
|
|
209
|
+
: withJudgment(options.judge, options.dimensions, options.judgment, events)
|
|
210
|
+
// A mode switch (especially act back to observe) must not reuse the other
|
|
211
|
+
// path's verdict. Checkpoint changes must run the comparison again too.
|
|
212
|
+
const judgmentKey =
|
|
213
|
+
options.judgment === undefined
|
|
214
|
+
? []
|
|
215
|
+
: [options.judgment.mode ?? "observe", options.judgment.judgment.identity]
|
|
131
216
|
return yield* cachedReview(
|
|
132
217
|
options.files,
|
|
133
218
|
join(options.gateDir, `${label}.json`),
|
|
134
|
-
options.fingerprint(spec, diff, rubric),
|
|
219
|
+
options.fingerprint(spec, diff, rubric, ...judgmentKey),
|
|
135
220
|
events.publish(Info.make({ message: `judging ${label}` })).pipe(
|
|
136
221
|
Effect.andThen(
|
|
137
222
|
withShrink(`judge[${label}]`, (cap) =>
|
|
138
223
|
Effect.gen(function* () {
|
|
139
224
|
const cappedSpec = yield* capped(`spec[${label}]`, spec, cap)
|
|
140
225
|
const cappedDiff = yield* capped(`diff[${label}]`, diff, cap)
|
|
141
|
-
return yield*
|
|
226
|
+
return yield* judge
|
|
142
227
|
.evaluate(
|
|
143
228
|
Sample.make({ response: cappedDiff, context: cappedSpec, query: options.query })
|
|
144
229
|
)
|
package/src/Replay.ts
CHANGED
|
@@ -3,6 +3,7 @@ import * as Ref from "effect/Ref"
|
|
|
3
3
|
import * as Schema from "effect/Schema"
|
|
4
4
|
import * as Stream from "effect/Stream"
|
|
5
5
|
import { InvalidRequestError, ParseError, ProviderError } from "@llm4ts/core/Errors"
|
|
6
|
+
import { verbalizedScoreLabels } from "@llm4ts/core/LabelScoring"
|
|
6
7
|
import type { LlmServiceShape } from "@llm4ts/core/LlmService"
|
|
7
8
|
import { LlmChunk, TokenUsage } from "@llm4ts/core/Models"
|
|
8
9
|
import { collect } from "@llm4ts/core/Streaming"
|
|
@@ -211,6 +212,24 @@ export const makeReplayConnector = Effect.fn("@llm4ts/flow/Replay.makeConnector"
|
|
|
211
212
|
)
|
|
212
213
|
)
|
|
213
214
|
|
|
215
|
+
const executeStructuredWithUsage = <A, E, RD, RE>(
|
|
216
|
+
_prompt: string,
|
|
217
|
+
schema: Schema.ConstraintCodec<A, E, RD, RE>
|
|
218
|
+
) =>
|
|
219
|
+
collect(next).pipe(
|
|
220
|
+
Effect.flatMap((response) =>
|
|
221
|
+
Schema.decodeUnknownEffect(Schema.fromJsonString(schema))(response.content).pipe(
|
|
222
|
+
Effect.map((value) => [value, response.usage, response.metadata.model] as const)
|
|
223
|
+
)
|
|
224
|
+
),
|
|
225
|
+
Effect.mapError((error) =>
|
|
226
|
+
ParseError.make({
|
|
227
|
+
message: `replay structured parse error: ${String(error)}`,
|
|
228
|
+
raw: ""
|
|
229
|
+
})
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
|
|
214
233
|
return {
|
|
215
234
|
executeStream: (_prompt) => next,
|
|
216
235
|
executeStreamWithHistory: (_messages) => next,
|
|
@@ -235,23 +254,10 @@ export const makeReplayConnector = Effect.fn("@llm4ts/flow/Replay.makeConnector"
|
|
|
235
254
|
})
|
|
236
255
|
)
|
|
237
256
|
),
|
|
238
|
-
executeStructuredWithUsage
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
)
|
|
242
|
-
collect(next).pipe(
|
|
243
|
-
Effect.flatMap((response) =>
|
|
244
|
-
Schema.decodeUnknownEffect(Schema.fromJsonString(schema))(response.content).pipe(
|
|
245
|
-
Effect.map((value) => [value, response.usage, response.metadata.model] as const)
|
|
246
|
-
)
|
|
247
|
-
),
|
|
248
|
-
Effect.mapError((error) =>
|
|
249
|
-
ParseError.make({
|
|
250
|
-
message: `replay structured parse error: ${String(error)}`,
|
|
251
|
-
raw: ""
|
|
252
|
-
})
|
|
253
|
-
)
|
|
254
|
-
),
|
|
257
|
+
executeStructuredWithUsage,
|
|
258
|
+
// A replayed label question decodes the recorded JSON reply like any
|
|
259
|
+
// other structured call: the trace holds the verbalized answer.
|
|
260
|
+
scoreLabels: verbalizedScoreLabels(executeStructuredWithUsage),
|
|
255
261
|
isAvailable: Effect.succeed(true)
|
|
256
262
|
}
|
|
257
263
|
})
|