@aldus-runtime/regression 0.1.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/LICENSE +201 -0
- package/NOTICE +21 -0
- package/dist/blindspots.d.ts +107 -0
- package/dist/blindspots.d.ts.map +1 -0
- package/dist/blindspots.js +169 -0
- package/dist/blindspots.js.map +1 -0
- package/dist/corpus.d.ts +184 -0
- package/dist/corpus.d.ts.map +1 -0
- package/dist/corpus.js +254 -0
- package/dist/corpus.js.map +1 -0
- package/dist/errors.d.ts +44 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +40 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -0
- package/dist/metrics.d.ts +134 -0
- package/dist/metrics.d.ts.map +1 -0
- package/dist/metrics.js +125 -0
- package/dist/metrics.js.map +1 -0
- package/dist/policy.d.ts +140 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +148 -0
- package/dist/policy.js.map +1 -0
- package/dist/promotion.d.ts +108 -0
- package/dist/promotion.d.ts.map +1 -0
- package/dist/promotion.js +130 -0
- package/dist/promotion.js.map +1 -0
- package/dist/report.d.ts +42 -0
- package/dist/report.d.ts.map +1 -0
- package/dist/report.js +116 -0
- package/dist/report.js.map +1 -0
- package/dist/scope.d.ts +57 -0
- package/dist/scope.d.ts.map +1 -0
- package/dist/scope.js +81 -0
- package/dist/scope.js.map +1 -0
- package/package.json +49 -0
- package/src/blindspots.ts +199 -0
- package/src/corpus.ts +311 -0
- package/src/errors.ts +49 -0
- package/src/index.ts +104 -0
- package/src/metrics.ts +293 -0
- package/src/policy.ts +267 -0
- package/src/promotion.ts +336 -0
- package/src/report.ts +154 -0
- package/src/scope.ts +100 -0
package/src/promotion.ts
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evaluator promotion (architecture contract §12.1, §25 item 9; ADR-0010).
|
|
3
|
+
*
|
|
4
|
+
* §12.1: "An evaluator MAY become blocking only after it is calibrated against human-labeled
|
|
5
|
+
* examples." This module turns that permission into an evidence check.
|
|
6
|
+
*
|
|
7
|
+
* Two decisions shape everything here, both recorded in ADR-0010:
|
|
8
|
+
*
|
|
9
|
+
* 1. **Promotion is always scoped.** There is no such thing as promoting an evaluator outright.
|
|
10
|
+
* A verdict names the slices where the bar is met, and §12.1's own list of scope dimensions
|
|
11
|
+
* is why: calibration on one host, voice, or script form is not evidence about another.
|
|
12
|
+
* 2. **The whole-corpus figure never decides anything.** It is computed and reported because a
|
|
13
|
+
* reader wants the corpus's shape, but no threshold is applied to it. An evaluator that looks
|
|
14
|
+
* excellent in aggregate while failing one slice must not read as promotable, and the surest
|
|
15
|
+
* way to guarantee that is to give the aggregate no vote.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { BlindSpot, BlindSpotRegistry } from "./blindspots.js";
|
|
19
|
+
import type { ComparisonReport, SliceMetrics } from "./metrics.js";
|
|
20
|
+
import type { PolicyOrigin, PromotionPolicy } from "./policy.js";
|
|
21
|
+
import type { ScopeSelector } from "./scope.js";
|
|
22
|
+
|
|
23
|
+
/** Why a slice did not clear the bar. */
|
|
24
|
+
export interface PromotionShortfall {
|
|
25
|
+
/** Machine-readable reason. */
|
|
26
|
+
code: PromotionShortfallCode;
|
|
27
|
+
/** What the slice needed. */
|
|
28
|
+
required: number | string;
|
|
29
|
+
/** What it had. `undefined` when the metric was unmeasurable. */
|
|
30
|
+
observed: number | string | undefined;
|
|
31
|
+
/** Operator-facing explanation. */
|
|
32
|
+
message: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Reasons a slice can fail (contract §12.1). */
|
|
36
|
+
export type PromotionShortfallCode =
|
|
37
|
+
| "insufficient_cases"
|
|
38
|
+
| "insufficient_defective_cases"
|
|
39
|
+
| "insufficient_clean_cases"
|
|
40
|
+
| "insufficient_labellers"
|
|
41
|
+
| "recall_below_threshold"
|
|
42
|
+
| "severity_weighted_recall_below_threshold"
|
|
43
|
+
| "false_positive_rate_above_threshold"
|
|
44
|
+
| "unnecessary_correction_harm_above_threshold"
|
|
45
|
+
| "open_blind_spot"
|
|
46
|
+
| "unmeasurable";
|
|
47
|
+
|
|
48
|
+
/** Whether one slice clears the bar, and why not when it does not. */
|
|
49
|
+
export interface SliceVerdict {
|
|
50
|
+
/** Which slice. */
|
|
51
|
+
selector: ScopeSelector;
|
|
52
|
+
/** Stable key for the slice. */
|
|
53
|
+
key: string;
|
|
54
|
+
/** Metrics the verdict was computed from. */
|
|
55
|
+
metrics: SliceMetrics;
|
|
56
|
+
/**
|
|
57
|
+
* Whether this slice clears the §12.1 bar.
|
|
58
|
+
*
|
|
59
|
+
* Named for what it is: evidence sufficient to *permit* promotion, not proof the evaluator is
|
|
60
|
+
* right. §12 forbids presenting a machine pass as semantic correctness.
|
|
61
|
+
*/
|
|
62
|
+
meetsPromotionBar: boolean;
|
|
63
|
+
/** Every reason it does not, empty when it does. */
|
|
64
|
+
shortfalls: readonly PromotionShortfall[];
|
|
65
|
+
/** Open blind spots applying to this slice (contract §12.1, §9.3). */
|
|
66
|
+
openBlindSpots: readonly BlindSpot[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The full promotion verdict. */
|
|
70
|
+
export interface PromotionVerdict {
|
|
71
|
+
/** Evaluator assessed. */
|
|
72
|
+
evaluatorId: string;
|
|
73
|
+
/** Version assessed. */
|
|
74
|
+
evaluatorVersion: string;
|
|
75
|
+
/** Corpus assessed against. */
|
|
76
|
+
corpusId: string;
|
|
77
|
+
/**
|
|
78
|
+
* Where the policy's numbers came from.
|
|
79
|
+
*
|
|
80
|
+
* `default-uncalibrated` means the bar itself has never been validated against a real corpus
|
|
81
|
+
* (ADR-0010). A verdict is only as trustworthy as the thresholds it was measured against, and
|
|
82
|
+
* hiding that would be its own dishonesty.
|
|
83
|
+
*/
|
|
84
|
+
policyOrigin: PolicyOrigin;
|
|
85
|
+
/** Per-slice verdicts (contract §12.1). */
|
|
86
|
+
slices: readonly SliceVerdict[];
|
|
87
|
+
/** Slices that clear the bar. May be empty. */
|
|
88
|
+
promotableScopes: readonly string[];
|
|
89
|
+
/** Slices that do not. */
|
|
90
|
+
blockedScopes: readonly string[];
|
|
91
|
+
/**
|
|
92
|
+
* Whole-corpus metrics, for context only.
|
|
93
|
+
*
|
|
94
|
+
* Deliberately not accompanied by a whole-corpus verdict: no threshold is applied to it
|
|
95
|
+
* (ADR-0010 decision 3).
|
|
96
|
+
*/
|
|
97
|
+
wholeCorpus: SliceMetrics;
|
|
98
|
+
/**
|
|
99
|
+
* True when the aggregate would flatter the evaluator relative to its worst slice.
|
|
100
|
+
*
|
|
101
|
+
* Surfaced so a report can say so out loud rather than leaving a reader to compare the numbers
|
|
102
|
+
* themselves — this is the specific failure §12.1's scope requirement exists to prevent.
|
|
103
|
+
*/
|
|
104
|
+
aggregateFlattersWorstScope: boolean;
|
|
105
|
+
/** Cases the run never reported on. */
|
|
106
|
+
unevaluatedCaseIds: readonly string[];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function shortfall(
|
|
110
|
+
code: PromotionShortfallCode,
|
|
111
|
+
required: number | string,
|
|
112
|
+
observed: number | string | undefined,
|
|
113
|
+
message: string,
|
|
114
|
+
): PromotionShortfall {
|
|
115
|
+
return { code, required, observed, message };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Assess one slice against the policy. */
|
|
119
|
+
function assessSlice(
|
|
120
|
+
metrics: SliceMetrics,
|
|
121
|
+
policy: PromotionPolicy,
|
|
122
|
+
openBlindSpots: readonly BlindSpot[],
|
|
123
|
+
): SliceVerdict {
|
|
124
|
+
const { thresholds } = policy;
|
|
125
|
+
const shortfalls: PromotionShortfall[] = [];
|
|
126
|
+
|
|
127
|
+
if (metrics.cases < thresholds.minCases) {
|
|
128
|
+
shortfalls.push(
|
|
129
|
+
shortfall(
|
|
130
|
+
"insufficient_cases",
|
|
131
|
+
thresholds.minCases,
|
|
132
|
+
metrics.cases,
|
|
133
|
+
`Only ${metrics.cases} labelled cases; the bar is ${thresholds.minCases}.`,
|
|
134
|
+
),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
if (metrics.defectiveCases < thresholds.minDefectiveCases) {
|
|
138
|
+
shortfalls.push(
|
|
139
|
+
shortfall(
|
|
140
|
+
"insufficient_defective_cases",
|
|
141
|
+
thresholds.minDefectiveCases,
|
|
142
|
+
metrics.defectiveCases,
|
|
143
|
+
`Only ${metrics.defectiveCases} cases a human labelled defective; recall over so few is ` +
|
|
144
|
+
"not a measurement.",
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
if (metrics.cleanCases < thresholds.minCleanCases) {
|
|
149
|
+
shortfalls.push(
|
|
150
|
+
shortfall(
|
|
151
|
+
"insufficient_clean_cases",
|
|
152
|
+
thresholds.minCleanCases,
|
|
153
|
+
metrics.cleanCases,
|
|
154
|
+
`Only ${metrics.cleanCases} cases a human labelled clean, so the false-positive rate is ` +
|
|
155
|
+
"barely constrained.",
|
|
156
|
+
),
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
if (metrics.labellers < thresholds.minLabellers) {
|
|
160
|
+
shortfalls.push(
|
|
161
|
+
shortfall(
|
|
162
|
+
"insufficient_labellers",
|
|
163
|
+
thresholds.minLabellers,
|
|
164
|
+
metrics.labellers,
|
|
165
|
+
`Labels come from ${metrics.labellers} person(s); the bar is ${thresholds.minLabellers}.`,
|
|
166
|
+
),
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// An unmeasurable metric is a shortfall, never a pass. A slice with no defective cases has an
|
|
171
|
+
// undefined recall, and treating undefined as satisfied would promote an evaluator that was
|
|
172
|
+
// never tested on a single defect.
|
|
173
|
+
if (metrics.recall === undefined) {
|
|
174
|
+
shortfalls.push(
|
|
175
|
+
shortfall(
|
|
176
|
+
"unmeasurable",
|
|
177
|
+
thresholds.minRecall,
|
|
178
|
+
undefined,
|
|
179
|
+
"Recall is unmeasurable here: no case in this slice was labelled defective.",
|
|
180
|
+
),
|
|
181
|
+
);
|
|
182
|
+
} else if (metrics.recall < thresholds.minRecall) {
|
|
183
|
+
shortfalls.push(
|
|
184
|
+
shortfall(
|
|
185
|
+
"recall_below_threshold",
|
|
186
|
+
thresholds.minRecall,
|
|
187
|
+
metrics.recall,
|
|
188
|
+
`Recall ${metrics.recall.toFixed(3)} is below the required ${thresholds.minRecall}; the ` +
|
|
189
|
+
`evaluator missed ${metrics.falseNegatives} defect(s) a human found.`,
|
|
190
|
+
),
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (metrics.severityWeightedRecall === undefined) {
|
|
195
|
+
if (metrics.recall !== undefined) {
|
|
196
|
+
shortfalls.push(
|
|
197
|
+
shortfall(
|
|
198
|
+
"unmeasurable",
|
|
199
|
+
thresholds.minSeverityWeightedRecall,
|
|
200
|
+
undefined,
|
|
201
|
+
"Severity-weighted recall is unmeasurable: the defective cases carry no severity weight.",
|
|
202
|
+
),
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
} else if (metrics.severityWeightedRecall < thresholds.minSeverityWeightedRecall) {
|
|
206
|
+
shortfalls.push(
|
|
207
|
+
shortfall(
|
|
208
|
+
"severity_weighted_recall_below_threshold",
|
|
209
|
+
thresholds.minSeverityWeightedRecall,
|
|
210
|
+
metrics.severityWeightedRecall,
|
|
211
|
+
`Severity-weighted recall ${metrics.severityWeightedRecall.toFixed(3)} is below the ` +
|
|
212
|
+
`required ${thresholds.minSeverityWeightedRecall}. Weighted misses total ` +
|
|
213
|
+
`${metrics.severityWeightedFalseNegatives}, so what it missed was disproportionately ` +
|
|
214
|
+
"severe (architecture contract §12.1).",
|
|
215
|
+
),
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (metrics.falsePositiveRate === undefined) {
|
|
220
|
+
shortfalls.push(
|
|
221
|
+
shortfall(
|
|
222
|
+
"unmeasurable",
|
|
223
|
+
thresholds.maxFalsePositiveRate,
|
|
224
|
+
undefined,
|
|
225
|
+
"False-positive rate is unmeasurable here: no case in this slice was labelled clean.",
|
|
226
|
+
),
|
|
227
|
+
);
|
|
228
|
+
} else if (metrics.falsePositiveRate > thresholds.maxFalsePositiveRate) {
|
|
229
|
+
shortfalls.push(
|
|
230
|
+
shortfall(
|
|
231
|
+
"false_positive_rate_above_threshold",
|
|
232
|
+
thresholds.maxFalsePositiveRate,
|
|
233
|
+
metrics.falsePositiveRate,
|
|
234
|
+
`False-positive rate ${metrics.falsePositiveRate.toFixed(3)} exceeds the permitted ` +
|
|
235
|
+
`${thresholds.maxFalsePositiveRate}.`,
|
|
236
|
+
),
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (
|
|
241
|
+
metrics.meanUnnecessaryCorrectionHarm !== undefined &&
|
|
242
|
+
metrics.meanUnnecessaryCorrectionHarm > thresholds.maxUnnecessaryCorrectionHarm
|
|
243
|
+
) {
|
|
244
|
+
shortfalls.push(
|
|
245
|
+
shortfall(
|
|
246
|
+
"unnecessary_correction_harm_above_threshold",
|
|
247
|
+
thresholds.maxUnnecessaryCorrectionHarm,
|
|
248
|
+
metrics.meanUnnecessaryCorrectionHarm,
|
|
249
|
+
`Mean unnecessary-correction harm ${metrics.meanUnnecessaryCorrectionHarm.toFixed(3)} ` +
|
|
250
|
+
`exceeds the permitted ${thresholds.maxUnnecessaryCorrectionHarm}. Its false positives ` +
|
|
251
|
+
"trigger expensive repairs, which §12.1 weighs separately from how often they occur.",
|
|
252
|
+
),
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (policy.openBlindSpotDisqualifies && openBlindSpots.length > 0) {
|
|
257
|
+
shortfalls.push(
|
|
258
|
+
shortfall(
|
|
259
|
+
"open_blind_spot",
|
|
260
|
+
0,
|
|
261
|
+
openBlindSpots.length,
|
|
262
|
+
`${openBlindSpots.length} open blind spot(s) apply here: ` +
|
|
263
|
+
`${openBlindSpots.map((record) => record.blindSpotId).join(", ")}. Corpus metrics are ` +
|
|
264
|
+
"not evidence against a blind spot — a blind spot is what the corpus did not sample " +
|
|
265
|
+
"(architecture contract §12.1, §9.3).",
|
|
266
|
+
),
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
selector: metrics.selector,
|
|
272
|
+
key: metrics.key,
|
|
273
|
+
metrics,
|
|
274
|
+
meetsPromotionBar: shortfalls.length === 0,
|
|
275
|
+
shortfalls,
|
|
276
|
+
openBlindSpots,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Decide whether an evaluator may be promoted to blocking, per scope (contract §12.1).
|
|
282
|
+
*
|
|
283
|
+
* Returns a verdict rather than throwing. A blocked promotion is information an operator needs
|
|
284
|
+
* rendered — several slices may fail for different reasons at once, and an exception would carry
|
|
285
|
+
* one and discard the rest. This follows ADR-0006 decision 5's reasoning for pack resolution.
|
|
286
|
+
*/
|
|
287
|
+
export function assessPromotion(
|
|
288
|
+
comparison: ComparisonReport,
|
|
289
|
+
policy: PromotionPolicy,
|
|
290
|
+
blindSpots?: BlindSpotRegistry,
|
|
291
|
+
): PromotionVerdict {
|
|
292
|
+
const slices = comparison.slices.map((metrics) =>
|
|
293
|
+
assessSlice(
|
|
294
|
+
metrics,
|
|
295
|
+
policy,
|
|
296
|
+
blindSpots?.openFor(comparison.evaluatorId, metrics.selector) ?? [],
|
|
297
|
+
),
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
const promotable = slices.filter((slice) => slice.meetsPromotionBar);
|
|
301
|
+
const blocked = slices.filter((slice) => !slice.meetsPromotionBar);
|
|
302
|
+
|
|
303
|
+
// Does the aggregate look better than the worst slice? Compared on agreement because that is
|
|
304
|
+
// the figure a reader is most likely to skim and mistake for a verdict.
|
|
305
|
+
const worstAgreement = comparison.slices.reduce<number | undefined>((worst, slice) => {
|
|
306
|
+
const value = slice.agreementWithHumanLabels;
|
|
307
|
+
if (value === undefined) return worst;
|
|
308
|
+
return worst === undefined || value < worst ? value : worst;
|
|
309
|
+
}, undefined);
|
|
310
|
+
const aggregate = comparison.wholeCorpus.agreementWithHumanLabels;
|
|
311
|
+
const aggregateFlattersWorstScope =
|
|
312
|
+
aggregate !== undefined && worstAgreement !== undefined && aggregate > worstAgreement;
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
evaluatorId: comparison.evaluatorId,
|
|
316
|
+
evaluatorVersion: comparison.evaluatorVersion,
|
|
317
|
+
corpusId: comparison.corpusId,
|
|
318
|
+
policyOrigin: policy.origin,
|
|
319
|
+
slices,
|
|
320
|
+
promotableScopes: promotable.map((slice) => slice.key),
|
|
321
|
+
blockedScopes: blocked.map((slice) => slice.key),
|
|
322
|
+
wholeCorpus: comparison.wholeCorpus,
|
|
323
|
+
aggregateFlattersWorstScope,
|
|
324
|
+
unevaluatedCaseIds: comparison.unevaluatedCaseIds,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* True when every measured slice clears the bar **and** at least one slice was measured.
|
|
330
|
+
*
|
|
331
|
+
* The second half is not pedantry: an empty slice list would otherwise satisfy `every` and read
|
|
332
|
+
* as universal approval of an evaluator nothing was measured about.
|
|
333
|
+
*/
|
|
334
|
+
export function isPromotableEverywhereMeasured(verdict: PromotionVerdict): boolean {
|
|
335
|
+
return verdict.slices.length > 0 && verdict.blockedScopes.length === 0;
|
|
336
|
+
}
|
package/src/report.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The promotion report (architecture contract §12, §12.1).
|
|
3
|
+
*
|
|
4
|
+
* §12 states: "Machine pass MUST NOT be presented as semantic correctness." That is a constraint
|
|
5
|
+
* on *this file* more than any other, because this is where numbers become prose a human acts
|
|
6
|
+
* on. Three rules follow, and each is pinned by a test:
|
|
7
|
+
*
|
|
8
|
+
* 1. **The aggregate is never rendered alone.** Every rendering that shows the whole-corpus
|
|
9
|
+
* figure also shows the per-scope breakdown, and labels the aggregate as descriptive. §12.1
|
|
10
|
+
* requires scope be considered; an aggregate-only report is how that requirement gets
|
|
11
|
+
* quietly dropped.
|
|
12
|
+
* 2. **No word implying correctness.** The report says "agreed with human labels", never
|
|
13
|
+
* "accurate", "correct", or "passed". A reader skimming for a verdict must not find one that
|
|
14
|
+
* was never established.
|
|
15
|
+
* 3. **Blocked means explained.** A slice that fails lists every shortfall with the observed and
|
|
16
|
+
* required values. "0.94" tells nobody whether to promote.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { PromotionVerdict, SliceVerdict } from "./promotion.js";
|
|
20
|
+
import type { SliceMetrics } from "./metrics.js";
|
|
21
|
+
import { scopeLabel } from "./scope.js";
|
|
22
|
+
|
|
23
|
+
/** Words this report must never use about an evaluator's output (contract §12). */
|
|
24
|
+
const FORBIDDEN_CLAIM_WORDS: readonly string[] = [
|
|
25
|
+
"accurate",
|
|
26
|
+
"accuracy",
|
|
27
|
+
"correctness",
|
|
28
|
+
"correct",
|
|
29
|
+
"proven",
|
|
30
|
+
"verified",
|
|
31
|
+
"guarantees",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The standing caveat, printed on every report.
|
|
36
|
+
*
|
|
37
|
+
* Not decoration. §12 forbids presenting a machine pass as semantic correctness, and §12.1's
|
|
38
|
+
* scope requirement exists because corpora do not generalise. A reader who takes only the
|
|
39
|
+
* headline away should still have been told both.
|
|
40
|
+
*/
|
|
41
|
+
export const REPORT_CAVEAT =
|
|
42
|
+
"This report measures agreement with human labels on one corpus. It is not evidence that the " +
|
|
43
|
+
"evaluator is semantically right (architecture contract §12), and it says nothing about " +
|
|
44
|
+
"scopes the corpus does not contain — those were not evaluated and must not be assumed covered.";
|
|
45
|
+
|
|
46
|
+
function formatRate(value: number | undefined): string {
|
|
47
|
+
return value === undefined ? "unmeasurable" : value.toFixed(3);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function renderMetrics(metrics: SliceMetrics, indent: string): string[] {
|
|
51
|
+
return [
|
|
52
|
+
`${indent}cases ${metrics.cases} (${metrics.defectiveCases} labelled defective, ${metrics.cleanCases} labelled clean, ${metrics.labellers} labeller(s))`,
|
|
53
|
+
`${indent}recall ${formatRate(metrics.recall)} severity-weighted recall ${formatRate(metrics.severityWeightedRecall)}`,
|
|
54
|
+
`${indent}false-positive rate ${formatRate(metrics.falsePositiveRate)} mean unnecessary-correction harm ${formatRate(metrics.meanUnnecessaryCorrectionHarm)}`,
|
|
55
|
+
`${indent}missed ${metrics.falseNegatives} (weighted ${metrics.severityWeightedFalseNegatives}), spurious flags ${metrics.falsePositives}, wrong-category detections ${metrics.categoryMismatches}`,
|
|
56
|
+
`${indent}agreement with human labels ${formatRate(metrics.agreementWithHumanLabels)}`,
|
|
57
|
+
];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function renderSlice(slice: SliceVerdict): string[] {
|
|
61
|
+
const lines: string[] = [];
|
|
62
|
+
const status = slice.meetsPromotionBar ? "MEETS PROMOTION BAR" : "BLOCKED";
|
|
63
|
+
lines.push(` [${status}] ${scopeLabel(slice.selector)}`);
|
|
64
|
+
lines.push(...renderMetrics(slice.metrics, " "));
|
|
65
|
+
if (slice.shortfalls.length > 0) {
|
|
66
|
+
lines.push(" why it is blocked:");
|
|
67
|
+
for (const item of slice.shortfalls) lines.push(` - ${item.message}`);
|
|
68
|
+
}
|
|
69
|
+
if (slice.openBlindSpots.length > 0) {
|
|
70
|
+
lines.push(" open blind spots:");
|
|
71
|
+
for (const record of slice.openBlindSpots) {
|
|
72
|
+
lines.push(` - ${record.blindSpotId}: ${record.description}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return lines;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Render a promotion verdict as plain text (contract §12.1).
|
|
80
|
+
*
|
|
81
|
+
* The whole-corpus figure appears **after** the per-scope breakdown and is explicitly labelled
|
|
82
|
+
* descriptive, so a reader reaches the scope detail first. Ordering is part of the guarantee: a
|
|
83
|
+
* summary at the top is the one people quote.
|
|
84
|
+
*/
|
|
85
|
+
export function renderPromotionReport(verdict: PromotionVerdict): string {
|
|
86
|
+
const lines: string[] = [];
|
|
87
|
+
|
|
88
|
+
lines.push(
|
|
89
|
+
`Evaluator promotion evidence — ${verdict.evaluatorId} @ ${verdict.evaluatorVersion}`,
|
|
90
|
+
`Corpus: ${verdict.corpusId}`,
|
|
91
|
+
"",
|
|
92
|
+
REPORT_CAVEAT,
|
|
93
|
+
"",
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
if (verdict.policyOrigin === "default-uncalibrated") {
|
|
97
|
+
lines.push(
|
|
98
|
+
"NOTE: assessed against this package's default thresholds, which have not themselves been " +
|
|
99
|
+
"calibrated against any real corpus (ADR-0010). Treat the bar as provisional.",
|
|
100
|
+
"",
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
lines.push(`Scopes assessed: ${verdict.slices.length}`);
|
|
105
|
+
if (verdict.slices.length === 0) {
|
|
106
|
+
lines.push(
|
|
107
|
+
" none — the corpus declares no scope dimensions, so nothing could be assessed per scope.",
|
|
108
|
+
" Promotion is scoped (ADR-0010); with no scopes there is no promotion decision to make.",
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
lines.push("");
|
|
112
|
+
|
|
113
|
+
for (const slice of verdict.slices) lines.push(...renderSlice(slice), "");
|
|
114
|
+
|
|
115
|
+
lines.push("Whole corpus (descriptive only — no threshold is applied to it, ADR-0010):");
|
|
116
|
+
lines.push(...renderMetrics(verdict.wholeCorpus, " "));
|
|
117
|
+
|
|
118
|
+
if (verdict.aggregateFlattersWorstScope) {
|
|
119
|
+
lines.push(
|
|
120
|
+
"",
|
|
121
|
+
"WARNING: the whole-corpus figure is better than the worst scope above. Reading the " +
|
|
122
|
+
"aggregate alone would overstate this evaluator (architecture contract §12.1).",
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (verdict.unevaluatedCaseIds.length > 0) {
|
|
127
|
+
lines.push(
|
|
128
|
+
"",
|
|
129
|
+
`${verdict.unevaluatedCaseIds.length} corpus case(s) the run never reported on; they count ` +
|
|
130
|
+
"as unflagged.",
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
lines.push(
|
|
135
|
+
"",
|
|
136
|
+
verdict.promotableScopes.length === 0
|
|
137
|
+
? "Outcome: no scope meets the promotion bar. The evaluator stays advisory (§12 level 2)."
|
|
138
|
+
: `Outcome: ${verdict.promotableScopes.length} of ${verdict.slices.length} scope(s) meet ` +
|
|
139
|
+
`the bar — ${verdict.promotableScopes.join(", ")}. Promotion applies only to those ` +
|
|
140
|
+
"scopes; every other scope stays advisory.",
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
return lines.join("\n");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Words the report must not use, exported so a test can enforce rule 2 above.
|
|
148
|
+
*
|
|
149
|
+
* Exported rather than kept private because the guarantee is only real if something checks it,
|
|
150
|
+
* and a test importing the same list cannot drift from the implementation.
|
|
151
|
+
*/
|
|
152
|
+
export function forbiddenClaimWords(): readonly string[] {
|
|
153
|
+
return FORBIDDEN_CLAIM_WORDS;
|
|
154
|
+
}
|
package/src/scope.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scope slicing (architecture contract §12.1).
|
|
3
|
+
*
|
|
4
|
+
* §12.1 lists "show, host, voice, model, and script-form scope" among what evaluator promotion
|
|
5
|
+
* must consider. The reason is that calibration does not generalise: an evaluator tuned on one
|
|
6
|
+
* host's cadence says nothing about another's, and one calibrated on a single voice says nothing
|
|
7
|
+
* about a second voice's artefacts.
|
|
8
|
+
*
|
|
9
|
+
* So a corpus is sliced, and every metric is computed per slice. Dimensions are caller-supplied
|
|
10
|
+
* `Record<string, string>` throughout, consistent with Knowledge Pack scope (§9.2, ADR-0006) —
|
|
11
|
+
* §12.1's list is illustrative and §4.2 forbids Core from naming a provider.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ScopeDimensions } from "./corpus.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A slice of the corpus: which dimensions were held fixed, and at what values.
|
|
18
|
+
*
|
|
19
|
+
* `dimensions` is empty for the whole-corpus slice, which is why that slice is named rather than
|
|
20
|
+
* left implicit — see {@link WHOLE_CORPUS_SLICE}.
|
|
21
|
+
*/
|
|
22
|
+
export interface ScopeSelector {
|
|
23
|
+
/** Dimension names held fixed by this slice, sorted. */
|
|
24
|
+
dimensions: readonly string[];
|
|
25
|
+
/** Value of each held dimension. */
|
|
26
|
+
values: Readonly<Record<string, string>>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The selector matching every case: no dimension held fixed. */
|
|
30
|
+
export const WHOLE_CORPUS_SLICE: ScopeSelector = { dimensions: [], values: {} };
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Stable string key for a selector, for grouping and for report ordering.
|
|
34
|
+
*
|
|
35
|
+
* Dimensions are sorted before joining so `{host,voice}` and `{voice,host}` produce one key.
|
|
36
|
+
* Values are separated by a character that cannot appear in a dimension name.
|
|
37
|
+
*/
|
|
38
|
+
export function scopeKey(selector: ScopeSelector): string {
|
|
39
|
+
if (selector.dimensions.length === 0) return "*";
|
|
40
|
+
return [...selector.dimensions]
|
|
41
|
+
.sort()
|
|
42
|
+
.map((dimension) => `${dimension}=${selector.values[dimension] ?? ""}`)
|
|
43
|
+
.join(" & ");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Human-readable label for a selector. */
|
|
47
|
+
export function scopeLabel(selector: ScopeSelector): string {
|
|
48
|
+
return selector.dimensions.length === 0 ? "whole corpus" : scopeKey(selector);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** True if a case's scope satisfies a selector. */
|
|
52
|
+
export function scopeMatches(scope: ScopeDimensions, selector: ScopeSelector): boolean {
|
|
53
|
+
return selector.dimensions.every((dimension) => scope[dimension] === selector.values[dimension]);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Every dimension name that appears anywhere in a set of scopes, sorted. */
|
|
57
|
+
export function observedDimensions(scopes: readonly ScopeDimensions[]): string[] {
|
|
58
|
+
const names = new Set<string>();
|
|
59
|
+
for (const scope of scopes) for (const key of Object.keys(scope)) names.add(key);
|
|
60
|
+
return [...names].sort();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Derive the selectors a corpus should be reported against.
|
|
65
|
+
*
|
|
66
|
+
* The default is **each observed dimension, sliced individually** — one slice per distinct
|
|
67
|
+
* `host`, one per distinct `voice`, and so on. It deliberately does not default to the full
|
|
68
|
+
* cross-product: with five dimensions a corpus would shatter into slices of one or two cases
|
|
69
|
+
* each, and a metric over two cases is noise that reads like evidence.
|
|
70
|
+
*
|
|
71
|
+
* A caller that genuinely needs a joint slice — "this evaluator on this host *and* this voice" —
|
|
72
|
+
* passes the grouping explicitly through `groupings`. That makes combinatorial slicing a
|
|
73
|
+
* deliberate request rather than something that happens by accident.
|
|
74
|
+
*
|
|
75
|
+
* @param scopes every case's scope.
|
|
76
|
+
* @param groupings dimension groupings to slice by. Defaults to each observed dimension alone.
|
|
77
|
+
*/
|
|
78
|
+
export function deriveScopeSelectors(
|
|
79
|
+
scopes: readonly ScopeDimensions[],
|
|
80
|
+
groupings?: readonly (readonly string[])[],
|
|
81
|
+
): ScopeSelector[] {
|
|
82
|
+
const effective = groupings ?? observedDimensions(scopes).map((dimension) => [dimension]);
|
|
83
|
+
const selectors = new Map<string, ScopeSelector>();
|
|
84
|
+
|
|
85
|
+
for (const grouping of effective) {
|
|
86
|
+
if (grouping.length === 0) continue;
|
|
87
|
+
const dimensions = [...grouping].sort();
|
|
88
|
+
for (const scope of scopes) {
|
|
89
|
+
// A case that does not declare every dimension in the grouping is not in any slice of it.
|
|
90
|
+
// Substituting a placeholder would invent a scope the labeller never asserted.
|
|
91
|
+
if (dimensions.some((dimension) => scope[dimension] === undefined)) continue;
|
|
92
|
+
const values: Record<string, string> = {};
|
|
93
|
+
for (const dimension of dimensions) values[dimension] = scope[dimension] as string;
|
|
94
|
+
const selector: ScopeSelector = { dimensions, values };
|
|
95
|
+
selectors.set(scopeKey(selector), selector);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return [...selectors.values()].sort((a, b) => scopeKey(a).localeCompare(scopeKey(b)));
|
|
100
|
+
}
|