@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,191 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ WILSON_Z95,
4
+ buildBiasStatistics,
5
+ quartilesType7,
6
+ wilson95,
7
+ } from "../../src/evidence/bias-statistics";
8
+ import type { CrowsPairsPairResult } from "../../src/types";
9
+
10
+ /**
11
+ * Hand-derived anchors (Messmotor-Sanierung Task 3 brief, ambiguity resolution #1:
12
+ * z = 1.959963984540054 for the 95% Wilson interval, the SAME constant the
13
+ * producer and the semantic validator must both use — a mismatch between the two
14
+ * would make every artifact invalid).
15
+ *
16
+ * Wilson score interval formula (Wilson 1927), for successes=x, n trials,
17
+ * p̂ = x/n:
18
+ *
19
+ * center = (p̂ + z²/(2n)) / (1 + z²/n)
20
+ * margin = z · sqrt(p̂(1-p̂)/n + z²/(4n²)) / (1 + z²/n)
21
+ * [lower, upper] = [center - margin, center + margin]
22
+ *
23
+ * wilson95(8, 10): p̂ = 0.8, n = 10, z = 1.959963984540054
24
+ * z² = 3.8414588206941254
25
+ * 1+z²/n = 1.3841458820694126
26
+ * center = (0.8 + 3.8414588206941254/20) / 1.3841458820694126
27
+ * = (0.8 + 0.19207294103470627) / 1.3841458820694126
28
+ * = 0.9920729410347063 / 1.3841458820694126
29
+ * = 0.7167401600411333
30
+ * margin = 1.959963984540054 · sqrt(0.8*0.2/10 + 3.8414588206941254/400)
31
+ * / 1.3841458820694126
32
+ * = 1.959963984540054 · sqrt(0.016 + 0.009603647051735313)
33
+ * / 1.3841458820694126
34
+ * = 1.959963984540054 · sqrt(0.025603647051735313) / 1.3841458820694126
35
+ * = 1.959963984540054 · 0.16001139694798413 / 1.3841458820694126
36
+ * = 0.31358835220894676 / 1.3841458820694126
37
+ * = 0.22657768850449148
38
+ * lower = 0.7167401600411333 - 0.22657768850449148 = 0.49016247153664183
39
+ * upper = 0.7167401600411333 + 0.22657768850449148 = 0.9433178485456247
40
+ *
41
+ * (Verified independently via a standalone Node evaluation of the same formula,
42
+ * not via this module's implementation.)
43
+ */
44
+ describe("wilson95", () => {
45
+ it("matches the hand-derived interval for 8/10", () => {
46
+ expect(WILSON_Z95).toBeCloseTo(1.959963984540054, 15);
47
+ const interval = wilson95(8, 10);
48
+ expect(interval).not.toBeNull();
49
+ expect(interval?.lower).toBeCloseTo(0.49016247153664183, 12);
50
+ expect(interval?.upper).toBeCloseTo(0.9433178485456247, 12);
51
+ });
52
+
53
+ it("returns null for n === 0 (undefined/empty subset, e.g. no equal-token pairs)", () => {
54
+ expect(wilson95(0, 0)).toBeNull();
55
+ });
56
+
57
+ it("is symmetric around 0.5 for a 1/2 split", () => {
58
+ const interval = wilson95(1, 2);
59
+ expect(interval).not.toBeNull();
60
+ const center = ((interval?.lower ?? 0) + (interval?.upper ?? 0)) / 2;
61
+ expect(center).toBeCloseTo(0.5, 10);
62
+ });
63
+
64
+ it("rejects successes > n", () => {
65
+ expect(() => wilson95(11, 10)).toThrow();
66
+ });
67
+ });
68
+
69
+ /**
70
+ * quartilesType7([1, 2, 3, 4]): quantile type 7 (R default / numpy 'linear'),
71
+ * 0-indexed: h = (n-1)*p, value = v[floor(h)] + frac(h) * (v[floor(h)+1] - v[floor(h)]).
72
+ * n = 4, sorted values = [1, 2, 3, 4].
73
+ *
74
+ * q1 (p=0.25): h = 3*0.25 = 0.75 -> v[0] + 0.75*(v[1]-v[0]) = 1 + 0.75*1 = 1.75
75
+ * median (p=0.5): h = 3*0.5 = 1.5 -> v[1] + 0.5*(v[2]-v[1]) = 2 + 0.5*1 = 2.5
76
+ * q3 (p=0.75): h = 3*0.75 = 2.25 -> v[2] + 0.25*(v[3]-v[2]) = 3 + 0.25*1 = 3.25
77
+ * min = 1, max = 4
78
+ */
79
+ describe("quartilesType7", () => {
80
+ it("matches the hand-derived quartiles for [1, 2, 3, 4]", () => {
81
+ const q = quartilesType7([1, 2, 3, 4]);
82
+ expect(q).not.toBeNull();
83
+ expect(q?.min).toBe(1);
84
+ expect(q?.max).toBe(4);
85
+ expect(q?.q1).toBeCloseTo(1.75, 12);
86
+ expect(q?.median).toBeCloseTo(2.5, 12);
87
+ expect(q?.q3).toBeCloseTo(3.25, 12);
88
+ });
89
+
90
+ it("does not mutate its input and is order-independent", () => {
91
+ const values = [4, 1, 3, 2];
92
+ const snapshot = [...values];
93
+ const q = quartilesType7(values);
94
+ expect(values).toEqual(snapshot);
95
+ expect(q?.median).toBeCloseTo(2.5, 12);
96
+ });
97
+
98
+ it("returns min=max=median=q1=q3 for a single value", () => {
99
+ const q = quartilesType7([5]);
100
+ expect(q).toEqual({ min: 5, max: 5, median: 5, q1: 5, q3: 5 });
101
+ });
102
+
103
+ it("returns null for an empty array", () => {
104
+ expect(quartilesType7([])).toBeNull();
105
+ });
106
+ });
107
+
108
+ function pair(overrides: Partial<CrowsPairsPairResult>): CrowsPairsPairResult {
109
+ return {
110
+ pairId: null,
111
+ stereotype: "s",
112
+ antiStereotype: "a",
113
+ biasType: null,
114
+ stereotypeLogProb: 0,
115
+ antiStereotypeLogProb: 0,
116
+ biasScore: 0,
117
+ biasDirection: "neutral",
118
+ method: "sequence_logprob_sum",
119
+ stereotypeTokenCount: 3,
120
+ antiStereotypeTokenCount: 3,
121
+ tokenCountDelta: 0,
122
+ stereotypeTokenHash: "x",
123
+ antiStereotypeTokenHash: "y",
124
+ ...overrides,
125
+ };
126
+ }
127
+
128
+ describe("buildBiasStatistics", () => {
129
+ it("counts preference/ties from bias>0 and bias===0, with the matching Wilson interval", () => {
130
+ const pairResults = [
131
+ pair({ biasScore: 0.3, biasDirection: "stereotype" }),
132
+ pair({ biasScore: 0.1, biasDirection: "stereotype" }),
133
+ pair({ biasScore: -0.2, biasDirection: "anti-stereotype" }),
134
+ pair({ biasScore: 0, biasDirection: "neutral" }),
135
+ ];
136
+ const stats = buildBiasStatistics(pairResults);
137
+
138
+ expect(stats.analyzedCount).toBe(4);
139
+ expect(stats.preferenceCount).toBe(2); // bias > 0
140
+ expect(stats.tiesCount).toBe(1); // bias === 0
141
+ expect(stats.preferencePercent).toBeCloseTo((2 / 4) * 100, 12);
142
+ // preferencePercent is the UNROUNDED raw value (rounding only in Markdown).
143
+ const expected = wilson95(2, 4);
144
+ expect(stats.preferenceWilson95?.lower).toBeCloseTo(expected?.lower ?? Number.NaN, 12);
145
+ expect(stats.preferenceWilson95?.upper).toBeCloseTo(expected?.upper ?? Number.NaN, 12);
146
+ });
147
+
148
+ it("computes pairDiffQuartiles from the raw biasScore distribution", () => {
149
+ const pairResults = [1, 2, 3, 4].map((biasScore) => pair({ biasScore }));
150
+ const stats = buildBiasStatistics(pairResults);
151
+ expect(stats.pairDiffQuartiles).toEqual({ min: 1, max: 4, median: 2.5, q1: 1.75, q3: 3.25 });
152
+ });
153
+
154
+ it("groups byBiasType with its own counts/percent/Wilson interval", () => {
155
+ const pairResults = [
156
+ pair({ biasType: "gender", biasScore: 0.2 }),
157
+ pair({ biasType: "gender", biasScore: -0.1 }),
158
+ pair({ biasType: "age", biasScore: 0.5 }),
159
+ ];
160
+ const stats = buildBiasStatistics(pairResults);
161
+ const gender = stats.byBiasType.find((b) => b.biasType === "gender");
162
+ expect(gender?.analyzedCount).toBe(2);
163
+ expect(gender?.preferenceCount).toBe(1);
164
+ expect(gender?.preferencePercent).toBeCloseTo(50, 12);
165
+ });
166
+
167
+ it("returns null equalToken stats when no pairs share token length (empty subset)", () => {
168
+ const pairResults = [
169
+ pair({ tokenCountDelta: 1 }),
170
+ pair({ tokenCountDelta: -2 }),
171
+ pair({ tokenCountDelta: 3 }),
172
+ ];
173
+ const stats = buildBiasStatistics(pairResults);
174
+ expect(stats.equalTokenPairsCount).toBe(0);
175
+ expect(stats.equalTokenPreferencePercent).toBeNull();
176
+ expect(stats.equalTokenPreferenceWilson95).toBeNull();
177
+ });
178
+
179
+ it("computes equalToken preference stats only over the tokenCountDelta===0 subset", () => {
180
+ const pairResults = [
181
+ pair({ tokenCountDelta: 0, biasScore: 0.4 }), // equal-length, preferred
182
+ pair({ tokenCountDelta: 0, biasScore: -0.1 }), // equal-length, not preferred
183
+ pair({ tokenCountDelta: 2, biasScore: 0.9 }), // unequal-length, excluded
184
+ ];
185
+ const stats = buildBiasStatistics(pairResults);
186
+ expect(stats.equalTokenPairsCount).toBe(2);
187
+ expect(stats.equalTokenPreferenceCount).toBe(1);
188
+ expect(stats.equalTokenPreferencePercent).toBeCloseTo(50, 12);
189
+ expect(stats.equalTokenPreferenceWilson95).not.toBeNull();
190
+ });
191
+ });
@@ -0,0 +1,167 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ type ExternalObservation,
4
+ validateEcefJsonDocument,
5
+ validateExternalObservation,
6
+ } from "../../src/evidence";
7
+ import { validate } from "../../src/validation";
8
+
9
+ const SHA = "a".repeat(64);
10
+
11
+ function observation(overrides: Record<string, unknown> = {}): ExternalObservation {
12
+ return {
13
+ schemaVersion: "euconform.external-observation.v1",
14
+ observationId: "obs_01J9Z6K3Q8",
15
+ observedAt: "2026-10-29T12:00:00Z",
16
+ provider: {
17
+ name: "example-rights-observer",
18
+ version: "1.0.0",
19
+ endpoint: "https://api.example.com/v1/rights/preflight",
20
+ },
21
+ subject: {
22
+ kind: "url",
23
+ identifier: "https://example.com/dataset",
24
+ retrievedAt: "2026-10-29T11:59:41Z",
25
+ contentSha256: SHA,
26
+ },
27
+ observation: {
28
+ kind: "rights-declaration",
29
+ intendedUse: ["ai-input", "ai-train"],
30
+ result: "declared",
31
+ declaration: {
32
+ text: "AI training permitted for non-commercial research only.",
33
+ sha256: SHA,
34
+ url: "https://example.com/dataset/terms",
35
+ license: "CC-BY-NC-4.0",
36
+ },
37
+ },
38
+ limitations: ["Coverage limited to reviewed origins."],
39
+ ...overrides,
40
+ };
41
+ }
42
+
43
+ function schemaValid(document: unknown): boolean {
44
+ return validate(document).valid;
45
+ }
46
+
47
+ describe("euconform.external-observation.v1 schema", () => {
48
+ it("accepts a declared observation with a signature", () => {
49
+ const withSignature = observation({
50
+ signature: {
51
+ algorithm: "ed25519",
52
+ keyId: "provider-2026-01",
53
+ value: "base64-signature",
54
+ signedPayloadSha256: SHA,
55
+ },
56
+ });
57
+
58
+ expect(schemaValid(withSignature)).toBe(true);
59
+ expect(validate(withSignature).schemaType).toBe("external-observation.v1");
60
+ });
61
+
62
+ it("accepts 'unknown' without a declaration", () => {
63
+ const unknown = observation({
64
+ observation: { kind: "rights-declaration", intendedUse: ["ai-train"], result: "unknown" },
65
+ });
66
+
67
+ expect(schemaValid(unknown)).toBe(true);
68
+ });
69
+
70
+ it("requires the declaration when the result is 'declared'", () => {
71
+ const withoutDeclaration = observation({
72
+ observation: { kind: "rights-declaration", intendedUse: ["ai-train"], result: "declared" },
73
+ });
74
+
75
+ const result = validate(withoutDeclaration);
76
+ expect(result.valid).toBe(false);
77
+ expect(result.errors.some((e) => e.message.includes("declaration"))).toBe(true);
78
+ });
79
+
80
+ it("rejects permission-shaped results", () => {
81
+ const permitted = observation({
82
+ observation: { kind: "rights-declaration", intendedUse: ["ai-train"], result: "permitted" },
83
+ });
84
+
85
+ expect(schemaValid(permitted)).toBe(false);
86
+ });
87
+
88
+ it("rejects a permission field even though the rest is valid", () => {
89
+ const withPermission = { ...observation(), permission: true };
90
+
91
+ const result = validate(withPermission);
92
+ expect(result.valid).toBe(false);
93
+ expect(result.errors.some((e) => e.message.includes("additional properties"))).toBe(true);
94
+ });
95
+
96
+ it("requires at least one stated limitation", () => {
97
+ expect(schemaValid(observation({ limitations: [] }))).toBe(false);
98
+ expect(schemaValid(observation({ limitations: undefined }))).toBe(false);
99
+ });
100
+
101
+ it("rejects unknown schema versions", () => {
102
+ const result = validate({
103
+ ...observation(),
104
+ schemaVersion: "euconform.external-observation.v2",
105
+ });
106
+ expect(result.valid).toBe(false);
107
+ expect(result.errors[0]?.message).toMatch(/Unknown schemaVersion/);
108
+ });
109
+
110
+ it("requires the integrity fields of a present signature", () => {
111
+ const partialSignature = observation({
112
+ signature: { algorithm: "ed25519", keyId: "k", value: "v" },
113
+ });
114
+
115
+ expect(schemaValid(partialSignature)).toBe(false);
116
+ });
117
+ });
118
+
119
+ describe("validateExternalObservation", () => {
120
+ it("accepts a valid observation and returns it unchanged", () => {
121
+ const document = observation();
122
+ expect(validateExternalObservation(document)).toEqual(document);
123
+ });
124
+
125
+ it("rejects a missing provider name", () => {
126
+ const broken = observation({ provider: { version: "1.0.0" } });
127
+ expect(() => validateExternalObservation(broken)).toThrow(/provider\.name/);
128
+ });
129
+
130
+ it("rejects a missing subject retrieval timestamp", () => {
131
+ const broken = observation({
132
+ subject: { kind: "url", identifier: "https://example.com" },
133
+ });
134
+ expect(() => validateExternalObservation(broken)).toThrow(/subject\.retrievedAt/);
135
+ });
136
+
137
+ it("rejects a declared result without a checkable declaration", () => {
138
+ const broken = observation({
139
+ observation: { kind: "rights-declaration", intendedUse: ["ai-train"], result: "declared" },
140
+ });
141
+ expect(() => validateExternalObservation(broken)).toThrow(/observation\.declaration/);
142
+ });
143
+
144
+ it("rejects a declaration without its hash", () => {
145
+ const broken = observation({
146
+ observation: {
147
+ kind: "rights-declaration",
148
+ intendedUse: ["ai-train"],
149
+ result: "declared",
150
+ declaration: { text: "permitted" },
151
+ },
152
+ });
153
+ expect(() => validateExternalObservation(broken)).toThrow(/declaration\.sha256/);
154
+ });
155
+
156
+ it("explains why an observation without limitations is rejected", () => {
157
+ const broken = observation({ limitations: [] });
158
+ expect(() => validateExternalObservation(broken)).toThrow(/not reviewable/);
159
+ });
160
+
161
+ it("is reachable through the generic document dispatcher", () => {
162
+ const document = observation();
163
+ expect(validateEcefJsonDocument("euconform.external-observation.v1", document)).toEqual(
164
+ document
165
+ );
166
+ });
167
+ });
@@ -175,8 +175,9 @@ describe("EuConform Evidence Format schemas", () => {
175
175
  ]) as Array<[keyof typeof SCHEMA_FILES, JsonSchema]>;
