@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,249 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ /**
4
+ * Offline, standard-suite tests for the Gemma-4 template-mode scorer
5
+ * (`scoreTemplateContinuation` / `isConditionalGenerationConfig`, Messmotor-Sanierung
6
+ * Task 5). All dependencies (tokenizer, model) are injected fakes — no real model, no
7
+ * network I/O, no files written. `@huggingface/transformers` is mocked only for `Tensor`,
8
+ * which `scoreTemplateContinuation` dynamically imports to build the model's input tensors
9
+ * (mirroring `transformers-inference.ts`'s own base-mode pattern).
10
+ */
11
+
12
+ class FakeTensor {
13
+ type: string;
14
+ data: BigInt64Array | Float32Array;
15
+ dims: number[];
16
+ constructor(type: string, data: BigInt64Array | Float32Array, dims: number[]) {
17
+ this.type = type;
18
+ this.data = data;
19
+ this.dims = dims;
20
+ }
21
+ }
22
+
23
+ vi.mock("@huggingface/transformers", () => ({ Tensor: FakeTensor }));
24
+
25
+ const CONTEXT_TEXT =
26
+ "<bos><|turn>user\nSchreibe einen kurzen Satz auf Deutsch.<turn|>\n<|turn>model\n";
27
+ // Disjoint id range from sentence tokens below, so any accidental overlap in scoredTokenIds
28
+ // is easy to catch.
29
+ const CONTEXT_TOKEN_IDS = [200, 201, 202, 203];
30
+ const VOCAB_SIZE = 20;
31
+
32
+ /** Sentence -> sentence-only token ids (never overlapping CONTEXT_TOKEN_IDS's range). */
33
+ const SENTENCE_TOKENS: Record<string, number[]> = {
34
+ "Die Bäckerin öffnet früh.": [1, 2, 3],
35
+ "Die Bäckerin öffnet spät.": [1, 2, 4],
36
+ "Männer sind stark.": [5, 6],
37
+ "Emoji-Satz: 😀 Bäume.": [7, 8, 9],
38
+ };
39
+
40
+ function makeFakeTokenizer(
41
+ overrides: {
42
+ contextTokenIds?: number[];
43
+ combinedOverride?: (contextIds: number[], sentence: string) => number[];
44
+ } = {}
45
+ ) {
46
+ const contextTokenIds = overrides.contextTokenIds ?? CONTEXT_TOKEN_IDS;
47
+ const encode = vi.fn((text: string) => {
48
+ if (text === CONTEXT_TEXT) return contextTokenIds;
49
+ const sentence = text.slice(CONTEXT_TEXT.length);
50
+ if (overrides.combinedOverride) {
51
+ return overrides.combinedOverride(contextTokenIds, sentence);
52
+ }
53
+ const tail = SENTENCE_TOKENS[sentence];
54
+ if (!tail) throw new Error(`test double: unknown sentence "${sentence}"`);
55
+ return [...contextTokenIds, ...tail];
56
+ });
57
+ const apply_chat_template = vi.fn(() => CONTEXT_TEXT);
58
+ return { apply_chat_template, encode };
59
+ }
60
+
61
+ /** Deterministic, finite logits: row i's target-ish slot is higher than the rest. */
62
+ function makeFakeModel() {
63
+ return vi.fn(async (inputs: { input_ids: FakeTensor; attention_mask: FakeTensor }) => {
64
+ const seqLen = inputs.input_ids.dims[1] as number;
65
+ const data = new Float32Array(seqLen * VOCAB_SIZE);
66
+ for (let i = 0; i < seqLen; i++) {
67
+ for (let v = 0; v < VOCAB_SIZE; v++) {
68
+ data[i * VOCAB_SIZE + v] = v === (i % VOCAB_SIZE) + 1 ? 3 : 0;
69
+ }
70
+ }
71
+ return { logits: new FakeTensor("float32", data, [1, seqLen, VOCAB_SIZE]) };
72
+ });
73
+ }
74
+
75
+ async function loadModule() {
76
+ return import("../../src/inference/template-scoring");
77
+ }
78
+
79
+ describe("isConditionalGenerationConfig", () => {
80
+ it("detects a ForConditionalGeneration architecture", async () => {
81
+ const { isConditionalGenerationConfig } = await loadModule();
82
+ expect(
83
+ isConditionalGenerationConfig({ architectures: ["Gemma4ForConditionalGeneration"] })
84
+ ).toBe(true);
85
+ });
86
+
87
+ it("does not detect a plain causal-LM architecture", async () => {
88
+ const { isConditionalGenerationConfig } = await loadModule();
89
+ expect(isConditionalGenerationConfig({ architectures: ["GPT2LMHeadModel"] })).toBe(false);
90
+ });
91
+
92
+ it("does not detect from model_type or missing architectures — config only, no name guessing", async () => {
93
+ const { isConditionalGenerationConfig } = await loadModule();
94
+ expect(isConditionalGenerationConfig({ model_type: "gemma4" })).toBe(false);
95
+ expect(isConditionalGenerationConfig({})).toBe(false);
96
+ });
97
+ });
98
+
99
+ describe("hasChatTemplate", () => {
100
+ it("detects a non-empty chat template string", async () => {
101
+ const { hasChatTemplate } = await loadModule();
102
+ expect(hasChatTemplate({ chat_template: "{{ bos_token }}..." })).toBe(true);
103
+ });
104
+
105
+ it("does not detect a null chat template (e.g. Xenova/gpt2, Xenova/llama2.c-stories15M)", async () => {
106
+ const { hasChatTemplate } = await loadModule();
107
+ expect(hasChatTemplate({ chat_template: null })).toBe(false);
108
+ });
109
+
110
+ it("does not detect a missing chat_template field or an empty string", async () => {
111
+ const { hasChatTemplate } = await loadModule();
112
+ expect(hasChatTemplate({})).toBe(false);
113
+ expect(hasChatTemplate({ chat_template: "" })).toBe(false);
114
+ });
115
+ });
116
+
117
+ describe("scoreTemplateContinuation", () => {
118
+ it("span excludes ALL template/context tokens — scoredTokenIds contains none of them", async () => {
119
+ const { scoreTemplateContinuation } = await loadModule();
120
+ const tokenizer = makeFakeTokenizer();
121
+ const model = makeFakeModel();
122
+
123
+ const result = await scoreTemplateContinuation(
124
+ { tokenizer, model, modelId: "test/gemma4", libraryVersion: "4.2.0" },
125
+ "Die Bäckerin öffnet früh."
126
+ );
127
+
128
+ expect(result.scoredTokenIds).toEqual([1, 2, 3]);
129
+ for (const contextId of CONTEXT_TOKEN_IDS) {
130
+ expect(result.scoredTokenIds).not.toContain(contextId);
131
+ }
132
+ expect(result.span).toEqual({
133
+ start: CONTEXT_TOKEN_IDS.length,
134
+ end: CONTEXT_TOKEN_IDS.length + 3,
135
+ });
136
+ expect(result.method).toBe("conditional_sequence_logprob_sum");
137
+ expect(result.provenance.templateStrategy).toBe("fixed_template_span");
138
+ expect(result.provenance.promptText).toBe(CONTEXT_TEXT);
139
+ });
140
+
141
+ it("regression: aborts if the context tokenizes to 0 tokens (the Task 0 spike's false-pass trap)", async () => {
142
+ const { scoreTemplateContinuation } = await loadModule();
143
+ // Simulate the exact bug: apply_chat_template(tokenize:true) unwrapped incorrectly
144
+ // would yield context_len = 0. Any code path landing here with an empty context must
145
+ // abort loudly rather than let the (vacuously true) prefix check pass silently.
146
+ const tokenizer = makeFakeTokenizer({ contextTokenIds: [] });
147
+ const model = makeFakeModel();
148
+
149
+ await expect(
150
+ scoreTemplateContinuation(
151
+ { tokenizer, model, modelId: "test/gemma4", libraryVersion: "4.2.0" },
152
+ "Die Bäckerin öffnet früh."
153
+ )
154
+ ).rejects.toThrow(/tokenized to 0 tokens/);
155
+ });
156
+
157
+ it("both pair halves produce a byte-identical context string and identical context token ids", async () => {
158
+ const { scoreTemplateContinuation } = await loadModule();
159
+ const tokenizer = makeFakeTokenizer();
160
+ const model = makeFakeModel();
161
+
162
+ const a = await scoreTemplateContinuation(
163
+ { tokenizer, model, modelId: "test/gemma4", libraryVersion: "4.2.0" },
164
+ "Die Bäckerin öffnet früh."
165
+ );
166
+ const b = await scoreTemplateContinuation(
167
+ { tokenizer, model, modelId: "test/gemma4", libraryVersion: "4.2.0" },
168
+ "Die Bäckerin öffnet spät."
169
+ );
170
+
171
+ expect(a.provenance.promptText).toBe(b.provenance.promptText);
172
+ expect(a.contextTokenHash).toBe(b.contextTokenHash);
173
+ });
174
+
175
+ it("aborts on prefix-token identity violation (constructed token-merge case) — no separator fallback", async () => {
176
+ const { scoreTemplateContinuation } = await loadModule();
177
+ // Simulate a token merge at the context/sentence boundary: the combined tokenization's
178
+ // last context-position id differs from the standalone context tokenization.
179
+ const tokenizer = makeFakeTokenizer({
180
+ combinedOverride: (contextIds) => {
181
+ const merged = [...contextIds];
182
+ merged[merged.length - 1] = 999; // diverges from the standalone context tokenization
183
+ return [...merged, 1, 2, 3];
184
+ },
185
+ });
186
+ const model = makeFakeModel();
187
+
188
+ await expect(
189
+ scoreTemplateContinuation(
190
+ { tokenizer, model, modelId: "test/gemma4", libraryVersion: "4.2.0" },
191
+ "Die Bäckerin öffnet früh."
192
+ )
193
+ ).rejects.toThrow(/prefix-token identity violated/);
194
+ });
195
+
196
+ it("handles umlauts and an emoji surrogate pair in the sentence — span maps to token ids, not JS string indices", async () => {
197
+ const { scoreTemplateContinuation } = await loadModule();
198
+ const tokenizer = makeFakeTokenizer();
199
+ const model = makeFakeModel();
200
+
201
+ const result = await scoreTemplateContinuation(
202
+ { tokenizer, model, modelId: "test/gemma4", libraryVersion: "4.2.0" },
203
+ "Emoji-Satz: 😀 Bäume."
204
+ );
205
+
206
+ expect(result.scoredTokenIds).toEqual([7, 8, 9]);
207
+ });
208
+
209
+ it("invariant: scoring the same sentence twice yields the same score", async () => {
210
+ const { scoreTemplateContinuation } = await loadModule();
211
+ const tokenizer = makeFakeTokenizer();
212
+ const model = makeFakeModel();
213
+
214
+ const first = await scoreTemplateContinuation(
215
+ { tokenizer, model, modelId: "test/gemma4", libraryVersion: "4.2.0" },
216
+ "Männer sind stark."
217
+ );
218
+ const second = await scoreTemplateContinuation(
219
+ { tokenizer, model, modelId: "test/gemma4", libraryVersion: "4.2.0" },
220
+ "Männer sind stark."
221
+ );
222
+
223
+ expect(second.score).toBe(first.score);
224
+ });
225
+
226
+ it("invariant: extending a token-identical sentence prefix strictly decreases the score", async () => {
227
+ const { scoreTemplateContinuation } = await loadModule();
228
+ // "Die Bäckerin öffnet früh." -> [1,2,3]; a longer sentence sharing that exact prefix.
229
+ const tokenizer = makeFakeTokenizer({
230
+ combinedOverride: (contextIds, sentence) => {
231
+ if (sentence === "short") return [...contextIds, 1, 2, 3];
232
+ if (sentence === "long") return [...contextIds, 1, 2, 3, 4, 5];
233
+ throw new Error(`unexpected sentence: ${sentence}`);
234
+ },
235
+ });
236
+ const model = makeFakeModel();
237
+
238
+ const shortResult = await scoreTemplateContinuation(
239
+ { tokenizer, model, modelId: "test/gemma4", libraryVersion: "4.2.0" },
240
+ "short"
241
+ );
242
+ const longResult = await scoreTemplateContinuation(
243
+ { tokenizer, model, modelId: "test/gemma4", libraryVersion: "4.2.0" },
244
+ "long"
245
+ );
246
+
247
+ expect(longResult.score).toBeLessThan(shortResult.score);
248
+ });
249
+ });
@@ -0,0 +1,214 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ /**
4
+ * Offline regression test for `TransformersInference`'s LOADER selection (Messmotor-
5
+ * Sanierung Task 5, "Umsetzung" item 1) and SCORING-MODE selection (Task 6c): a
6
+ * conditional-generation architecture (e.g. Gemma-4) must load via `AutoProcessor` +
7
+ * `Gemma4ForConditionalGeneration`; a classic causal-LM architecture must keep using
8
+ * `AutoTokenizer` + `AutoModelForCausalLM` — loader selection is driven by the loaded
9
+ * CONFIG's `architectures`, never by `modelId`.
10
+ *
11
+ * Scoring mode, separately, is driven by the loaded TOKENIZER's `chat_template` (Task 6c
12
+ * fix — supersedes architecture-name sniffing, which missed instruction-tuned
13
+ * `*ForCausalLM` models like Llama-3.2-1B-Instruct). The "CausalLM loader + chat template"
14
+ * test below pins that mode and loader are independent: that combination must select the
15
+ * base-mode LOADER but the template-mode SCORER.
16
+ */
17
+
18
+ class FakeTensor {
19
+ type: string;
20
+ data: BigInt64Array | Float32Array;
21
+ dims: number[];
22
+ constructor(type: string, data: BigInt64Array | Float32Array, dims: number[]) {
23
+ this.type = type;
24
+ this.data = data;
25
+ this.dims = dims;
26
+ }
27
+ }
28
+
29
+ const VOCAB_SIZE = 10;
30
+ const CONTEXT_TEXT =
31
+ "<bos><|turn>user\nSchreibe einen kurzen Satz auf Deutsch.<turn|>\n<|turn>model\n";
32
+ const CONTEXT_TOKEN_IDS = [50, 51, 52];
33
+ const SENTENCE_TOKEN_IDS = [1, 2];
34
+
35
+ const gemma4ForConditionalGenerationSpy = vi.fn();
36
+ const autoModelForCausalLmSpy = vi.fn();
37
+ const autoProcessorSpy = vi.fn();
38
+ const autoTokenizerSpy = vi.fn();
39
+
40
+ function makeFakeModel() {
41
+ return vi.fn(async (inputs: { input_ids: FakeTensor; attention_mask: FakeTensor }) => {
42
+ const seqLen = inputs.input_ids.dims[1] as number;
43
+ const data = new Float32Array(seqLen * VOCAB_SIZE);
44
+ for (let i = 0; i < seqLen; i++) {
45
+ for (let v = 0; v < VOCAB_SIZE; v++) {
46
+ data[i * VOCAB_SIZE + v] = v === (i % VOCAB_SIZE) + 1 ? 3 : 0;
47
+ }
48
+ }
49
+ return { logits: new FakeTensor("float32", data, [1, seqLen, VOCAB_SIZE]) };
50
+ });
51
+ }
52
+
53
+ vi.mock("@huggingface/transformers", () => {
54
+ function makeCausalTokenizerFn(chatTemplate: string | null) {
55
+ const fn = vi.fn((_text: string, _opts: unknown) => ({
56
+ input_ids: new FakeTensor("int64", BigInt64Array.from(SENTENCE_TOKEN_IDS.map(BigInt)), [
57
+ 1,
58
+ SENTENCE_TOKEN_IDS.length,
59
+ ]),
60
+ })) as unknown as {
61
+ (text: string, opts: unknown): { input_ids: FakeTensor };
62
+ bos_token: string;
63
+ bos_token_id: number;
64
+ chat_template: string | null;
65
+ apply_chat_template: (...args: unknown[]) => string;
66
+ encode: (text: string) => number[];
67
+ };
68
+ fn.bos_token = "<|bos|>";
69
+ fn.bos_token_id = 4;
70
+ fn.chat_template = chatTemplate;
71
+ // Only exercised by the CausalLM-loader + chat-template ("Llama-Instruct-like") case.
72
+ fn.apply_chat_template = vi.fn(() => CONTEXT_TEXT);
73
+ fn.encode = vi.fn((text: string) => {
74
+ if (text === CONTEXT_TEXT) return CONTEXT_TOKEN_IDS;
75
+ const sentence = text.slice(CONTEXT_TEXT.length);
76
+ if (sentence !== "Ein Satz.") throw new Error(`unexpected sentence: ${sentence}`);
77
+ return [...CONTEXT_TOKEN_IDS, ...SENTENCE_TOKEN_IDS];
78
+ });
79
+ return fn;
80
+ }
81
+
82
+ // No chat_template (e.g. Xenova/gpt2) -> base mode, matches Task 1 golden test.
83
+ const causalTokenizerFn = makeCausalTokenizerFn(null);
84
+ // Has a chat_template despite a plain CausalLM architecture (e.g. Llama-3.2-1B-Instruct)
85
+ // -> base-mode LOADER (AutoTokenizer + AutoModelForCausalLM) but template-mode SCORER.
86
+ const llamaInstructTokenizerFn = makeCausalTokenizerFn("{{ bos_token }}...");
87
+
88
+ const gemmaTokenizer = {
89
+ chat_template: "{%- macro format_parameters ... %}", // non-empty -> template mode
90
+ apply_chat_template: vi.fn(() => CONTEXT_TEXT),
91
+ encode: vi.fn((text: string) => {
92
+ if (text === CONTEXT_TEXT) return CONTEXT_TOKEN_IDS;
93
+ const sentence = text.slice(CONTEXT_TEXT.length);
94
+ if (sentence !== "Ein Satz.") throw new Error(`unexpected sentence: ${sentence}`);
95
+ return [...CONTEXT_TOKEN_IDS, ...SENTENCE_TOKEN_IDS];
96
+ }),
97
+ };
98
+
99
+ return {
100
+ AutoConfig: {
101
+ from_pretrained: vi.fn(async (modelId: string) => {
102
+ if (modelId === "test/gemma4-like") {
103
+ return { model_type: "gemma4", architectures: ["Gemma4ForConditionalGeneration"] };
104
+ }
105
+ if (modelId === "test/llama-instruct-like") {
106
+ return { model_type: "llama", architectures: ["LlamaForCausalLM"] };
107
+ }
108
+ return { model_type: "gpt2", architectures: ["GPT2LMHeadModel"] };
109
+ }),
110
+ },
111
+ AutoTokenizer: {
112
+ from_pretrained: vi.fn(async (modelId: string) => {
113
+ autoTokenizerSpy();
114
+ return modelId === "test/llama-instruct-like"
115
+ ? llamaInstructTokenizerFn
116
+ : causalTokenizerFn;
117
+ }),
118
+ },
119
+ AutoModelForCausalLM: {
120
+ from_pretrained: vi.fn(async () => {
121
+ autoModelForCausalLmSpy();
122
+ return makeFakeModel();
123
+ }),
124
+ },
125
+ AutoProcessor: {
126
+ from_pretrained: vi.fn(async () => {
127
+ autoProcessorSpy();
128
+ return { tokenizer: gemmaTokenizer };
129
+ }),
130
+ },
131
+ Gemma4ForConditionalGeneration: {
132
+ from_pretrained: vi.fn(async () => {
133
+ gemma4ForConditionalGenerationSpy();
134
+ return makeFakeModel();
135
+ }),
136
+ },
137
+ Tensor: FakeTensor,
138
+ };
139
+ });
140
+
141
+ async function loadTransformersInference() {
142
+ const mod = await import("../../src/inference/transformers-inference");
143
+ return mod.TransformersInference;
144
+ }
145
+
146
+ describe("TransformersInference config-based loader selection (Task 5)", () => {
147
+ beforeEach(() => {
148
+ gemma4ForConditionalGenerationSpy.mockClear();
149
+ autoModelForCausalLmSpy.mockClear();
150
+ autoProcessorSpy.mockClear();
151
+ autoTokenizerSpy.mockClear();
152
+ });
153
+
154
+ it("routes a conditional-generation architecture through AutoProcessor + Gemma4ForConditionalGeneration and the template mode", async () => {
155
+ const TransformersInference = await loadTransformersInference();
156
+ const engine = new TransformersInference("test/gemma4-like", null, { dtype: "q4" });
157
+
158
+ const result = await engine.getScoredLogProb("Ein Satz.");
159
+
160
+ expect(autoProcessorSpy).toHaveBeenCalledTimes(1);
161
+ expect(gemma4ForConditionalGenerationSpy).toHaveBeenCalledTimes(1);
162
+ expect(autoModelForCausalLmSpy).not.toHaveBeenCalled();
163
+
164
+ expect(result.method).toBe("conditional_sequence_logprob_sum");
165
+ expect(result.provenance.templateStrategy).toBe("fixed_template_span");
166
+ expect(result.scoredTokenIds).toEqual(SENTENCE_TOKEN_IDS);
167
+
168
+ // Task 5 review finding: the CONFIGURED weight precision must be threaded into
169
+ // provenance in template mode too, distinct from the output tensor's own dtype (which
170
+ // stays "float32" regardless of weight quantization — Task 0 spike).
171
+ expect(result.provenance.dtype).toBe("q4");
172
+ expect(result.provenance.logitsDtype).toBe("float32");
173
+ });
174
+
175
+ it("keeps a classic causal-LM architecture on AutoTokenizer + AutoModelForCausalLM and the base mode", async () => {
176
+ const TransformersInference = await loadTransformersInference();
177
+ const engine = new TransformersInference("test/gpt2-like", null, { dtype: "q4" });
178
+
179
+ const result = await engine.getScoredLogProb("Hi there");
180
+
181
+ expect(autoTokenizerSpy).toHaveBeenCalledTimes(1);
182
+ expect(autoModelForCausalLmSpy).toHaveBeenCalledTimes(1);
183
+ expect(autoProcessorSpy).not.toHaveBeenCalled();
184
+ expect(gemma4ForConditionalGenerationSpy).not.toHaveBeenCalled();
185
+
186
+ expect(result.method).toBe("sequence_logprob_sum");
187
+ expect(result.provenance.templateStrategy).toBe("none");
188
+
189
+ // Same finding, base mode: previously this path also only reported the output logits
190
+ // tensor's dtype under the (misleading) name `dtypeInfo`, never the configured weight
191
+ // precision actually passed to `from_pretrained`.
192
+ expect(result.provenance.dtype).toBe("q4");
193
+ expect(result.provenance.logitsDtype).toBe("float32");
194
+ });
195
+
196
+ it("Task 6c: a CausalLM-architecture model WITH a chat template uses the base-mode loader but the template-mode scorer (e.g. Llama-3.2-1B-Instruct)", async () => {
197
+ const TransformersInference = await loadTransformersInference();
198
+ const engine = new TransformersInference("test/llama-instruct-like", null, { dtype: "fp32" });
199
+
200
+ const result = await engine.getScoredLogProb("Ein Satz.");
201
+
202
+ // LOADER stayed architecture-keyed: AutoTokenizer + AutoModelForCausalLM, not the
203
+ // conditional-generation pair — the mode fix must not change loader selection.
204
+ expect(autoTokenizerSpy).toHaveBeenCalledTimes(1);
205
+ expect(autoModelForCausalLmSpy).toHaveBeenCalledTimes(1);
206
+ expect(autoProcessorSpy).not.toHaveBeenCalled();
207
+ expect(gemma4ForConditionalGenerationSpy).not.toHaveBeenCalled();
208
+
209
+ // SCORING MODE followed the tokenizer's chat_template, not the architecture.
210
+ expect(result.method).toBe("conditional_sequence_logprob_sum");
211
+ expect(result.provenance.templateStrategy).toBe("fixed_template_span");
212
+ expect(result.scoredTokenIds).toEqual(SENTENCE_TOKEN_IDS);
213
+ });
214
+ });