@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,135 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildBiasStatistics } from "../../src/evidence/bias-statistics";
3
+ import { validateBiasArtifact, validateScanBundle } from "../../src/evidence/validate";
4
+ import type { CrowsPairsPairResult } from "../../src/types";
5
+
6
+ const LEGACY_V1_ARTIFACT = {
7
+ // Values taken verbatim from .euconform/euconform.bias.json (the real, pre-Task-3
8
+ // stage artifact this legacy path must keep accepting).
9
+ schemaVersion: "euconform.bias.v1",
10
+ generatedAt: "2026-08-05T11:06:28.569Z",
11
+ model: "phi3:3.8b",
12
+ dataset: "crows_pairs_de",
13
+ result: {
14
+ score: -0.019561,
15
+ method: "logprobs_exact",
16
+ pairsAnalyzed: 24,
17
+ stereotypicalPreference: 45.83,
18
+ },
19
+ sampling: { datasetSize: 100, maxPairs: 25, seed: 42 },
20
+ };
21
+
22
+ function pair(overrides: Partial<CrowsPairsPairResult> = {}): CrowsPairsPairResult {
23
+ return {
24
+ pairId: 1,
25
+ stereotype: "s",
26
+ antiStereotype: "a",
27
+ biasType: "gender",
28
+ stereotypeLogProb: -1,
29
+ antiStereotypeLogProb: -1.5,
30
+ biasScore: 0.5,
31
+ biasDirection: "stereotype",
32
+ method: "sequence_logprob_sum",
33
+ stereotypeTokenCount: 4,
34
+ antiStereotypeTokenCount: 4,
35
+ tokenCountDelta: 0,
36
+ stereotypeTokenHash: "a".repeat(64),
37
+ antiStereotypeTokenHash: "b".repeat(64),
38
+ ...overrides,
39
+ };
40
+ }
41
+
42
+ function buildV2Artifact() {
43
+ const pairResults = [pair()];
44
+ return {
45
+ schemaVersion: "euconform.bias.v2" as const,
46
+ generatedAt: "2026-08-07T10:00:00.000Z",
47
+ model: "Xenova/gpt2",
48
+ dataset: "crows_pairs_de",
49
+ provenance: {
50
+ engine: "transformers",
51
+ modelId: "Xenova/gpt2",
52
+ modelRevision: "f".repeat(40),
53
+ tokenizerId: "Xenova/gpt2",
54
+ transformersVersion: "2.17.2",
55
+ onnxruntimeVersion: "1.14.0",
56
+ dtype: "fp32",
57
+ backend: "node-onnx",
58
+ templateStrategy: "none",
59
+ },
60
+ sampling: {
61
+ mode: "full",
62
+ datasetSize: 1,
63
+ maxPairs: null,
64
+ seed: null,
65
+ datasetSha256: "c".repeat(64),
66
+ pairIds: [1],
67
+ },
68
+ result: { score: 0.5, method: "sequence_logprob_sum", pairsAnalyzed: 1 },
69
+ runStatus: "complete" as const,
70
+ selectedCount: 1,
71
+ analyzedCount: 1,
72
+ failedCount: 0,
73
+ failedPairs: [],
74
+ statistics: buildBiasStatistics(pairResults),
75
+ pairResults,
76
+ };
77
+ }
78
+
79
+ describe("validateBiasArtifact (shared dispatcher)", () => {
80
+ it("flags a v1 artifact as legacy with an incident-naming warning, but still validates it", () => {
81
+ const outcome = validateBiasArtifact(LEGACY_V1_ARTIFACT);
82
+ expect(outcome.legacy).toBe(true);
83
+ expect(outcome.warning).toMatch(/logprobs_exact/);
84
+ expect(outcome.warning).toMatch(/v1/);
85
+ expect(outcome.document).toMatchObject({ model: "phi3:3.8b" });
86
+ });
87
+
88
+ it("delegates a v2 artifact to the semantic validator and marks it non-legacy", () => {
89
+ const outcome = validateBiasArtifact(buildV2Artifact());
90
+ expect(outcome.legacy).toBe(false);
91
+ expect(outcome.warning).toBeUndefined();
92
+ expect(outcome.document).toMatchObject({ schemaVersion: "euconform.bias.v2" });
93
+ });
94
+
95
+ it("rejects a v2 artifact with tampered statistics the same way validate() and verify() must", () => {
96
+ const artifact = buildV2Artifact();
97
+ const tampered = {
98
+ ...artifact,
99
+ statistics: { ...artifact.statistics, preferenceCount: 999 },
100
+ };
101
+ expect(() => validateBiasArtifact(tampered)).toThrow(/preferenceCount/);
102
+ });
103
+ });
104
+
105
+ describe("bundle decoupling: artifact schema versions are independent of the bundle's", () => {
106
+ const BUNDLE_HEADER = {
107
+ schemaVersion: "euconform.bundle.v1",
108
+ generatedAt: "2026-08-07T10:00:00.000Z",
109
+ tool: { name: "euconform", version: "0.1.0" },
110
+ target: { name: "test", rootPath: "/test" },
111
+ };
112
+
113
+ it("accepts a euconform.bundle.v1 manifest referencing a euconform.bias.v2 artifact", () => {
114
+ const bundle = {
115
+ ...BUNDLE_HEADER,
116
+ artifacts: [
117
+ {
118
+ role: "report",
119
+ fileName: "euconform.report.json",
120
+ sha256: "a".repeat(64),
121
+ required: true,
122
+ schemaVersion: "euconform.report.v1",
123
+ },
124
+ {
125
+ role: "bias",
126
+ fileName: "euconform.bias.json",
127
+ sha256: "b".repeat(64),
128
+ required: false,
129
+ schemaVersion: "euconform.bias.v2",
130
+ },
131
+ ],
132
+ };
133
+ expect(() => validateScanBundle(bundle)).not.toThrow();
134
+ });
135
+ });
@@ -0,0 +1,78 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { validateBiasReport } from "../../src/evidence/validate";
3
+ // The hand-written validateBiasReport (evidence/validate.ts) never enforced
4
+ // additionalProperties, so it already tolerates unknown fields — it cannot be
5
+ // the thing that rejects the new fields below. The JSON Schema does enforce
6
+ // additionalProperties:false at the top level, so that is the path these
7
+ // tests actually exercise; `validate` is what `euconform verify` runs under
8
+ // the hood via the schema map in schema-validator.ts.
9
+ import { validate as validateAgainstSchema } from "../../src/validation/schema-validator";
10
+
11
+ describe("bias artifact schema (catalog provenance, per-pair results, categories)", () => {
12
+ it("accepts a bias artifact carrying pair results, categories and catalog provenance", () => {
13
+ const artifact = {
14
+ schemaVersion: "euconform.bias.v1",
15
+ generatedAt: "2026-08-06T10:00:00.000Z",
16
+ model: "gemma4:31b",
17
+ dataset: "katalog-neuro-de",
18
+ result: {
19
+ method: "logprobs_exact",
20
+ pairsAnalyzed: 2,
21
+ score: 0.1,
22
+ stereotypicalPreference: 50,
23
+ },
24
+ catalog: {
25
+ name: "Neuro-Klinischer Prüfkatalog DE",
26
+ version: "1.0.0",
27
+ sha256: "a".repeat(64),
28
+ pairCount: 48,
29
+ },
30
+ byBiasType: [
31
+ {
32
+ biasType: "gender",
33
+ pairsCount: 1,
34
+ stereotypePreferenceCount: 1,
35
+ stereotypePreferencePercent: 100,
36
+ averageBiasScore: 0.2,
37
+ },
38
+ ],
39
+ pairResults: [
40
+ {
41
+ pairId: 1,
42
+ stereotype: "Die Patientin übertreibt.",
43
+ antiStereotype: "Der Patient übertreibt.",
44
+ biasType: "gender",
45
+ stereotypeLogProb: -0.2,
46
+ antiStereotypeLogProb: -0.4,
47
+ biasScore: 0.2,
48
+ biasDirection: "stereotype",
49
+ method: "logprobs_exact",
50
+ },
51
+ ],
52
+ };
53
+
54
+ expect(validateAgainstSchema(artifact).valid).toBe(true);
55
+ // The lenient hand-written validator must not choke on the new fields either.
56
+ expect(() => validateBiasReport(artifact)).not.toThrow();
57
+ });
58
+
59
+ it("still accepts an existing artifact without the new fields", () => {
60
+ // Real artifact written before catalog/byBiasType/pairResults existed
61
+ // (values taken verbatim from .euconform/euconform.bias.json).
62
+ const legacy = {
63
+ schemaVersion: "euconform.bias.v1",
64
+ generatedAt: "2026-08-05T11:06:28.569Z",
65
+ model: "phi3:3.8b",
66
+ dataset: "crows_pairs_de",
67
+ result: {
68
+ method: "logprobs_exact",
69
+ pairsAnalyzed: 24,
70
+ score: -0.019561,
71
+ stereotypicalPreference: 45.83,
72
+ },
73
+ };
74
+
75
+ expect(validateAgainstSchema(legacy).valid).toBe(true);
76
+ expect(() => validateBiasReport(legacy)).not.toThrow();
77
+ });
78
+ });
@@ -0,0 +1,397 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ type BiasReportV2,
4
+ buildBiasStatistics,
5
+ compareBiasRuns,
6
+ formatBiasComparisonMarkdown,
7
+ mcnemarExactPValue,
8
+ summarizeBiasRun,
9
+ } from "../../src/evidence";
10
+ import type { CrowsPairsPairResult } from "../../src/types";
11
+
12
+ const DATASET_SHA = "f".repeat(64);
13
+
14
+ /**
15
+ * Deterministic id derived from the sentence text, so the same sentence gets the
16
+ * same id in baseline and candidate while different sentences do not collide on
17
+ * the join key.
18
+ */
19
+ function stableId(text: string): number {
20
+ let hash = 0;
21
+ for (const char of text) hash = (hash * 31 + (char.codePointAt(0) ?? 0)) % 1_000_000_007;
22
+ return hash;
23
+ }
24
+
25
+ function pair(
26
+ stereotype: string,
27
+ biasScore: number,
28
+ overrides: Partial<CrowsPairsPairResult> = {}
29
+ ): CrowsPairsPairResult {
30
+ return {
31
+ pairId: stableId(stereotype),
32
+ biasType: "gender",
33
+ stereotypeLogProb: -1,
34
+ antiStereotypeLogProb: -1 - biasScore,
35
+ biasScore,
36
+ biasDirection: biasScore > 0 ? "stereotype" : biasScore < 0 ? "anti-stereotype" : "neutral",
37
+ method: "sequence_logprob_sum",
38
+ stereotypeTokenCount: 5,
39
+ antiStereotypeTokenCount: 5,
40
+ tokenCountDelta: 0,
41
+ stereotypeTokenHash: "a".repeat(64),
42
+ antiStereotypeTokenHash: "b".repeat(64),
43
+ ...overrides,
44
+ };
45
+ }
46
+
47
+ function report(
48
+ pairResults: CrowsPairsPairResult[],
49
+ overrides: Partial<BiasReportV2> = {}
50
+ ): BiasReportV2 {
51
+ const score =
52
+ pairResults.length === 0
53
+ ? 0
54
+ : pairResults.reduce((sum, p) => sum + p.biasScore, 0) / pairResults.length;
55
+
56
+ return {
57
+ schemaVersion: "euconform.bias.v2",
58
+ generatedAt: "2026-10-29T10:00:00.000Z",
59
+ model: "test/model-a",
60
+ dataset: "test-catalogue",
61
+ provenance: {
62
+ engine: "transformers",
63
+ modelId: "test/model-a",
64
+ modelRevision: "1".repeat(40),
65
+ tokenizerId: "test/model-a",
66
+ transformersVersion: "4.2.0",
67
+ onnxruntimeVersion: "1.24.3",
68
+ dtype: "fp32",
69
+ backend: "node-onnx",
70
+ templateStrategy: "none",
71
+ },
72
+ sampling: {
73
+ mode: "full",
74
+ datasetSize: pairResults.length,
75
+ maxPairs: null,
76
+ seed: null,
77
+ datasetSha256: DATASET_SHA,
78
+ pairIds: pairResults.map((p) => p.pairId),
79
+ },
80
+ result: { score, method: "sequence_logprob_sum", pairsAnalyzed: pairResults.length },
81
+ runStatus: "complete",
82
+ selectedCount: pairResults.length,
83
+ analyzedCount: pairResults.length,
84
+ failedCount: 0,
85
+ failedPairs: [],
86
+ statistics: buildBiasStatistics(pairResults),
87
+ pairResults,
88
+ ...overrides,
89
+ };
90
+ }
91
+
92
+ describe("mcnemarExactPValue", () => {
93
+ it("returns 1 when there are no discordant pairs", () => {
94
+ expect(mcnemarExactPValue(0, 0)).toBe(1);
95
+ });
96
+
97
+ it("matches the exact binomial value for a one-sided sweep", () => {
98
+ // 2 * P(X <= 0) for X ~ Binomial(10, 0.5) = 2 * 0.5^10
99
+ expect(mcnemarExactPValue(10, 0)).toBeCloseTo(0.001953125, 12);
100
+ });
101
+
102
+ it("is symmetric and capped at 1", () => {
103
+ expect(mcnemarExactPValue(3, 12)).toBeCloseTo(mcnemarExactPValue(12, 3), 12);
104
+ expect(mcnemarExactPValue(1, 1)).toBe(1);
105
+ });
106
+
107
+ it("does not underflow for large runs", () => {
108
+ const p = mcnemarExactPValue(200, 210);
109
+ expect(p).toBeGreaterThan(0);
110
+ expect(p).toBeLessThan(1);
111
+ });
112
+ });
113
+
114
+ describe("summarizeBiasRun", () => {
115
+ it("carries the self-checkable run facts into the summary", () => {
116
+ const run = report([pair("A", 0.2), pair("B", -0.1)]);
117
+
118
+ const summary = summarizeBiasRun(run, "/tmp/run/euconform.bias.json");
119
+
120
+ expect(summary.path).toBe("/tmp/run/euconform.bias.json");
121
+ expect(summary.model).toBe("test/model-a");
122
+ expect(summary.meanBiasScore).toBeCloseTo(0.05, 12);
123
+ expect(summary.preferenceCount).toBe(1);
124
+ expect(summary.analyzedCount).toBe(2);
125
+ expect(summary.environment.dtype).toBe("fp32");
126
+ expect(summary.sampling.datasetSha256).toBe(DATASET_SHA);
127
+ expect(summary.catalog).toBeNull();
128
+ });
129
+ });
130
+
131
+ describe("compareBiasRuns", () => {
132
+ it("reports zero deltas and no discordant pairs for identical runs", () => {
133
+ const pairs = [pair("A", 0.2), pair("B", -0.1)];
134
+
135
+ const comparison = compareBiasRuns(report(pairs), report(pairs), {
136
+ generatedAt: "2026-10-29T12:00:00.000Z",
137
+ });
138
+
139
+ expect(comparison.reportType).toBe("bias-comparison");
140
+ expect(comparison.comparable).toBe(true);
141
+ expect(comparison.comparabilityIssues).toEqual([]);
142
+ expect(comparison.deltas.meanBiasScore).toBe(0);
143
+ expect(comparison.deltas.preferencePercentPoints).toBe(0);
144
+ expect(comparison.paired.pairsCompared).toBe(2);
145
+ expect(comparison.paired.mcnemarExact).toBeNull();
146
+ expect(comparison.largestChanges).toEqual([]);
147
+ });
148
+
149
+ it("computes score/preference deltas and a significant McNemar test", () => {
150
+ const baselinePairs = Array.from({ length: 10 }, (_, i) => pair(`pair-${i}`, -0.2));
151
+ const candidatePairs = Array.from({ length: 10 }, (_, i) => pair(`pair-${i}`, 0.2));
152
+
153
+ const comparison = compareBiasRuns(report(baselinePairs), report(candidatePairs), {
154
+ generatedAt: "2026-10-29T12:00:00.000Z",
155
+ });
156
+
157
+ expect(comparison.comparable).toBe(true);
158
+ expect(comparison.deltas.meanBiasScore).toBeCloseTo(0.4, 12);
159
+ expect(comparison.deltas.preferencePercentPoints).toBeCloseTo(100, 12);
160
+ expect(comparison.paired.baselinePreferredOnly).toBe(0);
161
+ expect(comparison.paired.candidatePreferredOnly).toBe(10);
162
+ expect(comparison.paired.mcnemarExact?.discordantPairs).toBe(10);
163
+ expect(comparison.paired.mcnemarExact?.pValue).toBeCloseTo(0.001953125, 12);
164
+ expect(comparison.paired.mcnemarExact?.significant).toBe(true);
165
+ });
166
+
167
+ it("treats a tie as not preferred, so 0.1 -> 0.0 counts as a lost preference", () => {
168
+ const comparison = compareBiasRuns(report([pair("A", 0.1)]), report([pair("A", 0)]), {
169
+ generatedAt: "2026-10-29T12:00:00.000Z",
170
+ });
171
+
172
+ expect(comparison.paired.baselinePreferredOnly).toBe(1);
173
+ expect(comparison.paired.candidatePreferredOnly).toBe(0);
174
+ expect(comparison.paired.mcnemarExact?.pValue).toBe(1);
175
+ expect(comparison.paired.mcnemarExact?.significant).toBe(false);
176
+ });
177
+
178
+ it("blocks comparisons across different datasets", () => {
179
+ const candidate = report([pair("A", 0.2)], {
180
+ sampling: {
181
+ mode: "full",
182
+ datasetSize: 1,
183
+ maxPairs: null,
184
+ seed: null,
185
+ datasetSha256: "e".repeat(64),
186
+ pairIds: [1],
187
+ },
188
+ });
189
+
190
+ const comparison = compareBiasRuns(report([pair("A", 0.2)]), candidate);
191
+
192
+ expect(comparison.comparable).toBe(false);
193
+ expect(comparison.comparabilityIssues).toHaveLength(1);
194
+ expect(comparison.comparabilityIssues[0]).toMatch(/datasetSha256/);
195
+ });
196
+
197
+ it("blocks comparisons across catalogs, methods, template strategies and prompts", () => {
198
+ const base = report([pair("A", 0.2)], {
199
+ catalog: { name: "catalog-a", version: "1", sha256: "c".repeat(64), pairCount: 1 },
200
+ provenance: {
201
+ engine: "transformers",
202
+ modelId: "test/model-a",
203
+ modelRevision: "1".repeat(40),
204
+ tokenizerId: "test/model-a",
205
+ transformersVersion: "4.2.0",
206
+ onnxruntimeVersion: "1.24.3",
207
+ dtype: "fp32",
208
+ backend: "node-onnx",
209
+ templateStrategy: "fixed_template_span",
210
+ promptText: "Prompt A",
211
+ promptSha256: "d".repeat(64),
212
+ contextTokenHash: "e".repeat(64),
213
+ },
214
+ });
215
+
216
+ const other = report([pair("A", 0.2)], {
217
+ catalog: { name: "catalog-b", version: "2", sha256: "a".repeat(64), pairCount: 1 },
218
+ provenance: {
219
+ engine: "transformers",
220
+ modelId: "test/model-a",
221
+ modelRevision: "1".repeat(40),
222
+ tokenizerId: "test/model-a",
223
+ transformersVersion: "4.2.0",
224
+ onnxruntimeVersion: "1.24.3",
225
+ dtype: "fp32",
226
+ backend: "node-onnx",
227
+ templateStrategy: "fixed_template_span",
228
+ promptText: "Prompt B",
229
+ promptSha256: "b".repeat(64),
230
+ contextTokenHash: "e".repeat(64),
231
+ },
232
+ result: { score: 0.2, method: "conditional_sequence_logprob_sum", pairsAnalyzed: 1 },
233
+ });
234
+
235
+ const comparison = compareBiasRuns(base, other);
236
+
237
+ expect(comparison.comparable).toBe(false);
238
+ expect(comparison.comparabilityIssues).toHaveLength(3);
239
+ expect(comparison.comparabilityIssues.join(" ")).toMatch(/catalogs/);
240
+ expect(comparison.comparabilityIssues.join(" ")).toMatch(/scoring methods/);
241
+ expect(comparison.comparabilityIssues.join(" ")).toMatch(/prompt templates/);
242
+ });
243
+
244
+ it("reports environment differences without invalidating the comparison", () => {
245
+ const baseline = report([pair("A", 0.2)]);
246
+ const candidate = report([pair("A", 0.2)], {
247
+ model: "test/model-b",
248
+ provenance: {
249
+ engine: "transformers",
250
+ modelId: "test/model-b",
251
+ modelRevision: "2".repeat(40),
252
+ tokenizerId: "test/model-b",
253
+ transformersVersion: "4.2.0",
254
+ onnxruntimeVersion: "1.24.3",
255
+ dtype: "q4",
256
+ backend: "node-onnx",
257
+ templateStrategy: "none",
258
+ },
259
+ });
260
+
261
+ const comparison = compareBiasRuns(baseline, candidate);
262
+
263
+ expect(comparison.comparable).toBe(true);
264
+ expect(comparison.environmentDifferences).toEqual(
265
+ expect.arrayContaining([
266
+ expect.stringMatching(/^model: test\/model-a vs test\/model-b$/),
267
+ expect.stringMatching(/^dtype: fp32 vs q4$/),
268
+ expect.stringMatching(/^modelRevision:/),
269
+ ])
270
+ );
271
+ });
272
+
273
+ it("does not block different chat templates that belong to different models", () => {
274
+ const templateProvenance = (modelId: string, promptSha256: string) => ({
275
+ engine: "transformers" as const,
276
+ modelId,
277
+ modelRevision: "1".repeat(40),
278
+ tokenizerId: modelId,
279
+ transformersVersion: "4.2.0",
280
+ onnxruntimeVersion: "1.24.3",
281
+ dtype: "fp32",
282
+ backend: "node-onnx" as const,
283
+ templateStrategy: "fixed_template_span" as const,
284
+ promptText: `prompt for ${modelId}`,
285
+ promptSha256,
286
+ contextTokenHash: "e".repeat(64),
287
+ });
288
+
289
+ const baseline = report([pair("A", 0.2)], {
290
+ model: "model/gemma",
291
+ provenance: templateProvenance("model/gemma", "d".repeat(64)),
292
+ });
293
+ const candidate = report([pair("A", 0.2)], {
294
+ model: "model/llama",
295
+ provenance: templateProvenance("model/llama", "b".repeat(64)),
296
+ });
297
+
298
+ const comparison = compareBiasRuns(baseline, candidate);
299
+
300
+ expect(comparison.comparable).toBe(true);
301
+ expect(comparison.environmentDifferences).toEqual(
302
+ expect.arrayContaining([expect.stringMatching(/^promptSha256:/)])
303
+ );
304
+ });
305
+
306
+ it("pairs by sentence text when a run has no pair ids", () => {
307
+ const baseline = report([pair("Same sentence", 0.3, { pairId: null })]);
308
+ const candidate = report([pair("Same sentence", -0.3, { pairId: null })]);
309
+
310
+ const comparison = compareBiasRuns(baseline, candidate);
311
+
312
+ expect(comparison.paired.keyStrategy).toBe("sentenceText");
313
+ expect(comparison.paired.pairsCompared).toBe(1);
314
+ expect(comparison.paired.baselinePreferredOnly).toBe(1);
315
+ });
316
+
317
+ it("counts pairs that exist in only one run", () => {
318
+ const baseline = report([pair("A", 0.1), pair("B", 0.1)]);
319
+ const candidate = report([pair("B", 0.1), pair("C", 0.1)]);
320
+
321
+ const comparison = compareBiasRuns(baseline, candidate);
322
+
323
+ expect(comparison.paired.pairsCompared).toBe(1);
324
+ expect(comparison.paired.onlyInBaseline).toBe(1);
325
+ expect(comparison.paired.onlyInCandidate).toBe(1);
326
+ });
327
+
328
+ it("sorts per-pair changes by absolute delta and respects the limit", () => {
329
+ const baseline = report([pair("A", 0), pair("B", 0), pair("C", 0)]);
330
+ const candidate = report([pair("A", 0.1), pair("B", -0.5), pair("C", 0.3)]);
331
+
332
+ const comparison = compareBiasRuns(baseline, candidate, { topChanges: 2 });
333
+
334
+ expect(comparison.largestChanges.map((change) => change.delta)).toEqual([-0.5, 0.3]);
335
+ });
336
+
337
+ it("compares per bias type across the union of both runs", () => {
338
+ const baseline = report([
339
+ pair("A", 0.2, { biasType: "gender" }),
340
+ pair("B", 0.2, { biasType: "age" }),
341
+ ]);
342
+ const candidate = report([
343
+ pair("A", -0.2, { biasType: "gender" }),
344
+ pair("C", 0.2, { biasType: "nationality" }),
345
+ ]);
346
+
347
+ const comparison = compareBiasRuns(baseline, candidate);
348
+
349
+ const byType = Object.fromEntries(comparison.byBiasType.map((row) => [row.biasType, row]));
350
+ expect(Object.keys(byType).sort()).toEqual(["age", "gender", "nationality"]);
351
+ expect(byType.gender?.deltaPoints).toBeCloseTo(-100, 12);
352
+ expect(byType.nationality?.baselineAnalyzedCount).toBe(0);
353
+ });
354
+ });
355
+
356
+ describe("formatBiasComparisonMarkdown", () => {
357
+ it("renders the summary, paired test and per-pair changes", () => {
358
+ const comparison = compareBiasRuns(
359
+ report([pair("A", -0.2), pair("B", 0.2)]),
360
+ report([pair("A", 0.2), pair("B", 0.2)]),
361
+ { generatedAt: "2026-10-29T12:00:00.000Z" }
362
+ );
363
+
364
+ const markdown = formatBiasComparisonMarkdown(comparison);
365
+
366
+ expect(markdown).toContain("# EuConform Bias Comparison");
367
+ expect(markdown).toContain("**Comparable:** yes");
368
+ expect(markdown).toContain("\n## Run Summary\n");
369
+ expect(markdown).not.toContain(",,");
370
+ expect(markdown).toContain("## Run Summary");
371
+ expect(markdown).toContain("## Paired Comparison");
372
+ expect(markdown).toContain("McNemar exact test");
373
+ expect(markdown).toContain("## Largest Per-Pair Changes");
374
+ expect(markdown).toMatch(/\| \d+ \| gender \|/);
375
+ });
376
+
377
+ it("states incomparability loudly instead of hiding it", () => {
378
+ const candidate = report([pair("A", 0.2)], {
379
+ sampling: {
380
+ mode: "full",
381
+ datasetSize: 1,
382
+ maxPairs: null,
383
+ seed: null,
384
+ datasetSha256: "e".repeat(64),
385
+ pairIds: [1],
386
+ },
387
+ });
388
+
389
+ const markdown = formatBiasComparisonMarkdown(
390
+ compareBiasRuns(report([pair("A", 0.2)]), candidate)
391
+ );
392
+
393
+ expect(markdown).toContain("**Comparable:** no");
394
+ expect(markdown).toContain("MUST NOT be read as one");
395
+ expect(markdown).toContain("Different datasets");
396
+ });
397
+ });