@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,136 @@
1
+ /**
2
+ * Pure, offline-testable log-probability scorer for autoregressive language models.
3
+ *
4
+ * Scoring convention (binding):
5
+ * - A span [start, end) is half-open over token positions of the FULL fed sequence
6
+ * (i.e. including any context tokens such as a prepended BOS).
7
+ * - The token AT position j is scored using the logits AT position j-1 (the model's
8
+ * prediction distribution for "what comes next" is produced one position before the
9
+ * token it predicts).
10
+ * - The result is a SUM of log-probabilities, not a mean.
11
+ * - batch_size must be exactly 1; anything else is a caller error and throws.
12
+ */
13
+
14
+ /** Minimal shape this module needs from an onnxruntime-style Tensor. */
15
+ export interface LogitsTensorLike {
16
+ /** Flattened [batch, seqLen, vocabSize] logits. Must be Float32Array or Float64Array. */
17
+ data: Float32Array | Float64Array;
18
+ /** [batchSize, seqLen, vocabSize] */
19
+ dims: readonly number[];
20
+ }
21
+
22
+ /** Half-open token-position span [start, end). */
23
+ export interface Span {
24
+ start: number;
25
+ end: number;
26
+ }
27
+
28
+ function assertSupportedDtype(data: LogitsTensorLike["data"]): void {
29
+ if (!(data instanceof Float32Array) && !(data instanceof Float64Array)) {
30
+ const ctorName =
31
+ (data as { constructor?: { name?: string } })?.constructor?.name ?? typeof data;
32
+ throw new Error(
33
+ `sumSequenceLogProb: unsupported logits dtype, expected Float32Array or Float64Array, got ${ctorName}`
34
+ );
35
+ }
36
+ }
37
+
38
+ function assertBatchAndDims(
39
+ dims: readonly number[]
40
+ ): [batchSize: number, seqLen: number, vocabSize: number] {
41
+ if (dims.length !== 3) {
42
+ throw new Error(
43
+ `sumSequenceLogProb: expected logits dims [batch, seqLen, vocabSize], got [${dims.join(", ")}]`
44
+ );
45
+ }
46
+ const batchSize = dims[0] ?? 0;
47
+ const seqLen = dims[1] ?? 0;
48
+ const vocabSize = dims[2] ?? 0;
49
+ if (batchSize !== 1) {
50
+ throw new Error(`sumSequenceLogProb: batch_size must be 1, got ${batchSize}`);
51
+ }
52
+ return [batchSize, seqLen, vocabSize];
53
+ }
54
+
55
+ function assertSpanWithinBounds(span: Span, seqLen: number): void {
56
+ const { start, end } = span;
57
+ if (
58
+ !Number.isInteger(start) ||
59
+ !Number.isInteger(end) ||
60
+ start < 1 ||
61
+ end > seqLen ||
62
+ start >= end
63
+ ) {
64
+ throw new Error(
65
+ `sumSequenceLogProb: invalid span [${start}, ${end}) for seqLen ${seqLen}; span must satisfy integer 1 <= start < end <= seqLen`
66
+ );
67
+ }
68
+ }
69
+
70
+ function logSumExpRow(
71
+ data: LogitsTensorLike["data"],
72
+ rowStart: number,
73
+ vocabSize: number,
74
+ contextLabel: string
75
+ ): { maxLogit: number; logSumExp: number } {
76
+ let maxLogit = Number.NEGATIVE_INFINITY;
77
+ for (let v = 0; v < vocabSize; v++) {
78
+ const val = data[rowStart + v] ?? Number.NaN;
79
+ if (!Number.isFinite(val)) {
80
+ throw new Error(
81
+ `sumSequenceLogProb: non-finite logit at ${contextLabel}, vocab index ${v} (value: ${val})`
82
+ );
83
+ }
84
+ if (val > maxLogit) maxLogit = val;
85
+ }
86
+ let sumExp = 0;
87
+ for (let v = 0; v < vocabSize; v++) {
88
+ const val = data[rowStart + v] ?? Number.NaN;
89
+ sumExp += Math.exp(val - maxLogit);
90
+ }
91
+ return { maxLogit, logSumExp: maxLogit + Math.log(sumExp) };
92
+ }
93
+
94
+ /**
95
+ * Sum the log-probabilities of the tokens in `tokenIds` over the half-open span
96
+ * [span.start, span.end), where the token at position j is scored from the logits
97
+ * distribution at position j-1.
98
+ *
99
+ * Throws on: unsupported dtype, wrong dims shape, batch_size !== 1, tokenIds/seqLen
100
+ * mismatch, out-of-bounds span, out-of-vocab token ids, or non-finite logits.
101
+ */
102
+ export function sumSequenceLogProb(
103
+ logits: LogitsTensorLike,
104
+ tokenIds: ArrayLike<number | bigint>,
105
+ span: Span
106
+ ): number {
107
+ assertSupportedDtype(logits.data);
108
+ const [, seqLen, vocabSize] = assertBatchAndDims(logits.dims);
109
+
110
+ if (tokenIds.length !== seqLen) {
111
+ throw new Error(
112
+ `sumSequenceLogProb: tokenIds length (${tokenIds.length}) does not match logits seqLen (${seqLen})`
113
+ );
114
+ }
115
+ assertSpanWithinBounds(span, seqLen);
116
+
117
+ const { data } = logits;
118
+ let total = 0;
119
+
120
+ for (let j = span.start; j < span.end; j++) {
121
+ const targetId = Number(tokenIds[j]);
122
+ if (!Number.isInteger(targetId) || targetId < 0 || targetId >= vocabSize) {
123
+ throw new Error(
124
+ `sumSequenceLogProb: token id ${targetId} at position ${j} out of vocab range [0, ${vocabSize})`
125
+ );
126
+ }
127
+
128
+ const pos = j - 1;
129
+ const rowStart = pos * vocabSize;
130
+ const { logSumExp } = logSumExpRow(data, rowStart, vocabSize, `position ${pos}`);
131
+ const targetLogit = data[rowStart + targetId] ?? Number.NaN;
132
+ total += targetLogit - logSumExp;
133
+ }
134
+
135
+ return total;
136
+ }
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Gemma-4 template-mode scoring: `conditional_sequence_logprob_sum`.
3
+ *
4
+ * Gemma-4 (and any other instruction-tuned "ForConditionalGeneration" architecture) is
5
+ * trained on chat-templated input. Feeding it a bare sentence — the base-mode approach
6
+ * from Task 1 — measures outside its intended input format. Instead, the sentence is
7
+ * scored as an ASSISTANT CONTINUATION after a fixed, WOERTLICH user turn (Messmotor-
8
+ * Sanierung Task 5 / Plan v5), and only the sentence's own tokens are scored — never the
9
+ * template's.
10
+ *
11
+ * Split out of `transformers-inference.ts` (per Task 5 brief item 3) to keep that file
12
+ * from growing unwieldy; this module owns everything specific to the template-mode span
13
+ * computation and is exercised entirely offline via injected fakes (`TemplateTokenizer`,
14
+ * `TemplateModel`) — no `@huggingface/transformers` mocking needed except for `Tensor`,
15
+ * which is dynamically imported the same way `transformers-inference.ts` does it.
16
+ *
17
+ * FALLE aus dem Spike (Task 0): `tokenizer.apply_chat_template(..., { tokenize: true })`
18
+ * returns `{ input_ids: Tensor, attention_mask: Tensor }`, NOT a plain array. Naively
19
+ * calling `Array.isArray()`/`Array.from()` on that object yields `context_len = 0`, which
20
+ * makes the prefix-identity check vacuously true. This module never calls
21
+ * `apply_chat_template` with `tokenize: true` at all — it uses `tokenize: false` to get
22
+ * the literal context STRING, then tokenizes that string (and the context+sentence
23
+ * string) via `tokenizer.encode()`, which returns a plain `number[]` by contract. The
24
+ * `context_len > 0` guard below exists anyway, as a structural regression test against
25
+ * this exact trap resurfacing under a future refactor.
26
+ */
27
+
28
+ import type { ScoredLogProb, ScoredLogProbProvenance } from "./scored-log-prob-types";
29
+ import type { LogitsTensorLike, Span } from "./sequence-logprob";
30
+ import { sumSequenceLogProb } from "./sequence-logprob";
31
+
32
+ /**
33
+ * WOERTLICH fixed context spec (Messmotor-Sanierung Task 5 / Plan v5), verified against
34
+ * the real gemma-4-E2B-it tokenizer by the Task 0 spike (18 tokens,
35
+ * [2,105,2364,107,13790,500,18252,10175,171313,118778,4238,29078,236761,106,107,105,4368,107]).
36
+ * NEVER change this text without re-running the gated integration test — every bias.v2
37
+ * artifact produced in template mode recomputes against exactly this prompt.
38
+ */
39
+ export const FIXED_TEMPLATE_USER_MESSAGE = "Schreibe einen kurzen Satz auf Deutsch.";
40
+
41
+ /** Minimal message shape this module needs — deliberately not importing transformers.js's
42
+ * own `Message` type to keep this module's dependency surface (and test doubles) small. */
43
+ export interface TemplateChatMessage {
44
+ role: string;
45
+ content: string;
46
+ }
47
+
48
+ function buildTemplateMessages(): TemplateChatMessage[] {
49
+ return [{ role: "user", content: FIXED_TEMPLATE_USER_MESSAGE }];
50
+ }
51
+
52
+ /** Minimal shape this module needs from a transformers.js tokenizer for template-mode
53
+ * scoring. `apply_chat_template` is only ever called here with `tokenize: false`. */
54
+ export interface TemplateTokenizer {
55
+ apply_chat_template(
56
+ conversation: TemplateChatMessage[],
57
+ options: { add_generation_prompt: boolean; tokenize: false }
58
+ ): string;
59
+ encode(text: string): number[];
60
+ }
61
+
62
+ /** Minimal shape this module needs from a transformers.js model: callable, returns logits. */
63
+ export type TemplateModel = (inputs: {
64
+ input_ids: unknown;
65
+ attention_mask: unknown;
66
+ }) => Promise<{ logits: LogitsTensorLike }>;
67
+
68
+ export interface TemplateScoringDeps {
69
+ tokenizer: TemplateTokenizer;
70
+ model: TemplateModel;
71
+ modelId: string;
72
+ libraryVersion: string;
73
+ /** The CONFIGURED weight precision (e.g. "q4", "fp32") passed to `from_pretrained` —
74
+ * threaded through explicitly because the output logits tensor's own `.type` is always
75
+ * "float32" regardless of weight quantization (Task 0 spike) and so cannot be used to
76
+ * recover it; see `ScoredLogProbProvenance.dtype` vs `.logitsDtype`. */
77
+ dtype: string;
78
+ }
79
+
80
+ async function sha256Hex(input: string): Promise<string> {
81
+ const bytes = new TextEncoder().encode(input);
82
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes);
83
+ return Array.from(new Uint8Array(digest))
84
+ .map((b) => b.toString(16).padStart(2, "0"))
85
+ .join("");
86
+ }
87
+
88
+ /**
89
+ * Assert that `contextTokenIds` is an exact prefix of `combinedTokenIds`. Per spec: on
90
+ * violation (a token merge at the context/sentence boundary), ABORT with a speaking error.
91
+ * Never try a separator character, never blindly assume the prefix length as the span start.
92
+ */
93
+ function assertPrefixIdentity(
94
+ contextTokenIds: readonly number[],
95
+ combinedTokenIds: readonly number[],
96
+ modelId: string,
97
+ text: string
98
+ ): void {
99
+ if (combinedTokenIds.length < contextTokenIds.length) {
100
+ throw new Error(
101
+ `scoreTemplateContinuation: combined tokenization (${combinedTokenIds.length} tokens) is shorter ` +
102
+ `than the fixed context (${contextTokenIds.length} tokens) for sentence "${text}" on model ` +
103
+ `"${modelId}" — cannot determine a sentence span.`
104
+ );
105
+ }
106
+ for (let i = 0; i < contextTokenIds.length; i++) {
107
+ if (combinedTokenIds[i] !== contextTokenIds[i]) {
108
+ throw new Error(
109
+ `scoreTemplateContinuation: prefix-token identity violated at position ${i} for sentence "${text}" on model "${modelId}" (context token ${contextTokenIds[i]} vs combined token ${combinedTokenIds[i]}). The tokenizer merged tokens across the context/sentence boundary. Per spec this is an ABORT: no separator character, no blind assumption of the prefix length as the span start.`
110
+ );
111
+ }
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Score `text` as a Gemma-4-style assistant continuation of the fixed template context.
117
+ *
118
+ * Builds the context via `apply_chat_template(..., { tokenize: false })`, tokenizes the
119
+ * context alone and the context+sentence together via `tokenizer.encode()`, verifies
120
+ * prefix-token identity, determines the sentence-only span, and delegates the actual
121
+ * log-probability accumulation to the pure `sumSequenceLogProb` scorer from Task 1
122
+ * (reused, not reimplemented).
123
+ */
124
+ export async function scoreTemplateContinuation(
125
+ deps: TemplateScoringDeps,
126
+ text: string
127
+ ): Promise<ScoredLogProb> {
128
+ const { tokenizer, model, modelId, libraryVersion, dtype } = deps;
129
+
130
+ const messages = buildTemplateMessages();
131
+ const contextText = tokenizer.apply_chat_template(messages, {
132
+ add_generation_prompt: true,
133
+ tokenize: false,
134
+ });
135
+
136
+ const contextTokenIds = Array.from(tokenizer.encode(contextText), Number);
137
+ if (contextTokenIds.length === 0) {
138
+ throw new Error(
139
+ `scoreTemplateContinuation: fixed template context tokenized to 0 tokens for model "${modelId}". This is the exact false-pass trap from the Task 0 spike (an unwrapped apply_chat_template(tokenize:true) Tensor object silently produced context_len=0, making the prefix-identity check vacuously true) — aborting rather than treating an empty context as a valid prefix.`
140
+ );
141
+ }
142
+
143
+ const combinedTokenIds = Array.from(tokenizer.encode(contextText + text), Number);
144
+ assertPrefixIdentity(contextTokenIds, combinedTokenIds, modelId, text);
145
+
146
+ const span: Span = { start: contextTokenIds.length, end: combinedTokenIds.length };
147
+ if (span.start >= span.end) {
148
+ throw new Error(
149
+ `scoreTemplateContinuation: sentence "${text}" contributed zero tokens after the fixed ` +
150
+ `context (span [${span.start}, ${span.end})) for model "${modelId}".`
151
+ );
152
+ }
153
+ const scoredTokenIds = combinedTokenIds.slice(span.start);
154
+
155
+ const { Tensor } = await import("@huggingface/transformers");
156
+ const seqLen = combinedTokenIds.length;
157
+ const inputIds = new Tensor("int64", BigInt64Array.from(combinedTokenIds.map(BigInt)), [
158
+ 1,
159
+ seqLen,
160
+ ]);
161
+ const attentionMask = new Tensor("int64", BigInt64Array.from(new Array(seqLen).fill(1n)), [
162
+ 1,
163
+ seqLen,
164
+ ]);
165
+
166
+ const outputs = await model({ input_ids: inputIds, attention_mask: attentionMask });
167
+ const logits: LogitsTensorLike = outputs.logits;
168
+
169
+ const score = sumSequenceLogProb(logits, combinedTokenIds, span);
170
+
171
+ const contextTokenHash = await sha256Hex(JSON.stringify(contextTokenIds));
172
+ const promptSha256 = await sha256Hex(contextText);
173
+
174
+ const provenance: ScoredLogProbProvenance = {
175
+ modelId,
176
+ dtype,
177
+ logitsDtype: String((logits as unknown as { type?: string }).type ?? "unknown"),
178
+ backend: "node-onnx",
179
+ libraryVersion,
180
+ templateStrategy: "fixed_template_span",
181
+ promptText: contextText,
182
+ promptSha256,
183
+ };
184
+
185
+ return {
186
+ score,
187
+ scoredTokenIds,
188
+ span,
189
+ contextTokenHash,
190
+ provenance,
191
+ method: "conditional_sequence_logprob_sum",
192
+ };
193
+ }
194
+
195
+ /** Minimal shape this module needs from a loaded model config to detect architecture. */
196
+ export interface MinimalModelConfig {
197
+ model_type?: string | null;
198
+ architectures?: readonly string[] | null;
199
+ }
200
+
201
+ /**
202
+ * Detect whether a loaded model config indicates a conditional-generation / multimodal
203
+ * architecture (e.g. Gemma-4's `Gemma4ForConditionalGeneration`), which must be scored via
204
+ * the template mode rather than fed bare sentences as a classic causal LM.
205
+ *
206
+ * Detection is from the CONFIG (`architectures`), never the model id string, per spec —
207
+ * so a differently-named repo hosting the same architecture is still detected correctly,
208
+ * and a model whose name happens to contain "gemma" but isn't conditional-generation is not
209
+ * misdetected.
210
+ */
211
+ export function isConditionalGenerationConfig(config: MinimalModelConfig): boolean {
212
+ const architectures = config.architectures ?? [];
213
+ return architectures.some(
214
+ (name) => typeof name === "string" && name.endsWith("ForConditionalGeneration")
215
+ );
216
+ }
217
+
218
+ /** Minimal shape this module needs from a loaded tokenizer to detect a chat template. */
219
+ export interface MinimalChatTemplateTokenizer {
220
+ chat_template?: string | null;
221
+ }
222
+
223
+ /**
224
+ * Detect whether a loaded TOKENIZER carries a chat template, which decides the SCORING MODE
225
+ * (template vs. base) — Messmotor-Sanierung Task 6c.
226
+ *
227
+ * This replaces architecture-name sniffing (`isConditionalGenerationConfig`) as the mode
228
+ * signal: Task 6b's panel run showed that keying mode on architecture misses instruction-tuned
229
+ * models whose architecture is plain `*ForCausalLM` (e.g. `LlamaForCausalLM`,
230
+ * `Gemma3ForCausalLM`) but which still expect chat-templated input — they were scored bare,
231
+ * the exact error template mode exists to prevent.
232
+ *
233
+ * Verified empirically (transformers.js@4.2.0, against the four panel models + gpt2) via
234
+ * `AutoTokenizer.from_pretrained(modelId)`: the loaded tokenizer instance exposes the chat
235
+ * template — sourced from `tokenizer_config.json`'s `chat_template` field — directly as
236
+ * `tokenizer.chat_template`. It is `null` for models with no chat template
237
+ * (`Xenova/gpt2`, `Xenova/llama2.c-stories15M`) and a non-empty Jinja template string for
238
+ * instruction-tuned models (`onnx-community/Llama-3.2-1B-Instruct-ONNX`,
239
+ * `onnx-community/gemma-3-1b-it-ONNX`, `onnx-community/gemma-4-E2B-it-ONNX`).
240
+ *
241
+ * Mode selection (this function) and LOADER selection (`isConditionalGenerationConfig`,
242
+ * still architecture-keyed) are deliberately independent: a CausalLM-architecture model with
243
+ * a chat template loads via the base-mode loader (`AutoTokenizer` + `AutoModelForCausalLM`)
244
+ * but scores via the template-mode scorer — e.g. Llama-3.2-1B-Instruct.
245
+ */
246
+ export function hasChatTemplate(tokenizer: MinimalChatTemplateTokenizer): boolean {
247
+ return typeof tokenizer.chat_template === "string" && tokenizer.chat_template.length > 0;
248
+ }