@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +34 -0
- package/dist/datasets/crows-pairs.d.ts +18 -0
- package/dist/datasets/crows-pairs.d.ts.map +1 -1
- package/dist/datasets/crows-pairs.js +58 -0
- package/dist/evidence/bias-comparison-markdown.d.ts +11 -0
- package/dist/evidence/bias-comparison-markdown.d.ts.map +1 -0
- package/dist/evidence/bias-comparison-markdown.js +120 -0
- package/dist/evidence/bias-comparison.d.ts +133 -0
- package/dist/evidence/bias-comparison.d.ts.map +1 -0
- package/dist/evidence/bias-comparison.js +274 -0
- package/dist/evidence/bias-statistics.d.ts +72 -0
- package/dist/evidence/bias-statistics.d.ts.map +1 -0
- package/dist/evidence/bias-statistics.js +122 -0
- package/dist/evidence/bias-v2-semantics.d.ts +19 -0
- package/dist/evidence/bias-v2-semantics.d.ts.map +1 -0
- package/dist/evidence/bias-v2-semantics.js +133 -0
- package/dist/evidence/browser.d.ts +18 -0
- package/dist/evidence/browser.d.ts.map +1 -0
- package/dist/evidence/browser.js +14 -0
- package/dist/evidence/bundle.d.ts +1 -0
- package/dist/evidence/bundle.d.ts.map +1 -1
- package/dist/evidence/bundle.js +2 -0
- package/dist/evidence/index.d.ts +9 -2
- package/dist/evidence/index.d.ts.map +1 -1
- package/dist/evidence/index.js +5 -1
- package/dist/evidence/types.d.ts +175 -1
- package/dist/evidence/types.d.ts.map +1 -1
- package/dist/evidence/validate-bias-v2.d.ts +31 -0
- package/dist/evidence/validate-bias-v2.d.ts.map +1 -0
- package/dist/evidence/validate-bias-v2.js +40 -0
- package/dist/evidence/validate.d.ts +29 -2
- package/dist/evidence/validate.d.ts.map +1 -1
- package/dist/evidence/validate.js +90 -8
- package/dist/fairness-metrics.d.ts +15 -11
- package/dist/fairness-metrics.d.ts.map +1 -1
- package/dist/fairness-metrics.js +59 -183
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/inference/browser-inference.d.ts +6 -57
- package/dist/inference/browser-inference.d.ts.map +1 -1
- package/dist/inference/browser-inference.js +6 -219
- package/dist/inference/capability-detection.d.ts +9 -2
- package/dist/inference/capability-detection.d.ts.map +1 -1
- package/dist/inference/capability-detection.js +18 -16
- package/dist/inference/index.d.ts +15 -2
- package/dist/inference/index.d.ts.map +1 -1
- package/dist/inference/index.js +12 -3
- package/dist/inference/inference-factory.d.ts +6 -3
- package/dist/inference/inference-factory.d.ts.map +1 -1
- package/dist/inference/inference-factory.js +7 -26
- package/dist/inference/ollama-client.d.ts +29 -0
- package/dist/inference/ollama-client.d.ts.map +1 -1
- package/dist/inference/ollama-client.js +29 -0
- package/dist/inference/scored-log-prob-types.d.ts +64 -0
- package/dist/inference/scored-log-prob-types.d.ts.map +1 -0
- package/dist/inference/scored-log-prob-types.js +12 -0
- package/dist/inference/sequence-logprob.d.ts +34 -0
- package/dist/inference/sequence-logprob.d.ts.map +1 -0
- package/dist/inference/sequence-logprob.js +87 -0
- package/dist/inference/template-scoring.d.ts +124 -0
- package/dist/inference/template-scoring.d.ts.map +1 -0
- package/dist/inference/template-scoring.js +164 -0
- package/dist/inference/transformers-inference.d.ts +124 -0
- package/dist/inference/transformers-inference.d.ts.map +1 -0
- package/dist/inference/transformers-inference.js +392 -0
- package/dist/legal-checks/bias-metrics.d.ts +5 -7
- package/dist/legal-checks/bias-metrics.d.ts.map +1 -1
- package/dist/legal-checks/bias-metrics.js +8 -101
- package/dist/metrics/crows-pairs-bias.d.ts +17 -9
- package/dist/metrics/crows-pairs-bias.d.ts.map +1 -1
- package/dist/metrics/crows-pairs-bias.js +147 -59
- package/dist/metrics/stereotype-bias.d.ts +13 -5
- package/dist/metrics/stereotype-bias.d.ts.map +1 -1
- package/dist/metrics/stereotype-bias.js +16 -99
- package/dist/scanner/detectors/ai-framework-detector.js +1 -1
- package/dist/scanner/detectors/local-inference-detector.d.ts.map +1 -1
- package/dist/scanner/detectors/local-inference-detector.js +1 -1
- package/dist/scanner/detectors/package-json-detector.d.ts.map +1 -1
- package/dist/scanner/detectors/package-json-detector.js +1 -0
- package/dist/types.d.ts +103 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/validation/schema-validator.d.ts +1 -1
- package/dist/validation/schema-validator.d.ts.map +1 -1
- package/dist/validation/schema-validator.js +6 -0
- package/package.json +4 -2
- package/scripts/seed-logprob-fixture.mjs +179 -0
- package/scripts/ts-extensionless-resolve-hook.mjs +26 -0
- package/src/datasets/crows-pairs.ts +74 -0
- package/src/evidence/bias-comparison-markdown.ts +162 -0
- package/src/evidence/bias-comparison.ts +432 -0
- package/src/evidence/bias-statistics.ts +180 -0
- package/src/evidence/bias-v2-semantics.ts +214 -0
- package/src/evidence/browser.ts +35 -0
- package/src/evidence/bundle.ts +2 -0
- package/src/evidence/index.ts +31 -0
- package/src/evidence/types.ts +204 -1
- package/src/evidence/validate-bias-v2.ts +47 -0
- package/src/evidence/validate.ts +140 -13
- package/src/fairness-metrics.ts +79 -222
- package/src/index.ts +7 -0
- package/src/inference/browser-inference.ts +14 -256
- package/src/inference/capability-detection.ts +32 -18
- package/src/inference/index.ts +33 -5
- package/src/inference/inference-factory.ts +9 -30
- package/src/inference/ollama-client.ts +29 -0
- package/src/inference/scored-log-prob-types.ts +66 -0
- package/src/inference/sequence-logprob.ts +136 -0
- package/src/inference/template-scoring.ts +248 -0
- package/src/inference/transformers-inference.ts +494 -0
- package/src/legal-checks/bias-metrics.ts +20 -118
- package/src/metrics/crows-pairs-bias.ts +180 -83
- package/src/metrics/stereotype-bias.ts +27 -119
- package/src/scanner/detectors/ai-framework-detector.ts +1 -1
- package/src/scanner/detectors/local-inference-detector.ts +2 -1
- package/src/scanner/detectors/package-json-detector.ts +1 -0
- package/src/types.ts +108 -2
- package/src/validation/schema-validator.ts +15 -1
- package/tests/annex-iv-report.test.ts +1 -1
- package/tests/capability-detection.test.ts +57 -0
- package/tests/crows-pairs-bias.test.ts +286 -186
- package/tests/custom-dataset.test.ts +80 -0
- package/tests/evidence/bias-artifact-v2.test.ts +135 -0
- package/tests/evidence/bias-artifact.test.ts +78 -0
- package/tests/evidence/bias-comparison.test.ts +397 -0
- package/tests/evidence/bias-statistics.test.ts +191 -0
- package/tests/evidence/external-observation.test.ts +167 -0
- package/tests/evidence/spec-schemas.test.ts +35 -2
- package/tests/evidence/validate-bias-v2.test.ts +180 -0
- package/tests/fairness-metrics.test.ts +77 -1
- package/tests/inference/browser-inference-scoring.test.ts +159 -0
- package/tests/inference/fixtures/logprob-golden.json +69 -0
- package/tests/inference/gemma-template.integration.test.ts +81 -0
- package/tests/inference/logprob-golden.integration.test.ts +128 -0
- package/tests/inference/synthetic-logits.test.ts +188 -0
- package/tests/inference/template-scoring.test.ts +249 -0
- package/tests/inference/transformers-inference-template-mode.test.ts +214 -0
- package/tests/integration/bias-calculation.integration.test.ts +119 -599
- package/tests/stilled-ollama-paths.test.ts +76 -0
- 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
|
+
});
|