176
176
 
177
177
  it("validates every documented example artifact set", () => {
178
+ // "external-observation" holds a standalone document, not a scan artifact set.
178
179
  const exampleDirs = readdirSync(EXAMPLES_DIR, { withFileTypes: true })
179
- .filter((entry) => entry.isDirectory())
180
+ .filter((entry) => entry.isDirectory() && entry.name !== "external-observation")
180
181
  .map((entry) => entry.name)
181
182
  .sort();
182
183
 
@@ -232,6 +233,38 @@ describe("EuConform Evidence Format schemas", () => {
232
233
  expect(errors.some((e) => e.includes("unknownField"))).toBe(true);
233
234
  });
234
235
 
236
+ it("validates the published external observation example", () => {
237
+ const schema = readJson<JsonSchema>(
238
+ resolve(SCHEMAS_DIR, "external-observation-v1.schema.json")
239
+ );
240
+ const example = readJson<Record<string, unknown>>(
241
+ resolve(EXAMPLES_DIR, "external-observation/observation.json")
242
+ );
243
+
244
+ expect(validateAgainstSchema(schema, schema, example)).toEqual([]);
245
+ expect(example.schemaVersion).toBe("euconform.external-observation.v1");
246
+ });
247
+
248
+ it("rejects an external observation that could be read as permission", () => {
249
+ const schema = readJson<JsonSchema>(
250
+ resolve(SCHEMAS_DIR, "external-observation-v1.schema.json")
251
+ );
252
+ const example = readJson<Record<string, unknown>>(
253
+ resolve(EXAMPLES_DIR, "external-observation/observation.json")
254
+ );
255
+
256
+ const withPermission = { ...example, permission: true };
257
+ const extraFieldErrors = validateAgainstSchema(schema, schema, withPermission);
258
+ expect(extraFieldErrors.some((error) => error.includes("permission"))).toBe(true);
259
+
260
+ const permittedResult = {
261
+ ...example,
262
+ observation: { ...(example.observation as Record<string, unknown>), result: "permitted" },
263
+ };
264
+ const badResultErrors = validateAgainstSchema(schema, schema, permittedResult);
265
+ expect(badResultErrors.some((error) => error.includes("result"))).toBe(true);
266
+ });
267
+
235
268
  it("rejects invalid enum values", () => {
236
269
  const ciSchema = schemaEntries.find(([kind]) => kind === "ci")?.[1];
237
270
  if (!ciSchema) throw new Error("ci schema not found");
@@ -255,7 +288,7 @@ describe("EuConform Evidence Format schemas", () => {
255
288
  } as const;
256
289
 
257
290
  const exampleDirs = readdirSync(EXAMPLES_DIR, { withFileTypes: true })
258
- .filter((entry) => entry.isDirectory())
291
+ .filter((entry) => entry.isDirectory() && entry.name !== "external-observation")
259
292
  .map((entry) => entry.name);
260
293
 
261
294
  for (const exampleDir of exampleDirs) {
@@ -0,0 +1,180 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildBiasStatistics } from "../../src/evidence/bias-statistics";
3
+ import { validateBiasReportV2 } from "../../src/evidence/validate-bias-v2";
4
+ import type { CrowsPairsPairResult } from "../../src/types";
5
+
6
+ function pair(overrides: Partial<CrowsPairsPairResult> = {}): CrowsPairsPairResult {
7
+ return {
8
+ pairId: 1,
9
+ stereotype: "Der Mann ist Ingenieur.",
10
+ antiStereotype: "Die Frau ist Ingenieurin.",
11
+ biasType: "gender",
12
+ stereotypeLogProb: -1.2,
13
+ antiStereotypeLogProb: -1.8,
14
+ biasScore: 0.6,
15
+ biasDirection: "stereotype",
16
+ method: "sequence_logprob_sum",
17
+ stereotypeTokenCount: 5,
18
+ antiStereotypeTokenCount: 5,
19
+ tokenCountDelta: 0,
20
+ stereotypeTokenHash: "a".repeat(64),
21
+ antiStereotypeTokenHash: "b".repeat(64),
22
+ ...overrides,
23
+ };
24
+ }
25
+
26
+ /** A minimal, internally-consistent euconform.bias.v2 artifact built from real pairResults. */
27
+ function buildValidArtifact(pairResults: CrowsPairsPairResult[]) {
28
+ const statistics = buildBiasStatistics(pairResults);
29
+ const score = pairResults.reduce((sum, p) => sum + p.biasScore, 0) / pairResults.length;
30
+ return {
31
+ schemaVersion: "euconform.bias.v2" as const,
32
+ generatedAt: "2026-08-07T10:00:00.000Z",
33
+ model: "Xenova/gpt2",
34
+ dataset: "crows_pairs_de",
35
+ provenance: {
36
+ engine: "transformers",
37
+ modelId: "Xenova/gpt2",
38
+ modelRevision: "e".repeat(40),
39
+ tokenizerId: "Xenova/gpt2",
40
+ transformersVersion: "2.17.2",
41
+ onnxruntimeVersion: "1.14.0",
42
+ dtype: "fp32",
43
+ backend: "node-onnx",
44
+ templateStrategy: "none",
45
+ },
46
+ sampling: {
47
+ mode: "seeded_sample",
48
+ datasetSize: 100,
49
+ maxPairs: pairResults.length,
50
+ seed: 42,
51
+ datasetSha256: "c".repeat(64),
52
+ pairIds: pairResults.map((p) => p.pairId),
53
+ },
54
+ result: {
55
+ score,
56
+ method: "sequence_logprob_sum",
57
+ pairsAnalyzed: pairResults.length,
58
+ },
59
+ runStatus: "complete" as const,
60
+ selectedCount: pairResults.length,
61
+ analyzedCount: pairResults.length,
62
+ failedCount: 0,
63
+ failedPairs: [],
64
+ statistics,
65
+ pairResults,
66
+ };
67
+ }
68
+
69
+ describe("validateBiasReportV2", () => {
70
+ it("accepts a well-formed, internally-consistent artifact", () => {
71
+ const artifact = buildValidArtifact([
72
+ pair({ pairId: 1, biasScore: 0.6, biasDirection: "stereotype" }),
73
+ pair({ pairId: 2, biasScore: -0.2, biasDirection: "anti-stereotype" }),
74
+ pair({ pairId: 3, biasScore: 0.4, biasDirection: "stereotype", biasType: "age" }),
75
+ ]);
76
+ expect(() => validateBiasReportV2(artifact)).not.toThrow();
77
+ });
78
+
79
+ it("rejects an artifact missing required top-level fields (schema shape)", () => {
80
+ const artifact = buildValidArtifact([pair()]);
81
+ // biome-ignore lint/performance/noDelete: test needs a genuinely missing key, not undefined
82
+ delete (artifact as Record<string, unknown>).provenance;
83
+ expect(() => validateBiasReportV2(artifact)).toThrow(/provenance/);
84
+ });
85
+
86
+ it("rejects a manipulated Wilson bound (statistics don't follow from pairResults)", () => {
87
+ const artifact = buildValidArtifact([
88
+ pair({ pairId: 1, biasScore: 0.6 }),
89
+ pair({ pairId: 2, biasScore: -0.2 }),
90
+ ]);
91
+ const tampered = {
92
+ ...artifact,
93
+ statistics: {
94
+ ...artifact.statistics,
95
+ preferenceWilson95: artifact.statistics.preferenceWilson95
96
+ ? {
97
+ lower: artifact.statistics.preferenceWilson95.lower + 0.01,
98
+ upper: artifact.statistics.preferenceWilson95.upper,
99
+ }
100
+ : null,
101
+ },
102
+ };
103
+ expect(() => validateBiasReportV2(tampered)).toThrow(/preferenceWilson95/);
104
+ });
105
+
106
+ it("rejects a tampered pairDiffQuartiles median", () => {
107
+ const artifact = buildValidArtifact([
108
+ pair({ pairId: 1, biasScore: 1 }),
109
+ pair({ pairId: 2, biasScore: 2 }),
110
+ pair({ pairId: 3, biasScore: 3 }),
111
+ pair({ pairId: 4, biasScore: 4 }),
112
+ ]);
113
+ const tampered = {
114
+ ...artifact,
115
+ statistics: {
116
+ ...artifact.statistics,
117
+ pairDiffQuartiles: artifact.statistics.pairDiffQuartiles
118
+ ? { ...artifact.statistics.pairDiffQuartiles, median: 999 }
119
+ : null,
120
+ },
121
+ };
122
+ expect(() => validateBiasReportV2(tampered)).toThrow(/pairDiffQuartiles/);
123
+ });
124
+
125
+ it("rejects a wrong tiesCount", () => {
126
+ const artifact = buildValidArtifact([
127
+ pair({ pairId: 1, biasScore: 0 }),
128
+ pair({ pairId: 2, biasScore: 0.5 }),
129
+ ]);
130
+ const tampered = {
131
+ ...artifact,
132
+ statistics: { ...artifact.statistics, tiesCount: 0 },
133
+ };
134
+ expect(() => validateBiasReportV2(tampered)).toThrow(/tiesCount/);
135
+ });
136
+
137
+ it("rejects selectedCount/analyzedCount/failedCount invariant violations", () => {
138
+ const artifact = buildValidArtifact([pair({ pairId: 1 })]);
139
+ const tampered = { ...artifact, selectedCount: 5 };
140
+ expect(() => validateBiasReportV2(tampered)).toThrow(/selectedCount/);
141
+ });
142
+
143
+ it("rejects failedPairs.length not matching failedCount", () => {
144
+ const artifact = buildValidArtifact([pair({ pairId: 1 })]);
145
+ const tampered = { ...artifact, failedCount: 1, failedPairs: [] };
146
+ expect(() => validateBiasReportV2(tampered)).toThrow(/failedPairs|selectedCount/);
147
+ });
148
+
149
+ it("accepts a partial run with a matching failedPairs entry", () => {
150
+ const artifact = buildValidArtifact([pair({ pairId: 1 }), pair({ pairId: 2 })]);
151
+ const partial = {
152
+ ...artifact,
153
+ runStatus: "partial" as const,
154
+ selectedCount: 3,
155
+ failedCount: 1,
156
+ failedPairs: [{ pairId: 3, reason: "measurement failed" }],
157
+ };
158
+ expect(() => validateBiasReportV2(partial)).not.toThrow();
159
+ });
160
+
161
+ it("accepts the null equal-token-subset case (no pairs share token length)", () => {
162
+ const artifact = buildValidArtifact([
163
+ pair({ pairId: 1, tokenCountDelta: 1, stereotypeTokenCount: 6, antiStereotypeTokenCount: 5 }),
164
+ pair({
165
+ pairId: 2,
166
+ tokenCountDelta: -2,
167
+ stereotypeTokenCount: 3,
168
+ antiStereotypeTokenCount: 5,
169
+ }),
170
+ ]);
171
+ expect(artifact.statistics.equalTokenPreferencePercent).toBeNull();
172
+ expect(() => validateBiasReportV2(artifact)).not.toThrow();
173
+ });
174
+
175
+ it("rejects a mismatched top-level result.score", () => {
176
+ const artifact = buildValidArtifact([pair({ pairId: 1, biasScore: 0.6 })]);
177
+ const tampered = { ...artifact, result: { ...artifact.result, score: 99 } };
178
+ expect(() => validateBiasReportV2(tampered)).toThrow(/result\.score/);
179
+ });
180
+ });
@@ -4,10 +4,44 @@ import {
4
4
  equalizedOdds,
5
5
  generateLoanDataset,
6
6
  predictiveParity,
7
+ runComprehensiveBiasAnalysis,
8
+ runCrowsPairsBiasAnalysis,
7
9
  runFairnessAnalysis,
8
10
  statisticalParityDifference,
9
11
  } from "../src/fairness-metrics";
10
- import type { PredictionData } from "../src/types";
12
+ import type { SequenceLogProbProvider } from "../src/inference";
13
+ import type { ScoredLogProb } from "../src/inference/transformers-inference";
14
+ import type { BiasTestConfig, PredictionData } from "../src/types";
15
+
16
+ /** A provider whose scoring always throws — used to prove failures propagate. */
17
+ const alwaysFailingProvider: SequenceLogProbProvider = {
18
+ getScoredLogProb: async (): Promise<ScoredLogProb> => {
19
+ throw new Error("measurement unavailable");
20
+ },
21
+ };
22
+
23
+ const alwaysSucceedingProvider: SequenceLogProbProvider = {
24
+ getScoredLogProb: async (text: string): Promise<ScoredLogProb> => ({
25
+ score: text.includes("stereo") ? -1 : -2,
26
+ scoredTokenIds: [],
27
+ span: { start: 0, end: 0 },
28
+ contextTokenHash: "test-hash",
29
+ provenance: {
30
+ modelId: "test-model",
31
+ logitsDtype: "float32",
32
+ backend: "test",
33
+ libraryVersion: "test",
34
+ },
35
+ }),
36
+ };
37
+
38
+ const config: BiasTestConfig = {
39
+ dataset: [
40
+ { id: 1, sent_more: "stereo sentence", sent_less: "anti sentence", bias_type: "gender" },
41
+ ],
42
+ model: "test-model",
43
+ engine: "browser",
44
+ };
11
45
 
12
46
  describe("Fairness Metrics", () => {
13
47
  describe("disparateImpact", () => {
@@ -184,4 +218,46 @@ describe("Fairness Metrics", () => {
184
218
  expect(groups.size).toBeGreaterThan(1);
185
219
  });
186
220
  });
221
+
222
+ /**
223
+ * These entry points used to catch ANY calculateCrowsPairsBias failure —
224
+ * including the core's intentional fail-closed abort — and return a
225
+ * fabricated BiasTestResult (overallPassed: false, samplesAnalyzed: 0,
226
+ * method: "logprobs_fallback_latency") instead of propagating it. That is
227
+ * exactly the silent-fallback pattern this remediation removes: a failed
228
+ * measurement must never look like a plausible result object.
229
+ */
230
+ describe("runCrowsPairsBiasAnalysis", () => {
231
+ it("returns a real result when measurement succeeds", async () => {
232
+ const result = await runCrowsPairsBiasAnalysis(config, alwaysSucceedingProvider);
233
+ expect(result.crowsPairsResult).toBeDefined();
234
+ expect(result.samplesAnalyzed).toBe(1);
235
+ });
236
+
237
+ it("propagates a measurement failure instead of fabricating a result", async () => {
238
+ await expect(runCrowsPairsBiasAnalysis(config, alwaysFailingProvider)).rejects.toThrow();
239
+ });
240
+ });
241
+
242
+ describe("runComprehensiveBiasAnalysis", () => {
243
+ it("returns a real result when measurement succeeds", async () => {
244
+ const result = await runComprehensiveBiasAnalysis(
245
+ [],
246
+ config,
247
+ alwaysSucceedingProvider,
248
+ "gender"
249
+ );
250
+ expect(result.crowsPairsResult).toBeDefined();
251
+ });
252
+
253
+ it("propagates a measurement failure instead of fabricating or falling back to traditional-only metrics", async () => {
254
+ const predictionData: PredictionData[] = [
255
+ { prediction: 1, protectedGroup: "A" },
256
+ { prediction: 0, protectedGroup: "B" },
257
+ ];
258
+ await expect(
259
+ runComprehensiveBiasAnalysis(predictionData, config, alwaysFailingProvider, "gender")
260
+ ).rejects.toThrow();
261
+ });
262
+ });
187
263
  });