@wyattjoh/demur 0.6.0 → 0.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/README.md +53 -16
- package/extensions/demur/index.ts +99 -15
- package/extensions/demur/training-store.ts +213 -35
- package/package.json +2 -1
- package/src/adapters/pi-worker.ts +21 -7
- package/src/cli.ts +154 -5
- package/src/guard.internal.ts +83 -33
- package/src/guard.ts +42 -2
- package/src/judge.ts +53 -7
- package/src/policy.ts +10 -1
- package/src/questions.ts +9 -1
- package/src/state.ts +14 -16
- package/src/training-evaluation.ts +408 -0
- package/src/training-review-model.ts +17 -0
- package/src/training-review-tui.tsx +110 -30
- package/src/types.ts +56 -0
package/src/state.ts
CHANGED
|
@@ -1,17 +1,13 @@
|
|
|
1
1
|
import { Context, Effect, Layer, Option, Schema } from "effect";
|
|
2
2
|
import { analyze, type CommandAnalysis } from "./analyze.ts";
|
|
3
3
|
import { Environment } from "./key.ts";
|
|
4
|
-
import type {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Any JSON-compatible value.
|
|
13
|
-
*/
|
|
14
|
-
type JsonValue = string | number | boolean | null | JsonValue[] | JsonObject;
|
|
4
|
+
import type {
|
|
5
|
+
CommandState,
|
|
6
|
+
GitState,
|
|
7
|
+
Host,
|
|
8
|
+
JsonValue,
|
|
9
|
+
RenderedCommandState,
|
|
10
|
+
} from "./types.ts";
|
|
15
11
|
|
|
16
12
|
/**
|
|
17
13
|
* Milliseconds to wait for git before giving up and judging without it.
|
|
@@ -204,8 +200,8 @@ export function gatherState(
|
|
|
204
200
|
* @param state - Collected command state
|
|
205
201
|
* @returns A plain JSON object suitable for the `state` field
|
|
206
202
|
*/
|
|
207
|
-
export function renderState(state: CommandState):
|
|
208
|
-
const out:
|
|
203
|
+
export function renderState(state: CommandState): RenderedCommandState {
|
|
204
|
+
const out: { [key: string]: JsonValue } = {
|
|
209
205
|
command: state.command,
|
|
210
206
|
working_directory: state.cwd,
|
|
211
207
|
requesting_agent: state.agent,
|
|
@@ -245,11 +241,13 @@ export function renderState(state: CommandState): JsonObject {
|
|
|
245
241
|
* @param analysis - Analysis produced by {@link analyze}
|
|
246
242
|
* @returns A JSON object describing what code determined
|
|
247
243
|
*/
|
|
248
|
-
export function renderAnalysis(
|
|
249
|
-
|
|
244
|
+
export function renderAnalysis(
|
|
245
|
+
analysis: CommandAnalysis,
|
|
246
|
+
): RenderedCommandState {
|
|
247
|
+
const out: { [key: string]: JsonValue } = {
|
|
250
248
|
note: "A breakdown of what this command will do when it runs. `program` is the executable that will actually be invoked, after removing quotes and stripping wrappers such as env or sudo. `paths` are resolved for ~, $TMPDIR, and .. before being located. Every command listed here executes.",
|
|
251
249
|
commands: analysis.segments.map((segment) => {
|
|
252
|
-
const entry:
|
|
250
|
+
const entry: { [key: string]: JsonValue } = {
|
|
253
251
|
program: segment.argv0 ?? "(could not determine)",
|
|
254
252
|
arguments: segment.args,
|
|
255
253
|
};
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
TrainingCorrectionReason,
|
|
3
|
+
TrainingRecord,
|
|
4
|
+
} from "../extensions/demur/training-store.ts";
|
|
5
|
+
import type { JudgeResult } from "./judge.ts";
|
|
6
|
+
import { applyStaticGate, decide, THRESHOLDS, type Thresholds } from "./policy.ts";
|
|
7
|
+
import {
|
|
8
|
+
getLatestTrainingReview,
|
|
9
|
+
getTrainingCorrectionReason,
|
|
10
|
+
type TrainingReviewEntry,
|
|
11
|
+
} from "./training-review-model.ts";
|
|
12
|
+
import type {
|
|
13
|
+
Decision,
|
|
14
|
+
Judgments,
|
|
15
|
+
RenderedCommandState,
|
|
16
|
+
} from "./types.ts";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Decision-by-decision counts for one threshold evaluation.
|
|
20
|
+
*/
|
|
21
|
+
export type TrainingDecisionMatrix = Readonly<
|
|
22
|
+
Record<Decision, Readonly<Record<Decision, number>>>
|
|
23
|
+
>;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Aggregate quality and safety metrics for one threshold set.
|
|
27
|
+
*/
|
|
28
|
+
export type TrainingThresholdMetrics = {
|
|
29
|
+
evaluated: number;
|
|
30
|
+
matches: number;
|
|
31
|
+
corrections: number;
|
|
32
|
+
weightedLoss: number;
|
|
33
|
+
matrix: TrainingDecisionMatrix;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* One exploratory single-threshold change that improves observed weighted loss.
|
|
38
|
+
*/
|
|
39
|
+
export type TrainingThresholdCandidate = {
|
|
40
|
+
field: keyof Thresholds;
|
|
41
|
+
value: number;
|
|
42
|
+
metrics: TrainingThresholdMetrics;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Offline report derived only from locally persisted judgments and reviews.
|
|
47
|
+
*/
|
|
48
|
+
export type TrainingFeedbackReport = {
|
|
49
|
+
reviewed: number;
|
|
50
|
+
evaluable: number;
|
|
51
|
+
unavailable: number;
|
|
52
|
+
completeReplayRecords: number;
|
|
53
|
+
policyOnlyRecords: number;
|
|
54
|
+
correctionsByReason: Readonly<
|
|
55
|
+
Partial<Record<TrainingCorrectionReason, number>>
|
|
56
|
+
>;
|
|
57
|
+
current: TrainingThresholdMetrics;
|
|
58
|
+
candidates: ReadonlyArray<TrainingThresholdCandidate>;
|
|
59
|
+
warning: string | undefined;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Adapter used to run the current TypeSafe questions against captured state.
|
|
64
|
+
*/
|
|
65
|
+
export type TrainingReplayJudge = (
|
|
66
|
+
state: RenderedCommandState,
|
|
67
|
+
) => Promise<JudgeResult>;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* One reviewed record's live question-replay result.
|
|
71
|
+
*/
|
|
72
|
+
export type TrainingQuestionReplaySample = {
|
|
73
|
+
recordId: string;
|
|
74
|
+
expectedDecision: Decision;
|
|
75
|
+
originalDecision: Decision;
|
|
76
|
+
replayedDecision: Decision | undefined;
|
|
77
|
+
judgments: Judgments | undefined;
|
|
78
|
+
failure: string | undefined;
|
|
79
|
+
inputTokens: number | undefined;
|
|
80
|
+
outputTokens: number | undefined;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Aggregate comparison of the current question set with reviewed outcomes.
|
|
85
|
+
*/
|
|
86
|
+
export type TrainingQuestionReplayReport = {
|
|
87
|
+
replayable: number;
|
|
88
|
+
unavailable: number;
|
|
89
|
+
skipped: number;
|
|
90
|
+
improved: number;
|
|
91
|
+
regressed: number;
|
|
92
|
+
metrics: TrainingThresholdMetrics;
|
|
93
|
+
inputTokens: number;
|
|
94
|
+
outputTokens: number;
|
|
95
|
+
samples: ReadonlyArray<TrainingQuestionReplaySample>;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const MIN_RECOMMENDATION_SAMPLE = 20;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Evaluate reviewed training records and search safe, one-field threshold alternatives.
|
|
102
|
+
*
|
|
103
|
+
* Candidate values are advisory only. They are selected against the same records
|
|
104
|
+
* used to score them and must be validated on an independent holdout before any
|
|
105
|
+
* policy change is promoted.
|
|
106
|
+
*
|
|
107
|
+
* @param entries - Training records paired with complete append-only review history
|
|
108
|
+
* @param thresholds - Current policy thresholds used as the comparison baseline
|
|
109
|
+
* @returns Current metrics, correction clusters, and exploratory candidates
|
|
110
|
+
*/
|
|
111
|
+
export function analyzeTrainingFeedback(
|
|
112
|
+
entries: ReadonlyArray<TrainingReviewEntry>,
|
|
113
|
+
thresholds: Thresholds = THRESHOLDS,
|
|
114
|
+
): TrainingFeedbackReport {
|
|
115
|
+
const reviewed = entries.filter((entry) =>
|
|
116
|
+
getLatestTrainingReview(entry) !== undefined
|
|
117
|
+
);
|
|
118
|
+
const evaluable = reviewed.filter((entry) =>
|
|
119
|
+
entry.record.verdict.judgments !== undefined
|
|
120
|
+
);
|
|
121
|
+
const completeReplayRecords = evaluable.filter(hasCompleteReplayEvidence)
|
|
122
|
+
.length;
|
|
123
|
+
const current = evaluateThresholds(evaluable, thresholds);
|
|
124
|
+
const correctionsByReason = countCorrectionReasons(reviewed);
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
reviewed: reviewed.length,
|
|
128
|
+
evaluable: evaluable.length,
|
|
129
|
+
unavailable: reviewed.length - evaluable.length,
|
|
130
|
+
completeReplayRecords,
|
|
131
|
+
policyOnlyRecords: evaluable.length - completeReplayRecords,
|
|
132
|
+
correctionsByReason,
|
|
133
|
+
current,
|
|
134
|
+
candidates: findImprovingCandidates(evaluable, thresholds, current),
|
|
135
|
+
warning: evaluable.length < MIN_RECOMMENDATION_SAMPLE
|
|
136
|
+
? `Only ${evaluable.length} reviewed records have judgments; collect at least ${MIN_RECOMMENDATION_SAMPLE} before treating threshold candidates as meaningful.`
|
|
137
|
+
: undefined,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Score one threshold set against the latest human answer for each record.
|
|
143
|
+
*
|
|
144
|
+
* @param entries - Reviewed entries with usable raw judgments
|
|
145
|
+
* @param thresholds - Candidate policy thresholds
|
|
146
|
+
* @returns Decision matrix and asymmetrically weighted correction loss
|
|
147
|
+
*/
|
|
148
|
+
export function evaluateThresholds(
|
|
149
|
+
entries: ReadonlyArray<TrainingReviewEntry>,
|
|
150
|
+
thresholds: Thresholds,
|
|
151
|
+
): TrainingThresholdMetrics {
|
|
152
|
+
const matrix = emptyDecisionMatrix();
|
|
153
|
+
let evaluated = 0;
|
|
154
|
+
let matches = 0;
|
|
155
|
+
let weightedLoss = 0;
|
|
156
|
+
|
|
157
|
+
for (const entry of entries) {
|
|
158
|
+
const review = getLatestTrainingReview(entry);
|
|
159
|
+
const judgments = entry.record.verdict.judgments;
|
|
160
|
+
if (review === undefined || judgments === undefined) continue;
|
|
161
|
+
|
|
162
|
+
const actual = replayDecision(entry.record, thresholds);
|
|
163
|
+
const expected = review.expectedDecision;
|
|
164
|
+
matrix[expected][actual] += 1;
|
|
165
|
+
evaluated += 1;
|
|
166
|
+
if (actual === expected) matches += 1;
|
|
167
|
+
weightedLoss += decisionLoss(expected, actual);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
evaluated,
|
|
172
|
+
matches,
|
|
173
|
+
corrections: evaluated - matches,
|
|
174
|
+
weightedLoss,
|
|
175
|
+
matrix,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Re-run the current TypeSafe question set against exact captured state.
|
|
181
|
+
*
|
|
182
|
+
* Candidate commands remain JSON state and are never executed. The caller is
|
|
183
|
+
* responsible for making the cost-bearing TypeSafe request explicit to users.
|
|
184
|
+
*
|
|
185
|
+
* @param entries - Reviewed training history
|
|
186
|
+
* @param judge - Live judgment adapter for exact rendered state
|
|
187
|
+
* @param limit - Maximum cost-bearing TypeSafe requests in this replay
|
|
188
|
+
* @param thresholds - Policy thresholds applied to replayed raw judgments
|
|
189
|
+
* @returns Per-record evidence and aggregate improvement/regression metrics
|
|
190
|
+
*/
|
|
191
|
+
export async function replayTrainingQuestions(
|
|
192
|
+
entries: ReadonlyArray<TrainingReviewEntry>,
|
|
193
|
+
judge: TrainingReplayJudge,
|
|
194
|
+
limit = 20,
|
|
195
|
+
thresholds: Thresholds = THRESHOLDS,
|
|
196
|
+
): Promise<TrainingQuestionReplayReport> {
|
|
197
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
198
|
+
throw new RangeError("training replay limit must be a positive integer");
|
|
199
|
+
}
|
|
200
|
+
const samples: Array<TrainingQuestionReplaySample> = [];
|
|
201
|
+
const matrix = emptyDecisionMatrix();
|
|
202
|
+
let unavailable = 0;
|
|
203
|
+
let skipped = 0;
|
|
204
|
+
let attempted = 0;
|
|
205
|
+
let improved = 0;
|
|
206
|
+
let regressed = 0;
|
|
207
|
+
let matches = 0;
|
|
208
|
+
let weightedLoss = 0;
|
|
209
|
+
let inputTokens = 0;
|
|
210
|
+
let outputTokens = 0;
|
|
211
|
+
|
|
212
|
+
for (const entry of entries) {
|
|
213
|
+
const review = getLatestTrainingReview(entry);
|
|
214
|
+
if (review === undefined) continue;
|
|
215
|
+
if (entry.record.version !== 2 || entry.record.evidence === undefined) {
|
|
216
|
+
unavailable += 1;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (attempted >= limit) {
|
|
220
|
+
skipped += 1;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
attempted += 1;
|
|
225
|
+
const result = await judge(entry.record.evidence.modelState);
|
|
226
|
+
if (!result.ok) {
|
|
227
|
+
unavailable += 1;
|
|
228
|
+
samples.push({
|
|
229
|
+
recordId: entry.record.id,
|
|
230
|
+
expectedDecision: review.expectedDecision,
|
|
231
|
+
originalDecision: entry.record.verdict.decision,
|
|
232
|
+
replayedDecision: undefined,
|
|
233
|
+
judgments: undefined,
|
|
234
|
+
failure: `${result.failure}: ${result.detail}`,
|
|
235
|
+
inputTokens: undefined,
|
|
236
|
+
outputTokens: undefined,
|
|
237
|
+
});
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const base = decide(result.judgments, thresholds);
|
|
242
|
+
const replayedDecision = applyStaticGate(
|
|
243
|
+
base,
|
|
244
|
+
entry.record.evidence.analysis,
|
|
245
|
+
result.judgments,
|
|
246
|
+
thresholds,
|
|
247
|
+
).decision;
|
|
248
|
+
const expected = review.expectedDecision;
|
|
249
|
+
const originalMatched = entry.record.verdict.decision === expected;
|
|
250
|
+
const replayMatched = replayedDecision === expected;
|
|
251
|
+
if (!originalMatched && replayMatched) improved += 1;
|
|
252
|
+
if (originalMatched && !replayMatched) regressed += 1;
|
|
253
|
+
if (replayMatched) matches += 1;
|
|
254
|
+
weightedLoss += decisionLoss(expected, replayedDecision);
|
|
255
|
+
matrix[expected][replayedDecision] += 1;
|
|
256
|
+
inputTokens += result.usage.inputTokens;
|
|
257
|
+
outputTokens += result.usage.outputTokens;
|
|
258
|
+
samples.push({
|
|
259
|
+
recordId: entry.record.id,
|
|
260
|
+
expectedDecision: expected,
|
|
261
|
+
originalDecision: entry.record.verdict.decision,
|
|
262
|
+
replayedDecision,
|
|
263
|
+
judgments: result.judgments,
|
|
264
|
+
failure: undefined,
|
|
265
|
+
inputTokens: result.usage.inputTokens,
|
|
266
|
+
outputTokens: result.usage.outputTokens,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const evaluated = samples.filter((sample) =>
|
|
271
|
+
sample.replayedDecision !== undefined
|
|
272
|
+
).length;
|
|
273
|
+
return {
|
|
274
|
+
replayable: evaluated,
|
|
275
|
+
unavailable,
|
|
276
|
+
skipped,
|
|
277
|
+
improved,
|
|
278
|
+
regressed,
|
|
279
|
+
metrics: {
|
|
280
|
+
evaluated,
|
|
281
|
+
matches,
|
|
282
|
+
corrections: evaluated - matches,
|
|
283
|
+
weightedLoss,
|
|
284
|
+
matrix,
|
|
285
|
+
},
|
|
286
|
+
inputTokens,
|
|
287
|
+
outputTokens,
|
|
288
|
+
samples,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function replayDecision(
|
|
293
|
+
record: TrainingRecord,
|
|
294
|
+
thresholds: Thresholds,
|
|
295
|
+
): Decision {
|
|
296
|
+
const judgments = record.verdict.judgments;
|
|
297
|
+
if (judgments === undefined) return record.verdict.decision;
|
|
298
|
+
|
|
299
|
+
const outcome = decide(judgments, thresholds);
|
|
300
|
+
if (record.version === 1 || record.evidence?.analysis === undefined) {
|
|
301
|
+
return outcome.decision;
|
|
302
|
+
}
|
|
303
|
+
return applyStaticGate(
|
|
304
|
+
outcome,
|
|
305
|
+
record.evidence.analysis,
|
|
306
|
+
judgments,
|
|
307
|
+
thresholds,
|
|
308
|
+
).decision;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function hasCompleteReplayEvidence(entry: TrainingReviewEntry): boolean {
|
|
312
|
+
return entry.record.version === 2 &&
|
|
313
|
+
entry.record.evidence?.analysis !== undefined;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function countCorrectionReasons(
|
|
317
|
+
entries: ReadonlyArray<TrainingReviewEntry>,
|
|
318
|
+
): Partial<Record<TrainingCorrectionReason, number>> {
|
|
319
|
+
const counts: Partial<Record<TrainingCorrectionReason, number>> = {};
|
|
320
|
+
for (const entry of entries) {
|
|
321
|
+
const review = getLatestTrainingReview(entry);
|
|
322
|
+
if (
|
|
323
|
+
review === undefined ||
|
|
324
|
+
review.expectedDecision === entry.record.verdict.decision
|
|
325
|
+
) {
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
const reason = getTrainingCorrectionReason(review);
|
|
329
|
+
if (reason !== undefined) counts[reason] = (counts[reason] ?? 0) + 1;
|
|
330
|
+
}
|
|
331
|
+
return counts;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function findImprovingCandidates(
|
|
335
|
+
entries: ReadonlyArray<TrainingReviewEntry>,
|
|
336
|
+
thresholds: Thresholds,
|
|
337
|
+
current: TrainingThresholdMetrics,
|
|
338
|
+
): ReadonlyArray<TrainingThresholdCandidate> {
|
|
339
|
+
if (entries.length === 0) return [];
|
|
340
|
+
|
|
341
|
+
const candidates: Array<TrainingThresholdCandidate> = [];
|
|
342
|
+
for (const [field, values] of thresholdCandidateValues()) {
|
|
343
|
+
for (const value of values) {
|
|
344
|
+
if (value === thresholds[field]) continue;
|
|
345
|
+
const candidateThresholds = { ...thresholds, [field]: value };
|
|
346
|
+
const metrics = evaluateThresholds(entries, candidateThresholds);
|
|
347
|
+
if (metrics.weightedLoss >= current.weightedLoss) continue;
|
|
348
|
+
candidates.push({ field, value, metrics });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return candidates
|
|
353
|
+
.sort((left, right) =>
|
|
354
|
+
left.metrics.weightedLoss - right.metrics.weightedLoss ||
|
|
355
|
+
left.metrics.corrections - right.metrics.corrections ||
|
|
356
|
+
left.field.localeCompare(right.field) ||
|
|
357
|
+
left.value - right.value
|
|
358
|
+
)
|
|
359
|
+
.slice(0, 5);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function thresholdCandidateValues(): ReadonlyArray<
|
|
363
|
+
readonly [keyof Thresholds, ReadonlyArray<number>]
|
|
364
|
+
> {
|
|
365
|
+
const probabilities = numericRange(0.05, 0.95, 0.05);
|
|
366
|
+
return [
|
|
367
|
+
["executesDestruction", probabilities],
|
|
368
|
+
["denySensitiveDataExposure", probabilities],
|
|
369
|
+
["denySecurityBoundaryWeakening", probabilities],
|
|
370
|
+
["denyUnrecoverable", probabilities],
|
|
371
|
+
["denySharedInfrastructure", probabilities],
|
|
372
|
+
["denyBlastRadius", numericRange(0.5, 3, 0.1)],
|
|
373
|
+
["minBlastRadiusConfidence", probabilities],
|
|
374
|
+
["uncertaintyBand", numericRange(0, 0.15, 0.01)],
|
|
375
|
+
["uncertaintyBandScore", numericRange(0, 0.5, 0.05)],
|
|
376
|
+
["staticGateMinExecution", probabilities],
|
|
377
|
+
];
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function numericRange(
|
|
381
|
+
start: number,
|
|
382
|
+
end: number,
|
|
383
|
+
step: number,
|
|
384
|
+
): ReadonlyArray<number> {
|
|
385
|
+
const values: Array<number> = [];
|
|
386
|
+
for (let value = start; value <= end + step / 2; value += step) {
|
|
387
|
+
values.push(Number(value.toFixed(4)));
|
|
388
|
+
}
|
|
389
|
+
return values;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function emptyDecisionMatrix(): Record<
|
|
393
|
+
Decision,
|
|
394
|
+
Record<Decision, number>
|
|
395
|
+
> {
|
|
396
|
+
return {
|
|
397
|
+
allow: { allow: 0, ask: 0, deny: 0 },
|
|
398
|
+
ask: { allow: 0, ask: 0, deny: 0 },
|
|
399
|
+
deny: { allow: 0, ask: 0, deny: 0 },
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function decisionLoss(expected: Decision, actual: Decision): number {
|
|
404
|
+
if (expected === actual) return 0;
|
|
405
|
+
if (expected === "deny") return actual === "allow" ? 20 : 4;
|
|
406
|
+
if (expected === "ask") return actual === "allow" ? 5 : 2;
|
|
407
|
+
return actual === "deny" ? 3 : 1;
|
|
408
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
TrainingCorrectionReason,
|
|
2
3
|
TrainingRecord,
|
|
3
4
|
TrainingReview,
|
|
4
5
|
TrainingReviewInput,
|
|
@@ -114,23 +115,39 @@ export function filterTrainingReviewEntries(
|
|
|
114
115
|
.map((candidate) => candidate.entry);
|
|
115
116
|
}
|
|
116
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Return the structured reason attached to a versioned review.
|
|
120
|
+
*
|
|
121
|
+
* @param review - Append-only human review revision
|
|
122
|
+
* @returns Structured reason, or undefined for accepted and legacy reviews
|
|
123
|
+
*/
|
|
124
|
+
export function getTrainingCorrectionReason(
|
|
125
|
+
review: TrainingReview,
|
|
126
|
+
): TrainingCorrectionReason | undefined {
|
|
127
|
+
return review.version === 2 ? review.correctionReason : undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
117
130
|
/**
|
|
118
131
|
* Build an append-only review revision from a human decision.
|
|
119
132
|
*
|
|
120
133
|
* @param record - Training evidence being reviewed
|
|
121
134
|
* @param expectedDecision - Human-selected expected outcome
|
|
135
|
+
* @param correctionReason - Structured reason required for corrected decisions
|
|
122
136
|
* @param note - Optional explanation for the human decision
|
|
123
137
|
* @returns Normalized review input for persistence
|
|
124
138
|
*/
|
|
125
139
|
export function createTrainingReviewInput(
|
|
126
140
|
record: TrainingRecord,
|
|
127
141
|
expectedDecision: Decision,
|
|
142
|
+
correctionReason: TrainingCorrectionReason | undefined,
|
|
128
143
|
note: string | undefined,
|
|
129
144
|
): TrainingReviewInput {
|
|
145
|
+
const corrected = expectedDecision !== record.verdict.decision;
|
|
130
146
|
return {
|
|
131
147
|
recordId: record.id,
|
|
132
148
|
originalDecision: record.verdict.decision,
|
|
133
149
|
expectedDecision,
|
|
150
|
+
correctionReason: corrected ? correctionReason : undefined,
|
|
134
151
|
note: note?.trim() || undefined,
|
|
135
152
|
};
|
|
136
153
|
}
|