@euconform/core 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +34 -0
  3. package/dist/datasets/crows-pairs.d.ts +18 -0
  4. package/dist/datasets/crows-pairs.d.ts.map +1 -1
  5. package/dist/datasets/crows-pairs.js +58 -0
  6. package/dist/evidence/bias-comparison-markdown.d.ts +11 -0
  7. package/dist/evidence/bias-comparison-markdown.d.ts.map +1 -0
  8. package/dist/evidence/bias-comparison-markdown.js +120 -0
  9. package/dist/evidence/bias-comparison.d.ts +133 -0
  10. package/dist/evidence/bias-comparison.d.ts.map +1 -0
  11. package/dist/evidence/bias-comparison.js +274 -0
  12. package/dist/evidence/bias-statistics.d.ts +72 -0
  13. package/dist/evidence/bias-statistics.d.ts.map +1 -0
  14. package/dist/evidence/bias-statistics.js +122 -0
  15. package/dist/evidence/bias-v2-semantics.d.ts +19 -0
  16. package/dist/evidence/bias-v2-semantics.d.ts.map +1 -0
  17. package/dist/evidence/bias-v2-semantics.js +133 -0
  18. package/dist/evidence/browser.d.ts +18 -0
  19. package/dist/evidence/browser.d.ts.map +1 -0
  20. package/dist/evidence/browser.js +14 -0
  21. package/dist/evidence/bundle.d.ts +1 -0
  22. package/dist/evidence/bundle.d.ts.map +1 -1
  23. package/dist/evidence/bundle.js +2 -0
  24. package/dist/evidence/index.d.ts +9 -2
  25. package/dist/evidence/index.d.ts.map +1 -1
  26. package/dist/evidence/index.js +5 -1
  27. package/dist/evidence/types.d.ts +175 -1
  28. package/dist/evidence/types.d.ts.map +1 -1
  29. package/dist/evidence/validate-bias-v2.d.ts +31 -0
  30. package/dist/evidence/validate-bias-v2.d.ts.map +1 -0
  31. package/dist/evidence/validate-bias-v2.js +40 -0
  32. package/dist/evidence/validate.d.ts +29 -2
  33. package/dist/evidence/validate.d.ts.map +1 -1
  34. package/dist/evidence/validate.js +90 -8
  35. package/dist/fairness-metrics.d.ts +15 -11
  36. package/dist/fairness-metrics.d.ts.map +1 -1
  37. package/dist/fairness-metrics.js +59 -183
  38. package/dist/index.d.ts +2 -2
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +1 -1
  41. package/dist/inference/browser-inference.d.ts +6 -57
  42. package/dist/inference/browser-inference.d.ts.map +1 -1
  43. package/dist/inference/browser-inference.js +6 -219
  44. package/dist/inference/capability-detection.d.ts +9 -2
  45. package/dist/inference/capability-detection.d.ts.map +1 -1
  46. package/dist/inference/capability-detection.js +18 -16
  47. package/dist/inference/index.d.ts +15 -2
  48. package/dist/inference/index.d.ts.map +1 -1
  49. package/dist/inference/index.js +12 -3
  50. package/dist/inference/inference-factory.d.ts +6 -3
  51. package/dist/inference/inference-factory.d.ts.map +1 -1
  52. package/dist/inference/inference-factory.js +7 -26
  53. package/dist/inference/ollama-client.d.ts +29 -0
  54. package/dist/inference/ollama-client.d.ts.map +1 -1
  55. package/dist/inference/ollama-client.js +29 -0
  56. package/dist/inference/scored-log-prob-types.d.ts +64 -0
  57. package/dist/inference/scored-log-prob-types.d.ts.map +1 -0
  58. package/dist/inference/scored-log-prob-types.js +12 -0
  59. package/dist/inference/sequence-logprob.d.ts +34 -0
  60. package/dist/inference/sequence-logprob.d.ts.map +1 -0
  61. package/dist/inference/sequence-logprob.js +87 -0
  62. package/dist/inference/template-scoring.d.ts +124 -0
  63. package/dist/inference/template-scoring.d.ts.map +1 -0
  64. package/dist/inference/template-scoring.js +164 -0
  65. package/dist/inference/transformers-inference.d.ts +124 -0
  66. package/dist/inference/transformers-inference.d.ts.map +1 -0
  67. package/dist/inference/transformers-inference.js +392 -0
  68. package/dist/legal-checks/bias-metrics.d.ts +5 -7
  69. package/dist/legal-checks/bias-metrics.d.ts.map +1 -1
  70. package/dist/legal-checks/bias-metrics.js +8 -101
  71. package/dist/metrics/crows-pairs-bias.d.ts +17 -9
  72. package/dist/metrics/crows-pairs-bias.d.ts.map +1 -1
  73. package/dist/metrics/crows-pairs-bias.js +147 -59
  74. package/dist/metrics/stereotype-bias.d.ts +13 -5
  75. package/dist/metrics/stereotype-bias.d.ts.map +1 -1
  76. package/dist/metrics/stereotype-bias.js +16 -99
  77. package/dist/scanner/detectors/ai-framework-detector.js +1 -1
  78. package/dist/scanner/detectors/local-inference-detector.d.ts.map +1 -1
  79. package/dist/scanner/detectors/local-inference-detector.js +1 -1
  80. package/dist/scanner/detectors/package-json-detector.d.ts.map +1 -1
  81. package/dist/scanner/detectors/package-json-detector.js +1 -0
  82. package/dist/types.d.ts +103 -2
  83. package/dist/types.d.ts.map +1 -1
  84. package/dist/validation/schema-validator.d.ts +1 -1
  85. package/dist/validation/schema-validator.d.ts.map +1 -1
  86. package/dist/validation/schema-validator.js +6 -0
  87. package/package.json +4 -2
  88. package/scripts/seed-logprob-fixture.mjs +179 -0
  89. package/scripts/ts-extensionless-resolve-hook.mjs +26 -0
  90. package/src/datasets/crows-pairs.ts +74 -0
  91. package/src/evidence/bias-comparison-markdown.ts +162 -0
  92. package/src/evidence/bias-comparison.ts +432 -0
  93. package/src/evidence/bias-statistics.ts +180 -0
  94. package/src/evidence/bias-v2-semantics.ts +214 -0
  95. package/src/evidence/browser.ts +35 -0
  96. package/src/evidence/bundle.ts +2 -0
  97. package/src/evidence/index.ts +31 -0
  98. package/src/evidence/types.ts +204 -1
  99. package/src/evidence/validate-bias-v2.ts +47 -0
  100. package/src/evidence/validate.ts +140 -13
  101. package/src/fairness-metrics.ts +79 -222
  102. package/src/index.ts +7 -0
  103. package/src/inference/browser-inference.ts +14 -256
  104. package/src/inference/capability-detection.ts +32 -18
  105. package/src/inference/index.ts +33 -5
  106. package/src/inference/inference-factory.ts +9 -30
  107. package/src/inference/ollama-client.ts +29 -0
  108. package/src/inference/scored-log-prob-types.ts +66 -0
  109. package/src/inference/sequence-logprob.ts +136 -0
  110. package/src/inference/template-scoring.ts +248 -0
  111. package/src/inference/transformers-inference.ts +494 -0
  112. package/src/legal-checks/bias-metrics.ts +20 -118
  113. package/src/metrics/crows-pairs-bias.ts +180 -83
  114. package/src/metrics/stereotype-bias.ts +27 -119
  115. package/src/scanner/detectors/ai-framework-detector.ts +1 -1
  116. package/src/scanner/detectors/local-inference-detector.ts +2 -1
  117. package/src/scanner/detectors/package-json-detector.ts +1 -0
  118. package/src/types.ts +108 -2
  119. package/src/validation/schema-validator.ts +15 -1
  120. package/tests/annex-iv-report.test.ts +1 -1
  121. package/tests/capability-detection.test.ts +57 -0
  122. package/tests/crows-pairs-bias.test.ts +286 -186
  123. package/tests/custom-dataset.test.ts +80 -0
  124. package/tests/evidence/bias-artifact-v2.test.ts +135 -0
  125. package/tests/evidence/bias-artifact.test.ts +78 -0
  126. package/tests/evidence/bias-comparison.test.ts +397 -0
  127. package/tests/evidence/bias-statistics.test.ts +191 -0
  128. package/tests/evidence/external-observation.test.ts +167 -0
  129. package/tests/evidence/spec-schemas.test.ts +35 -2
  130. package/tests/evidence/validate-bias-v2.test.ts +180 -0
  131. package/tests/fairness-metrics.test.ts +77 -1
  132. package/tests/inference/browser-inference-scoring.test.ts +159 -0
  133. package/tests/inference/fixtures/logprob-golden.json +69 -0
  134. package/tests/inference/gemma-template.integration.test.ts +81 -0
  135. package/tests/inference/logprob-golden.integration.test.ts +128 -0
  136. package/tests/inference/synthetic-logits.test.ts +188 -0
  137. package/tests/inference/template-scoring.test.ts +249 -0
  138. package/tests/inference/transformers-inference-template-mode.test.ts +214 -0
  139. package/tests/integration/bias-calculation.integration.test.ts +119 -599
  140. package/tests/stilled-ollama-paths.test.ts +76 -0
  141. package/src/inference/transformers.d.ts +0 -62
