@llm4ts/flow 2.5.0 → 2.7.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 +33 -1
  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 +81 -10
  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,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
+ })
@@ -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