@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.
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
@@ -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, "&#124;").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
@@ -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 { Evaluator } from "@llm4ts/core/eval/Evaluator"
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 { FlowEvents, Info } from "./FlowEvents.ts"
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* options.judge
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: <A, E, RD, RE>(
239
- _prompt: string,
240
- schema: Schema.ConstraintCodec<A, E, RD, RE>
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
  })