@euconform/core 1.4.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 (179) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +46 -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/output.d.ts.map +1 -1
  28. package/dist/evidence/output.js +1 -0
  29. package/dist/evidence/types.d.ts +183 -3
  30. package/dist/evidence/types.d.ts.map +1 -1
  31. package/dist/evidence/validate-bias-v2.d.ts +31 -0
  32. package/dist/evidence/validate-bias-v2.d.ts.map +1 -0
  33. package/dist/evidence/validate-bias-v2.js +40 -0
  34. package/dist/evidence/validate.d.ts +29 -2
  35. package/dist/evidence/validate.d.ts.map +1 -1
  36. package/dist/evidence/validate.js +96 -11
  37. package/dist/fairness-metrics.d.ts +15 -11
  38. package/dist/fairness-metrics.d.ts.map +1 -1
  39. package/dist/fairness-metrics.js +59 -183
  40. package/dist/index.d.ts +2 -2
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +1 -1
  43. package/dist/inference/browser-inference.d.ts +6 -57
  44. package/dist/inference/browser-inference.d.ts.map +1 -1
  45. package/dist/inference/browser-inference.js +6 -219
  46. package/dist/inference/capability-detection.d.ts +9 -2
  47. package/dist/inference/capability-detection.d.ts.map +1 -1
  48. package/dist/inference/capability-detection.js +18 -16
  49. package/dist/inference/index.d.ts +15 -2
  50. package/dist/inference/index.d.ts.map +1 -1
  51. package/dist/inference/index.js +12 -3
  52. package/dist/inference/inference-factory.d.ts +6 -3
  53. package/dist/inference/inference-factory.d.ts.map +1 -1
  54. package/dist/inference/inference-factory.js +7 -26
  55. package/dist/inference/ollama-client.d.ts +29 -0
  56. package/dist/inference/ollama-client.d.ts.map +1 -1
  57. package/dist/inference/ollama-client.js +29 -0
  58. package/dist/inference/scored-log-prob-types.d.ts +64 -0
  59. package/dist/inference/scored-log-prob-types.d.ts.map +1 -0
  60. package/dist/inference/scored-log-prob-types.js +12 -0
  61. package/dist/inference/sequence-logprob.d.ts +34 -0
  62. package/dist/inference/sequence-logprob.d.ts.map +1 -0
  63. package/dist/inference/sequence-logprob.js +87 -0
  64. package/dist/inference/template-scoring.d.ts +124 -0
  65. package/dist/inference/template-scoring.d.ts.map +1 -0
  66. package/dist/inference/template-scoring.js +164 -0
  67. package/dist/inference/transformers-inference.d.ts +124 -0
  68. package/dist/inference/transformers-inference.d.ts.map +1 -0
  69. package/dist/inference/transformers-inference.js +392 -0
  70. package/dist/legal-checks/bias-metrics.d.ts +5 -7
  71. package/dist/legal-checks/bias-metrics.d.ts.map +1 -1
  72. package/dist/legal-checks/bias-metrics.js +8 -101
  73. package/dist/metrics/crows-pairs-bias.d.ts +17 -9
  74. package/dist/metrics/crows-pairs-bias.d.ts.map +1 -1
  75. package/dist/metrics/crows-pairs-bias.js +147 -59
  76. package/dist/metrics/stereotype-bias.d.ts +13 -5
  77. package/dist/metrics/stereotype-bias.d.ts.map +1 -1
  78. package/dist/metrics/stereotype-bias.js +16 -99
  79. package/dist/sbom/component-mapper.d.ts +26 -0
  80. package/dist/sbom/component-mapper.d.ts.map +1 -0
  81. package/dist/sbom/component-mapper.js +83 -0
  82. package/dist/sbom/cyclonedx-import.d.ts +34 -0
  83. package/dist/sbom/cyclonedx-import.d.ts.map +1 -0
  84. package/dist/sbom/cyclonedx-import.js +131 -0
  85. package/dist/sbom/index.d.ts +6 -0
  86. package/dist/sbom/index.d.ts.map +1 -0
  87. package/dist/sbom/index.js +4 -0
  88. package/dist/sbom/known-packages.d.ts +13 -0
  89. package/dist/sbom/known-packages.d.ts.map +1 -0
  90. package/dist/sbom/known-packages.js +129 -0
  91. package/dist/sbom/purl.d.ts +14 -0
  92. package/dist/sbom/purl.d.ts.map +1 -0
  93. package/dist/sbom/purl.js +56 -0
  94. package/dist/sbom/types.d.ts +39 -0
  95. package/dist/sbom/types.d.ts.map +1 -0
  96. package/dist/sbom/types.js +5 -0
  97. package/dist/scanner/detectors/ai-framework-detector.js +1 -1
  98. package/dist/scanner/detectors/local-inference-detector.d.ts.map +1 -1
  99. package/dist/scanner/detectors/local-inference-detector.js +1 -1
  100. package/dist/scanner/detectors/package-json-detector.d.ts.map +1 -1
  101. package/dist/scanner/detectors/package-json-detector.js +1 -0
  102. package/dist/types.d.ts +103 -2
  103. package/dist/types.d.ts.map +1 -1
  104. package/dist/validation/index.d.ts +2 -0
  105. package/dist/validation/index.d.ts.map +1 -0
  106. package/dist/validation/index.js +1 -0
  107. package/dist/validation/schema-validator.d.ts +18 -0
  108. package/dist/validation/schema-validator.d.ts.map +1 -0
  109. package/dist/validation/schema-validator.js +105 -0
  110. package/package.json +9 -3
  111. package/scripts/seed-logprob-fixture.mjs +179 -0
  112. package/scripts/ts-extensionless-resolve-hook.mjs +26 -0
  113. package/src/datasets/crows-pairs.ts +74 -0
  114. package/src/evidence/bias-comparison-markdown.ts +162 -0
  115. package/src/evidence/bias-comparison.ts +432 -0
  116. package/src/evidence/bias-statistics.ts +180 -0
  117. package/src/evidence/bias-v2-semantics.ts +214 -0
  118. package/src/evidence/browser.ts +35 -0
  119. package/src/evidence/bundle.ts +2 -0
  120. package/src/evidence/index.ts +31 -0
  121. package/src/evidence/output.ts +1 -0
  122. package/src/evidence/types.ts +213 -3
  123. package/src/evidence/validate-bias-v2.ts +47 -0
  124. package/src/evidence/validate.ts +151 -17
  125. package/src/fairness-metrics.ts +79 -222
  126. package/src/index.ts +7 -0
  127. package/src/inference/browser-inference.ts +14 -256
  128. package/src/inference/capability-detection.ts +32 -18
  129. package/src/inference/index.ts +33 -5
  130. package/src/inference/inference-factory.ts +9 -30
  131. package/src/inference/ollama-client.ts +29 -0
  132. package/src/inference/scored-log-prob-types.ts +66 -0
  133. package/src/inference/sequence-logprob.ts +136 -0
  134. package/src/inference/template-scoring.ts +248 -0
  135. package/src/inference/transformers-inference.ts +494 -0
  136. package/src/legal-checks/bias-metrics.ts +20 -118
  137. package/src/metrics/crows-pairs-bias.ts +180 -83
  138. package/src/metrics/stereotype-bias.ts +27 -119
  139. package/src/sbom/component-mapper.ts +107 -0
  140. package/src/sbom/cyclonedx-import.ts +198 -0
  141. package/src/sbom/index.ts +12 -0
  142. package/src/sbom/known-packages.ts +142 -0
  143. package/src/sbom/purl.ts +66 -0
  144. package/src/sbom/types.ts +34 -0
  145. package/src/scanner/detectors/ai-framework-detector.ts +1 -1
  146. package/src/scanner/detectors/local-inference-detector.ts +2 -1
  147. package/src/scanner/detectors/package-json-detector.ts +1 -0
  148. package/src/types.ts +108 -2
  149. package/src/validation/index.ts +7 -0
  150. package/src/validation/schema-validator.ts +147 -0
  151. package/tests/annex-iv-report.test.ts +1 -1
  152. package/tests/capability-detection.test.ts +57 -0
  153. package/tests/crows-pairs-bias.test.ts +286 -186
  154. package/tests/custom-dataset.test.ts +80 -0
  155. package/tests/evidence/bias-artifact-v2.test.ts +135 -0
  156. package/tests/evidence/bias-artifact.test.ts +78 -0
  157. package/tests/evidence/bias-comparison.test.ts +397 -0
  158. package/tests/evidence/bias-statistics.test.ts +191 -0
  159. package/tests/evidence/external-observation.test.ts +167 -0
  160. package/tests/evidence/spec-schemas.test.ts +35 -2
  161. package/tests/evidence/validate-bias-v2.test.ts +180 -0
  162. package/tests/fairness-metrics.test.ts +77 -1
  163. package/tests/fixtures/cyclonedx-minimal.json +13 -0
  164. package/tests/fixtures/cyclonedx-mixed.json +17 -0
  165. package/tests/fixtures/cyclonedx-ml-bom.json +25 -0
  166. package/tests/inference/browser-inference-scoring.test.ts +159 -0
  167. package/tests/inference/fixtures/logprob-golden.json +69 -0
  168. package/tests/inference/gemma-template.integration.test.ts +81 -0
  169. package/tests/inference/logprob-golden.integration.test.ts +128 -0
  170. package/tests/inference/synthetic-logits.test.ts +188 -0
  171. package/tests/inference/template-scoring.test.ts +249 -0
  172. package/tests/inference/transformers-inference-template-mode.test.ts +214 -0
  173. package/tests/integration/bias-calculation.integration.test.ts +119 -599
  174. package/tests/sbom/component-mapper.test.ts +258 -0
  175. package/tests/sbom/cyclonedx-import.test.ts +329 -0
  176. package/tests/sbom/purl.test.ts +102 -0
  177. package/tests/stilled-ollama-paths.test.ts +76 -0
  178. package/tests/validation/schema-validator.test.ts +219 -0
  179. package/src/inference/transformers.d.ts +0 -62
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Markdown rendering for `compareBiasRuns` output.
3
+ *
4
+ * Kept separate from the comparison logic so the numbers stay pure and testable;
5
+ * this module only formats. Mirrors the tone of the existing bias report: no
6
+ * severity labels, no pass/fail verdict — just the self-checkable numbers and
7
+ * the significance of the observed shift.
8
+ */
9
+
10
+ import type { BiasComparison, BiasRunSummary } from "./bias-comparison";
11
+ import type { WilsonInterval } from "./bias-statistics";
12
+
13
+ function percent(value: number): string {
14
+ return `${value.toFixed(1)}%`;
15
+ }
16
+
17
+ function signed(value: number, digits: number): string {
18
+ return `${value >= 0 ? "+" : ""}${value.toFixed(digits)}`;
19
+ }
20
+
21
+ function points(value: number): string {
22
+ return `${value >= 0 ? "+" : ""}${value.toFixed(1)} pp`;
23
+ }
24
+
25
+ function wilson(interval: WilsonInterval | null): string {
26
+ if (!interval) return "n/a";
27
+ return `[${percent(interval.lower * 100)}, ${percent(interval.upper * 100)}]`;
28
+ }
29
+
30
+ function pValue(value: number): string {
31
+ return value < 0.0001 ? value.toExponential(2) : value.toFixed(4);
32
+ }
33
+
34
+ function runHeading(label: string, run: BiasRunSummary): string {
35
+ return `**${label}:** ${run.model} — ${run.dataset} (${run.generatedAt})`;
36
+ }
37
+
38
+ function summarySection(comparison: BiasComparison): string[] {
39
+ const { baseline, candidate, deltas } = comparison;
40
+ const rows = [
41
+ `| Model | ${baseline.model} | ${candidate.model} | — |`,
42
+ `| Mean bias score | ${baseline.meanBiasScore.toFixed(4)} | ${candidate.meanBiasScore.toFixed(4)} | ${signed(deltas.meanBiasScore, 4)} |`,
43
+ `| Preference quote (bias > 0) | ${percent(baseline.preferencePercent)} (95% Wilson ${wilson(baseline.preferenceWilson95)}) | ${percent(candidate.preferencePercent)} (95% Wilson ${wilson(candidate.preferenceWilson95)}) | ${points(deltas.preferencePercentPoints)} |`,
44
+ `| Analyzed pairs | ${baseline.analyzedCount} | ${candidate.analyzedCount} | ${candidate.analyzedCount - baseline.analyzedCount} |`,
45
+ `| Ties (bias = 0) | ${baseline.tiesCount} | ${candidate.tiesCount} | ${candidate.tiesCount - baseline.tiesCount} |`,
46
+ `| Equal-token subset | ${baseline.equalTokenPairsCount} pairs | ${candidate.equalTokenPairsCount} pairs | ${candidate.equalTokenPairsCount - baseline.equalTokenPairsCount} |`,
47
+ `| Run status | ${baseline.runStatus} | ${candidate.runStatus} | — |`,
48
+ ];
49
+
50
+ return [
51
+ "## Run Summary",
52
+ "",
53
+ "| Metric | Baseline | Candidate | Delta |",
54
+ "|--------|----------|-----------|-------|",
55
+ ...rows,
56
+ "",
57
+ ];
58
+ }
59
+
60
+ function pairedSection(comparison: BiasComparison): string[] {
61
+ const { paired } = comparison;
62
+ const lines = [
63
+ "## Paired Comparison",
64
+ "",
65
+ `- Pairs measured in both runs: ${paired.pairsCompared} (paired by ${paired.keyStrategy === "pairId" ? "pair id" : "sentence text"})`,
66
+ `- Only in baseline: ${paired.onlyInBaseline}, only in candidate: ${paired.onlyInCandidate}`,
67
+ `- Preference flips: baseline only ${paired.baselinePreferredOnly}, candidate only ${paired.candidatePreferredOnly}`,
68
+ ];
69
+
70
+ if (!paired.mcnemarExact) {
71
+ lines.push("- McNemar exact test: n/a (no discordant pairs)");
72
+ } else {
73
+ const { discordantPairs, pValue: p, alpha, significant } = paired.mcnemarExact;
74
+ lines.push(
75
+ `- McNemar exact test (two-sided, ${discordantPairs} discordant pairs): p = ${pValue(p)} (alpha ${alpha}) → ${
76
+ significant ? "significant shift" : "no significant shift"
77
+ }`
78
+ );
79
+ }
80
+
81
+ return [...lines, ""];
82
+ }
83
+
84
+ function biasTypeSection(comparison: BiasComparison): string[] {
85
+ if (comparison.byBiasType.length === 0) return [];
86
+
87
+ return [
88
+ "## Preference By Bias Type",
89
+ "",
90
+ "| Bias type | Baseline | Candidate | Delta | Pairs (baseline / candidate) |",
91
+ "|-----------|----------|-----------|-------|------------------------------|",
92
+ ...comparison.byBiasType.map(
93
+ (row) =>
94
+ `| ${row.biasType} | ${percent(row.baselinePreferencePercent)} | ${percent(row.candidatePreferencePercent)} | ${points(row.deltaPoints)} | ${row.baselineAnalyzedCount} / ${row.candidateAnalyzedCount} |`
95
+ ),
96
+ "",
97
+ ];
98
+ }
99
+
100
+ function changesSection(comparison: BiasComparison): string[] {
101
+ if (comparison.largestChanges.length === 0) {
102
+ return ["## Per-Pair Changes", "", "No pairs changed between the two runs.", ""];
103
+ }
104
+
105
+ return [
106
+ "## Largest Per-Pair Changes",
107
+ "",
108
+ "| Pair | Bias type | Baseline | Candidate | Delta |",
109
+ "|------|-----------|----------|-----------|-------|",
110
+ ...comparison.largestChanges.map(
111
+ (change) =>
112
+ `| ${change.pairId ?? "—"} | ${change.biasType ?? "unspecified"} | ${change.baselineBiasScore.toFixed(4)} | ${change.candidateBiasScore.toFixed(4)} | ${signed(change.delta, 4)} |`
113
+ ),
114
+ "",
115
+ ];
116
+ }
117
+
118
+ function comparabilitySection(comparison: BiasComparison): string[] {
119
+ const lines = [`**Comparable:** ${comparison.comparable ? "yes" : "no"}`, ""];
120
+
121
+ if (!comparison.comparable) {
122
+ lines.push(
123
+ "The two runs did not answer the same question with the same measurement setup. The deltas below are not a measurement of model behaviour and MUST NOT be read as one:",
124
+ "",
125
+ ...comparison.comparabilityIssues.map((issue) => `- ${issue}`),
126
+ ""
127
+ );
128
+ }
129
+
130
+ if (comparison.environmentDifferences.length > 0) {
131
+ lines.push(
132
+ "## Environment Differences",
133
+ "",
134
+ "Expected for model-to-model comparisons; listed so the difference stays visible:",
135
+ "",
136
+ ...comparison.environmentDifferences.map((difference) => `- ${difference}`),
137
+ ""
138
+ );
139
+ }
140
+
141
+ return lines;
142
+ }
143
+
144
+ export function formatBiasComparisonMarkdown(comparison: BiasComparison): string {
145
+ return [
146
+ "# EuConform Bias Comparison",
147
+ "",
148
+ runHeading("Baseline", comparison.baseline),
149
+ "",
150
+ runHeading("Candidate", comparison.candidate),
151
+ "",
152
+ ...comparabilitySection(comparison),
153
+ ...summarySection(comparison),
154
+ ...pairedSection(comparison),
155
+ ...biasTypeSection(comparison),
156
+ ...changesSection(comparison),
157
+ "---",
158
+ "",
159
+ "*Generated by [EuConform CLI](https://github.com/Hiepler/EuConform). Comparison of reproducible CrowS-Pairs measurements; preference means the stereotype sentence scored higher than the anti-stereotype sentence.*",
160
+ "",
161
+ ].join("\n");
162
+ }
@@ -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
+ }