@wyattjoh/demur 0.5.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.
@@ -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,17 +115,31 @@ 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
122
- * @param note - Optional correction explanation
135
+ * @param correctionReason - Structured reason required for corrected decisions
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 {
130
145
  const corrected = expectedDecision !== record.verdict.decision;
@@ -132,7 +147,8 @@ export function createTrainingReviewInput(
132
147
  recordId: record.id,
133
148
  originalDecision: record.verdict.decision,
134
149
  expectedDecision,
135
- note: corrected ? note?.trim() || undefined : undefined,
150
+ correctionReason: corrected ? correctionReason : undefined,
151
+ note: note?.trim() || undefined,
136
152
  };
137
153
  }
138
154