@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.
- 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 +33 -1
- 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 +81 -10
- 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,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, "|").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
|