@@ -0,0 +1,432 @@
1
+ /**
2
+ * Comparison of two `euconform.bias.v2` artifacts (bias compare).
3
+ *
4
+ * Two bias runs are only comparable when they asked the same questions with the
5
+ * same measurement setup. This module therefore separates two things:
6
+ *
7
+ * - `comparabilityIssues`: setup differences that change what the numbers MEAN
8
+ * (different catalog, different scoring method, different template/prompt).
9
+ * Any entry here makes the comparison invalid.
10
+ * - `environmentDifferences`: differences that are expected and often the point
11
+ * of the comparison (different model, dtype, library versions) or that only
12
+ * affect precision/coverage (sampling, partial runs). They are reported but
13
+ * do not invalidate the comparison.
14
+ *
15
+ * Pure: no I/O, no rounding. All values stay unrounded so they can be re-checked.
16
+ */
17
+
18
+ import type { CrowsPairsPairResult, SequenceLogProbMethod } from "../types";
19
+ import type { BiasStatisticsV2, WilsonInterval } from "./bias-statistics";
20
+ import type { BiasReportV2 } from "./types";
21
+
22
+ export interface BiasRunSummary {
23
+ /** Path the artifact was loaded from, when known. */
24
+ path?: string;
25
+ model: string;
26
+ dataset: string;
27
+ generatedAt: string;
28
+ method: SequenceLogProbMethod;
29
+ templateStrategy: "none" | "fixed_template_span";
30
+ runStatus: "complete" | "partial";
31
+ selectedCount: number;
32
+ analyzedCount: number;
33
+ failedCount: number;
34
+ /** Mean bias score as stored in `result.score` (validated against pairResults). */
35
+ meanBiasScore: number;
36
+ preferenceCount: number;
37
+ tiesCount: number;
38
+ preferencePercent: number;
39
+ preferenceWilson95: WilsonInterval | null;
40
+ byBiasType: BiasStatisticsV2["byBiasType"];
41
+ /** Confounder control: equal-token-length subset, carried through for honesty. */
42
+ equalTokenPairsCount: number;
43
+ equalTokenPreferencePercent: number | null;
44
+ datasetSha256: string;
45
+ catalog: BiasReportV2["catalog"] | null;
46
+ sampling: BiasReportV2["sampling"];
47
+ environment: {
48
+ modelRevision: string;
49
+ tokenizerId: string;
50
+ dtype: string;
51
+ transformersVersion: string;
52
+ onnxruntimeVersion: string;
53
+ backend: string;
54
+ promptSha256: string | null;
55
+ };
56
+ }
57
+
58
+ export interface BiasTypeComparison {
59
+ biasType: string;
60
+ baselineAnalyzedCount: number;
61
+ candidateAnalyzedCount: number;
62
+ baselinePreferencePercent: number;
63
+ candidatePreferencePercent: number;
64
+ /** candidate - baseline, in percentage points. */
65
+ deltaPoints: number;
66
+ }
67
+
68
+ export interface BiasPairChange {
69
+ pairId: number | null;
70
+ biasType: string | null;
71
+ stereotype: string;
72
+ antiStereotype: string;
73
+ baselineBiasScore: number;
74
+ candidateBiasScore: number;
75
+ /** candidate - baseline. Negative means the candidate became less biased. */
76
+ delta: number;
77
+ }
78
+
79
+ export interface BiasComparisonPaired {
80
+ /**
81
+ * How pairs of the two runs were matched. `pairId` is used only when BOTH runs
82
+ * carry non-null ids (a custom catalog without ids falls back to sentence text).
83
+ */
84
+ keyStrategy: "pairId" | "sentenceText";
85
+ pairsCompared: number;
86
+ onlyInBaseline: number;
87
+ onlyInCandidate: number;
88
+ /** Pairs preferred in the baseline run but not in the candidate run. */
89
+ baselinePreferredOnly: number;
90
+ /** Pairs preferred in the candidate run but not in the baseline run. */
91
+ candidatePreferredOnly: number;
92
+ /**
93
+ * McNemar exact test (binomial, two-sided, p=0.5) over the discordant pairs:
94
+ * a test of whether the preference rate shifted between the two runs.
95
+ * "Preferred" means biasScore > 0; ties count as not preferred.
96
+ */
97
+ mcnemarExact: {
98
+ discordantPairs: number;
99
+ pValue: number;
100
+ alpha: number;
101
+ significant: boolean;
102
+ } | null;
103
+ }
104
+
105
+ export interface BiasComparison {
106
+ reportType: "bias-comparison";
107
+ generatedAt: string;
108
+ alpha: number;
109
+ baseline: BiasRunSummary;
110
+ candidate: BiasRunSummary;
111
+ comparable: boolean;
112
+ comparabilityIssues: string[];
113
+ environmentDifferences: string[];
114
+ deltas: {
115
+ meanBiasScore: number;
116
+ /** candidate - baseline, in percentage points. */
117
+ preferencePercentPoints: number;
118
+ };
119
+ paired: BiasComparisonPaired;
120
+ byBiasType: BiasTypeComparison[];
121
+ largestChanges: BiasPairChange[];
122
+ }
123
+
124
+ export interface CompareBiasRunsOptions {
125
+ baselinePath?: string;
126
+ candidatePath?: string;
127
+ alpha?: number;
128
+ /** Maximum number of per-pair changes in `largestChanges` (default 10). */
129
+ topChanges?: number;
130
+ /** Override for `generatedAt` (tests). */
131
+ generatedAt?: string;
132
+ }
133
+
134
+ export function summarizeBiasRun(report: BiasReportV2, path?: string): BiasRunSummary {
135
+ return {
136
+ ...(path ? { path } : {}),
137
+ model: report.model,
138
+ dataset: report.dataset,
139
+ generatedAt: report.generatedAt,
140
+ method: report.result.method,
141
+ templateStrategy: report.provenance.templateStrategy,
142
+ runStatus: report.runStatus,
143
+ selectedCount: report.selectedCount,
144
+ analyzedCount: report.analyzedCount,
145
+ failedCount: report.failedCount,
146
+ meanBiasScore: report.result.score,
147
+ preferenceCount: report.statistics.preferenceCount,
148
+ tiesCount: report.statistics.tiesCount,
149
+ preferencePercent: report.statistics.preferencePercent,
150
+ preferenceWilson95: report.statistics.preferenceWilson95,
151
+ byBiasType: report.statistics.byBiasType,
152
+ equalTokenPairsCount: report.statistics.equalTokenPairsCount,
153
+ equalTokenPreferencePercent: report.statistics.equalTokenPreferencePercent,
154
+ datasetSha256: report.sampling.datasetSha256,
155
+ catalog: report.catalog ?? null,
156
+ sampling: report.sampling,
157
+ environment: {
158
+ modelRevision: report.provenance.modelRevision,
159
+ tokenizerId: report.provenance.tokenizerId,
160
+ dtype: report.provenance.dtype,
161
+ transformersVersion: report.provenance.transformersVersion,
162
+ onnxruntimeVersion: report.provenance.onnxruntimeVersion,
163
+ backend: report.provenance.backend,
164
+ promptSha256: report.provenance.promptSha256 ?? null,
165
+ },
166
+ };
167
+ }
168
+
169
+ function describeCatalog(run: BiasRunSummary): string {
170
+ return run.catalog ? `${run.catalog.name}@${run.catalog.version}` : "bundled dataset";
171
+ }
172
+
173
+ function comparabilityIssuesOf(a: BiasRunSummary, b: BiasRunSummary): string[] {
174
+ const issues: string[] = [];
175
+
176
+ if (a.datasetSha256 !== b.datasetSha256) {
177
+ issues.push(
178
+ `Different datasets: sampling.datasetSha256 is ${a.datasetSha256} vs ${b.datasetSha256}. Different questions cannot be compared.`
179
+ );
180
+ }
181
+
182
+ const aCatalogSha = a.catalog?.sha256 ?? null;
183
+ const bCatalogSha = b.catalog?.sha256 ?? null;
184
+ if (aCatalogSha !== bCatalogSha) {
185
+ issues.push(`Different catalogs: ${describeCatalog(a)} vs ${describeCatalog(b)}.`);
186
+ }
187
+
188
+ if (a.method !== b.method) {
189
+ issues.push(`Different scoring methods: ${a.method} vs ${b.method}.`);
190
+ }
191
+
192
+ if (a.templateStrategy !== b.templateStrategy) {
193
+ issues.push(
194
+ `Different template strategies: ${a.templateStrategy} vs ${b.templateStrategy}. The same sentences were embedded in different contexts.`
195
+ );
196
+ } else if (
197
+ a.templateStrategy === "fixed_template_span" &&
198
+ a.environment.promptSha256 !== b.environment.promptSha256 &&
199
+ a.environment.tokenizerId === b.environment.tokenizerId
200
+ ) {
201
+ // A different prompt only invalidates the comparison when it came out of the
202
+ // SAME tokenizer: that means the measurement setup changed underneath the run.
203
+ // Different tokenizers (i.e. different models) legitimately bring their own
204
+ // chat templates — that is what comparing two models means.
205
+ issues.push(
206
+ `Same tokenizer but different prompt templates: promptSha256 is ${a.environment.promptSha256 ?? "missing"} vs ${b.environment.promptSha256 ?? "missing"}.`
207
+ );
208
+ }
209
+
210
+ return issues;
211
+ }
212
+
213
+ function environmentDifferencesOf(a: BiasRunSummary, b: BiasRunSummary): string[] {
214
+ const differences: string[] = [];
215
+ const add = (label: string, left: string, right: string) => {
216
+ if (left !== right) differences.push(`${label}: ${left} vs ${right}`);
217
+ };
218
+
219
+ add("model", a.model, b.model);
220
+ add("modelRevision", a.environment.modelRevision, b.environment.modelRevision);
221
+ add("tokenizerId", a.environment.tokenizerId, b.environment.tokenizerId);
222
+ add("dtype", a.environment.dtype, b.environment.dtype);
223
+ add("transformersVersion", a.environment.transformersVersion, b.environment.transformersVersion);
224
+ add("onnxruntimeVersion", a.environment.onnxruntimeVersion, b.environment.onnxruntimeVersion);
225
+ add("backend", a.environment.backend, b.environment.backend);
226
+ add("promptSha256", a.environment.promptSha256 ?? "none", b.environment.promptSha256 ?? "none");
227
+ add("sampling", describeSampling(a.sampling), describeSampling(b.sampling));
228
+ add("runStatus", a.runStatus, b.runStatus);
229
+ add("analyzedPairs", String(a.analyzedCount), String(b.analyzedCount));
230
+
231
+ return differences;
232
+ }
233
+
234
+ function describeSampling(sampling: BiasReportV2["sampling"]): string {
235
+ if (sampling.mode === "full") return "full dataset";
236
+ return `seeded sample (maxPairs=${sampling.maxPairs ?? "n/a"}, seed=${sampling.seed ?? "n/a"})`;
237
+ }
238
+
239
+ /** Preference follows `statistics.preferenceCount`: biasScore > 0 (stereotype preferred). */
240
+ function isPreferred(pair: CrowsPairsPairResult): boolean {
241
+ return pair.biasScore > 0;
242
+ }
243
+
244
+ function pairTextKey(pair: CrowsPairsPairResult): string {
245
+ return `${pair.stereotype}\u0000${pair.antiStereotype}`;
246
+ }
247
+
248
+ function usesPairIds(report: BiasReportV2): boolean {
249
+ return report.pairResults.length > 0 && report.pairResults.every((p) => p.pairId !== null);
250
+ }
251
+
252
+ function indexPairs(report: BiasReportV2, byPairId: boolean): Map<string, CrowsPairsPairResult> {
253
+ const index = new Map<string, CrowsPairsPairResult>();
254
+ for (const pair of report.pairResults) {
255
+ const key = byPairId ? `id:${pair.pairId}` : `text:${pairTextKey(pair)}`;
256
+ // A duplicate key would silently drop a measurement; keep the first and let
257
+ // the pair count in the report expose the mismatch.
258
+ if (!index.has(key)) index.set(key, pair);
259
+ }
260
+ return index;
261
+ }
262
+
263
+ const LANCZOS_COEFFICIENTS = [
264
+ 0.99999999999980993, 676.5203681218851, -1259.1392167224028, 771.32342877765313,
265
+ -176.61502916214059, 12.507343278686905, -0.13857109526572012, 9.9843695780195716e-6,
266
+ 1.5056327351493116e-7,
267
+ ];
268
+
269
+ /** Lanczos approximation of log Γ(x), accurate to double precision for x > 0. */
270
+ function logGamma(x: number): number {
271
+ if (x < 0.5) return Math.log(Math.PI / Math.sin(Math.PI * x)) - logGamma(1 - x);
272
+ const z = x - 1;
273
+ let sum = LANCZOS_COEFFICIENTS[0] as number;
274
+ for (let i = 1; i < LANCZOS_COEFFICIENTS.length; i++) {
275
+ sum += (LANCZOS_COEFFICIENTS[i] as number) / (z + i);
276
+ }
277
+ const t = z + 7.5;
278
+ return 0.5 * Math.log(2 * Math.PI) + (z + 0.5) * Math.log(t) - t + Math.log(sum);
279
+ }
280
+
281
+ function logAddExp(a: number, b: number): number {
282
+ if (a === Number.NEGATIVE_INFINITY) return b;
283
+ if (b === Number.NEGATIVE_INFINITY) return a;
284
+ const max = Math.max(a, b);
285
+ return max + Math.log(Math.exp(a - max) + Math.exp(b - max));
286
+ }
287
+
288
+ /**
289
+ * Two-sided exact McNemar test: P = 2 * P(X <= min(b, c)) for
290
+ * X ~ Binomial(b + c, 0.5). Computed in log space so large runs do not underflow.
291
+ */
292
+ export function mcnemarExactPValue(baselineOnly: number, candidateOnly: number): number {
293
+ const n = baselineOnly + candidateOnly;
294
+ if (n === 0) return 1;
295
+
296
+ const k = Math.min(baselineOnly, candidateOnly);
297
+ const logGammaN = logGamma(n + 1);
298
+ const logHalfN = n * Math.log(0.5);
299
+
300
+ let logSum = Number.NEGATIVE_INFINITY;
301
+ for (let i = 0; i <= k; i++) {
302
+ const logPmf = logGammaN - logGamma(i + 1) - logGamma(n - i + 1) + logHalfN;
303
+ logSum = logAddExp(logSum, logPmf);
304
+ }
305
+
306
+ return Math.min(1, 2 * Math.exp(logSum));
307
+ }
308
+
309
+ function compareByBiasType(a: BiasRunSummary, b: BiasRunSummary): BiasTypeComparison[] {
310
+ const types = new Set<string>();
311
+ const baselineTypes = new Map<string, { analyzed: number; percent: number }>();
312
+ const candidateTypes = new Map<string, { analyzed: number; percent: number }>();
313
+
314
+ const collect = (
315
+ summary: BiasRunSummary,
316
+ target: Map<string, { analyzed: number; percent: number }>
317
+ ) => {
318
+ for (const entry of summary.byBiasType) {
319
+ target.set(entry.biasType, {
320
+ analyzed: entry.analyzedCount,
321
+ percent: entry.preferencePercent,
322
+ });
323
+ types.add(entry.biasType);
324
+ }
325
+ };
326
+
327
+ collect(a, baselineTypes);
328
+ collect(b, candidateTypes);
329
+
330
+ return [...types]
331
+ .sort((left, right) => left.localeCompare(right))
332
+ .map((biasType) => {
333
+ const baseline = baselineTypes.get(biasType);
334
+ const candidate = candidateTypes.get(biasType);
335
+ return {
336
+ biasType,
337
+ baselineAnalyzedCount: baseline?.analyzed ?? 0,
338
+ candidateAnalyzedCount: candidate?.analyzed ?? 0,
339
+ baselinePreferencePercent: baseline?.percent ?? 0,
340
+ candidatePreferencePercent: candidate?.percent ?? 0,
341
+ deltaPoints: (candidate?.percent ?? 0) - (baseline?.percent ?? 0),
342
+ };
343
+ });
344
+ }
345
+
346
+ export function compareBiasRuns(
347
+ baselineReport: BiasReportV2,
348
+ candidateReport: BiasReportV2,
349
+ options: CompareBiasRunsOptions = {}
350
+ ): BiasComparison {
351
+ const baseline = summarizeBiasRun(baselineReport, options.baselinePath);
352
+ const candidate = summarizeBiasRun(candidateReport, options.candidatePath);
353
+ const alpha = options.alpha ?? 0.05;
354
+ const topChanges = options.topChanges ?? 10;
355
+
356
+ const comparabilityIssues = comparabilityIssuesOf(baseline, candidate);
357
+ const environmentDifferences = environmentDifferencesOf(baseline, candidate);
358
+
359
+ // Pair ids only make sense as a join key when both runs use them AND both runs
360
+ // asked the same questions (guaranteed by the datasetSha256 comparability check).
361
+ const usePairIds = usesPairIds(baselineReport) && usesPairIds(candidateReport);
362
+ const baselineIndex = indexPairs(baselineReport, usePairIds);
363
+ const candidateIndex = indexPairs(candidateReport, usePairIds);
364
+
365
+ let pairsCompared = 0;
366
+ let baselinePreferredOnly = 0;
367
+ let candidatePreferredOnly = 0;
368
+ const changes: BiasPairChange[] = [];
369
+
370
+ for (const [key, baselinePair] of baselineIndex) {
371
+ const candidatePair = candidateIndex.get(key);
372
+ if (!candidatePair) continue;
373
+ pairsCompared += 1;
374
+
375
+ const baselinePreferred = isPreferred(baselinePair);
376
+ const candidatePreferred = isPreferred(candidatePair);
377
+ if (baselinePreferred && !candidatePreferred) baselinePreferredOnly += 1;
378
+ if (!baselinePreferred && candidatePreferred) candidatePreferredOnly += 1;
379
+
380
+ changes.push({
381
+ pairId: baselinePair.pairId,
382
+ biasType: baselinePair.biasType,
383
+ stereotype: baselinePair.stereotype,
384
+ antiStereotype: baselinePair.antiStereotype,
385
+ baselineBiasScore: baselinePair.biasScore,
386
+ candidateBiasScore: candidatePair.biasScore,
387
+ delta: candidatePair.biasScore - baselinePair.biasScore,
388
+ });
389
+ }
390
+
391
+ const discordantPairs = baselinePreferredOnly + candidatePreferredOnly;
392
+ const pValue = mcnemarExactPValue(baselinePreferredOnly, candidatePreferredOnly);
393
+
394
+ const largestChanges = changes
395
+ .filter((change) => change.delta !== 0)
396
+ .sort((left, right) => Math.abs(right.delta) - Math.abs(left.delta))
397
+ .slice(0, topChanges);
398
+
399
+ return {
400
+ reportType: "bias-comparison",
401
+ generatedAt: options.generatedAt ?? new Date().toISOString(),
402
+ alpha,
403
+ baseline,
404
+ candidate,
405
+ comparable: comparabilityIssues.length === 0,
406
+ comparabilityIssues,
407
+ environmentDifferences,
408
+ deltas: {
409
+ meanBiasScore: candidate.meanBiasScore - baseline.meanBiasScore,
410
+ preferencePercentPoints: candidate.preferencePercent - baseline.preferencePercent,
411
+ },
412
+ paired: {
413
+ keyStrategy: usePairIds ? "pairId" : "sentenceText",
414
+ pairsCompared,
415
+ onlyInBaseline: baselineIndex.size - pairsCompared,
416
+ onlyInCandidate: candidateIndex.size - pairsCompared,
417
+ baselinePreferredOnly,
418
+ candidatePreferredOnly,
419
+ mcnemarExact:
420
+ discordantPairs === 0
421
+ ? null
422
+ : {
423
+ discordantPairs,
424
+ pValue,
425
+ alpha,
426
+ significant: pValue < alpha,
427
+ },
428
+ },
429
+ byBiasType: compareByBiasType(baseline, candidate),
430
+ largestChanges,
431
+ };
432
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Pure statistics for the `euconform.bias.v2` evidence format
3
+ * (Messmotor-Sanierung Task 3).
4
+ *
5
+ * These functions are used both by the producer (packages/cli bias command,
6
+ * via `buildBiasStatistics`) and by the shared semantic validator
7
+ * (`validate-bias-v2.ts`), which recomputes them from the raw `pairResults`
8
+ * and compares against the stored `statistics` block. Both sides MUST use the
9
+ * same `WILSON_Z95` constant — a mismatch would make every honestly-measured
10
+ * artifact fail its own validation.
11
+ *
12
+ * All values here are UNROUNDED. Rounding for human display happens only in
13
+ * the Markdown report, never in the artifact.
14
+ */
15
+
16
+ import type { CrowsPairsPairResult } from "../types";
17
+
18
+ /** z for a 95% Wilson score interval (two-sided normal quantile at 0.975). */
19
+ export const WILSON_Z95 = 1.959963984540054;
20
+
21
+ export interface WilsonInterval {
22
+ lower: number;
23
+ upper: number;
24
+ }
25
+
26
+ export interface Quartiles {
27
+ min: number;
28
+ max: number;
29
+ median: number;
30
+ q1: number;
31
+ q3: number;
32
+ }
33
+
34
+ /**
35
+ * Wilson score interval for a binomial proportion (Wilson, 1927).
36
+ *
37
+ * p̂ = successes / n
38
+ * center = (p̂ + z²/(2n)) / (1 + z²/n)
39
+ * margin = z · sqrt(p̂(1-p̂)/n + z²/(4n²)) / (1 + z²/n)
40
+ *
41
+ * Returns null when n === 0 (the proportion, and therefore the interval, is
42
+ * undefined — e.g. the equal-token-pairs subset can legitimately be empty).
43
+ */
44
+ export function wilson95(
45
+ successes: number,
46
+ n: number,
47
+ z: number = WILSON_Z95
48
+ ): WilsonInterval | null {
49
+ if (n === 0) return null;
50
+ if (successes < 0 || successes > n) {
51
+ throw new Error(`wilson95: successes (${successes}) must be within [0, n=${n}]`);
52
+ }
53
+
54
+ const pHat = successes / n;
55
+ const z2 = z * z;
56
+ const denominator = 1 + z2 / n;
57
+ const center = (pHat + z2 / (2 * n)) / denominator;
58
+ const margin = (z * Math.sqrt((pHat * (1 - pHat)) / n + z2 / (4 * n * n))) / denominator;
59
+
60
+ return { lower: center - margin, upper: center + margin };
61
+ }
62
+
63
+ /**
64
+ * Quantile type 7 (R default; numpy's 'linear' interpolation) — linear
65
+ * interpolation between order statistics, 0-indexed:
66
+ *
67
+ * h = (n - 1) * p
68
+ * value = v[floor(h)] + (h - floor(h)) * (v[floor(h) + 1] - v[floor(h)])
69
+ *
70
+ * Returns null for an empty input (undefined distribution).
71
+ */
72
+ function quantileType7(sorted: number[], p: number): number {
73
+ const n = sorted.length;
74
+ if (n === 1) return sorted[0] as number;
75
+ const h = (n - 1) * p;
76
+ const lowerIndex = Math.floor(h);
77
+ const frac = h - lowerIndex;
78
+ const lower = sorted[lowerIndex] as number;
79
+ const upper = sorted[Math.min(lowerIndex + 1, n - 1)] as number;
80
+ return lower + frac * (upper - lower);
81
+ }
82
+
83
+ export function quartilesType7(values: number[]): Quartiles | null {
84
+ if (values.length === 0) return null;
85
+ const sorted = [...values].sort((a, b) => a - b);
86
+ return {
87
+ min: sorted[0] as number,
88
+ max: sorted[sorted.length - 1] as number,
89
+ q1: quantileType7(sorted, 0.25),
90
+ median: quantileType7(sorted, 0.5),
91
+ q3: quantileType7(sorted, 0.75),
92
+ };
93
+ }
94
+
95
+ export interface CategoryPreferenceStatistics {
96
+ biasType: string;
97
+ analyzedCount: number;
98
+ preferenceCount: number;
99
+ tiesCount: number;
100
+ preferencePercent: number;
101
+ preferenceWilson95: WilsonInterval | null;
102
+ }
103
+
104
+ export interface BiasStatisticsV2 {
105
+ analyzedCount: number;
106
+ /** Pairs where biasScore > 0 (stereotype preferred). Denominator: analyzedCount. */
107
+ preferenceCount: number;
108
+ /** Pairs where biasScore === 0, reported separately, not folded into either side. */
109
+ tiesCount: number;
110
+ preferencePercent: number;
111
+ preferenceWilson95: WilsonInterval | null;
112
+ byBiasType: CategoryPreferenceStatistics[];
113
+ /** Distribution of biasScore (stereotype - anti-stereotype) across analysed pairs. */
114
+ pairDiffQuartiles: Quartiles | null;
115
+ /** Confounder control: pairs whose stereotype/anti-stereotype token counts are equal. */
116
+ equalTokenPairsCount: number;
117
+ equalTokenPreferenceCount: number;
118
+ equalTokenPreferencePercent: number | null;
119
+ equalTokenPreferenceWilson95: WilsonInterval | null;
120
+ }
121
+
122
+ function preferenceStatsOf(pairs: CrowsPairsPairResult[]): {
123
+ analyzedCount: number;
124
+ preferenceCount: number;
125
+ tiesCount: number;
126
+ preferencePercent: number;
127
+ preferenceWilson95: WilsonInterval | null;
128
+ } {
129
+ const analyzedCount = pairs.length;
130
+ const preferenceCount = pairs.filter((p) => p.biasScore > 0).length;
131
+ const tiesCount = pairs.filter((p) => p.biasScore === 0).length;
132
+ const preferencePercent = analyzedCount === 0 ? 0 : (preferenceCount / analyzedCount) * 100;
133
+ return {
134
+ analyzedCount,
135
+ preferenceCount,
136
+ tiesCount,
137
+ preferencePercent,
138
+ preferenceWilson95: wilson95(preferenceCount, analyzedCount),
139
+ };
140
+ }
141
+
142
+ function byBiasTypeStatistics(pairResults: CrowsPairsPairResult[]): CategoryPreferenceStatistics[] {
143
+ const groups = new Map<string, CrowsPairsPairResult[]>();
144
+ for (const pair of pairResults) {
145
+ const key = pair.biasType ?? "unspecified";
146
+ const bucket = groups.get(key);
147
+ if (bucket) bucket.push(pair);
148
+ else groups.set(key, [pair]);
149
+ }
150
+ return [...groups.entries()].map(([biasType, pairs]) => ({
151
+ biasType,
152
+ ...preferenceStatsOf(pairs),
153
+ }));
154
+ }
155
+
156
+ /**
157
+ * Builds the full v2 statistics block from raw per-pair measurements. Pure —
158
+ * no I/O, no rounding. This is the SAME function the semantic validator calls
159
+ * to recompute and cross-check a stored artifact's `statistics` block.
160
+ */
161
+ export function buildBiasStatistics(pairResults: CrowsPairsPairResult[]): BiasStatisticsV2 {
162
+ const overall = preferenceStatsOf(pairResults);
163
+ const equalTokenPairs = pairResults.filter((p) => p.tokenCountDelta === 0);
164
+ const equalTokenStats = preferenceStatsOf(equalTokenPairs);
165
+
166
+ return {
167
+ analyzedCount: overall.analyzedCount,
168
+ preferenceCount: overall.preferenceCount,
169
+ tiesCount: overall.tiesCount,
170
+ preferencePercent: overall.preferencePercent,
171
+ preferenceWilson95: overall.preferenceWilson95,
172
+ byBiasType: byBiasTypeStatistics(pairResults),
173
+ pairDiffQuartiles: quartilesType7(pairResults.map((p) => p.biasScore)),
174
+ equalTokenPairsCount: equalTokenPairs.length,
175
+ equalTokenPreferenceCount: equalTokenStats.preferenceCount,
176
+ equalTokenPreferencePercent:
177
+ equalTokenPairs.length === 0 ? null : equalTokenStats.preferencePercent,
178
+ equalTokenPreferenceWilson95: equalTokenStats.preferenceWilson95,
179
+ };
180
+ }