@sligqoer/dsh-usage-stats 0.2.0 → 0.2.2

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 (51) hide show
  1. package/CHANGELOG.md +89 -1
  2. package/NOTICE +39 -0
  3. package/README.en.md +187 -0
  4. package/README.md +93 -263
  5. package/lib/bridge.js +33 -0
  6. package/lib/client.js +2321 -1579
  7. package/lib/index.js +173 -197
  8. package/lib/lm-fingerprint-engine.js +166 -0
  9. package/lib/lm-fingerprint-source.js +134 -0
  10. package/lib/lm-fingerprint-worker.js +31 -0
  11. package/lib/{model-test-history.js → model-detection-history.js} +23 -30
  12. package/lib/model-detection.js +255 -0
  13. package/lib/quota-controls.js +64 -23
  14. package/lib/supplemental-usage.js +77 -0
  15. package/lib/usage-preferences.js +51 -0
  16. package/locale/en.json +6 -0
  17. package/locale/ja.json +6 -0
  18. package/locale/zh.json +6 -0
  19. package/package.json +28 -25
  20. package/screenshots/controls.png +0 -0
  21. package/screenshots/detection.png +0 -0
  22. package/screenshots/quotas.png +0 -0
  23. package/screenshots/usage.png +0 -0
  24. package/third_party/brand-icons/CHANNEL-ICONS-LICENSE +21 -0
  25. package/third_party/brand-icons/LICENSE +59 -0
  26. package/third_party/brand-icons/NOTICE.txt +5 -0
  27. package/third_party/brand-icons/REACT-ICONS-LICENSE +104 -0
  28. package/third_party/brand-icons/channel-icons/LICENSE +21 -0
  29. package/third_party/brand-icons/channel-icons/UPSTREAM.json +6 -0
  30. package/third_party/brand-icons/lobe-icons/LICENSE +21 -0
  31. package/third_party/brand-icons/lobe-icons/UPSTREAM.json +16 -0
  32. package/third_party/brand-icons/react-icons/LICENSE +13 -0
  33. package/third_party/brand-icons/react-icons/UPSTREAM.json +6 -0
  34. package/third_party/lm-fingerprint/LICENSE +21 -0
  35. package/third_party/lm-fingerprint/UPSTREAM.json +32 -0
  36. package/third_party/lm-fingerprint/core/challenge-browser.mjs +210 -0
  37. package/third_party/lm-fingerprint/core/detector-codec.mjs +20 -0
  38. package/third_party/lm-fingerprint/core/fingerprint-core.mjs +121 -0
  39. package/third_party/lm-fingerprint/core/positional.mjs +64 -0
  40. package/third_party/lm-fingerprint/core/shared-detector.mjs +132 -0
  41. package/third_party/lm-fingerprint/data/shared_detector.bin +0 -0
  42. package/third_party/lm-fingerprint/data/unified_bank.json +826820 -0
  43. package/README.zh-CN.md +0 -357
  44. package/lib/fingerprints/KBF-LICENSE.txt +0 -201
  45. package/lib/fingerprints/NOTICE.txt +0 -6
  46. package/lib/fingerprints/kbf-202609.json +0 -1
  47. package/lib/model-capabilities.js +0 -41
  48. package/lib/model-fingerprints.js +0 -146
  49. package/lib/model-match.js +0 -407
  50. package/screenshots/match.png +0 -0
  51. package/screenshots/token-speed.png +0 -0
@@ -0,0 +1,210 @@
1
+ function randomIndex(length) {
2
+ const limit = Math.floor(0x100000000 / length) * length;
3
+ const buffer = new Uint32Array(1);
4
+ do
5
+ crypto.getRandomValues(buffer);
6
+ while (buffer[0] >= limit);
7
+ return buffer[0] % length;
8
+ }
9
+ function choose(values) {
10
+ return values[randomIndex(values.length)];
11
+ }
12
+ function uniqueLengths(count, usedLengths) {
13
+ const available = Array.from({ length: 41 }, (_, index) => 292 + index).filter(length => !usedLengths.includes(length));
14
+ const output = [];
15
+ while (output.length < count)
16
+ output.push(available.splice(randomIndex(available.length), 1)[0]);
17
+ return output;
18
+ }
19
+ /** Equivalent instructions per language; every challenge draws its language independently. */
20
+ const LANGUAGES = {
21
+ zh: {
22
+ space: "",
23
+ openings: [
24
+ "这是一次独立的数值选择记录。",
25
+ "请完成下面的无语义整数选择任务。",
26
+ "执行一次第一反应取值记录。",
27
+ "生成一组不承载语义的整数选择。",
28
+ "进行一轮快速逐项取值。",
29
+ ],
30
+ actions: [
31
+ "为各个位置分别凭第一反应选择",
32
+ "逐项选择",
33
+ "每次只决定当前一项,共给出",
34
+ "分别凭第一反应给出",
35
+ "逐个直接选择",
36
+ ],
37
+ request: (action, count) => `${action} ${count} 个 1 到 355(含端点)的整数。`,
38
+ rules: "每个位置都要单独选择;不要从 1 开始计数,不要连续递增或递减,也不要采用等差、循环、重复区块或其他规则化模式。"
39
+ + "本任务必须由当前语言模型直接完成:禁止调用或借助任何工具,包括 Python、代码执行器、计算器、搜索、API 和外部随机数生成器;也不要先编写或运行代码。",
40
+ endings: [
41
+ "允许某个数字再次出现;每项写出后不要回头排序、去重或替换。",
42
+ "偶然重复是有效的;不要重新排列或修正已经写出的项目。",
43
+ "相同值可以再次出现;输出过程中不要整理或改写前面的项目。",
44
+ "重复值无需删除;不要筛选、重排或补成某种规律。",
45
+ "不必赋予数字任何含义;已经给出的值保持不变。",
46
+ ],
47
+ separators: [
48
+ "数字之间用逗号或空格分隔均可。",
49
+ "使用一种一致的常见分隔符即可。",
50
+ "可以用逗号、空格或换行分隔。",
51
+ "只要每个整数边界清楚,格式可自行选择。",
52
+ ],
53
+ start: "直接从第一个取值开始输出,不要在序列前重复数量、范围或任务说明。",
54
+ },
55
+ en: {
56
+ space: " ",
57
+ openings: [
58
+ "This is an independent record of numerical choices.",
59
+ "Complete the following integer-choice task, which carries no semantic meaning.",
60
+ "Record a round of first-instinct choices.",
61
+ "Generate a set of integer choices that carry no meaning.",
62
+ "Make a quick round of item-by-item choices.",
63
+ ],
64
+ actions: [
65
+ "For each position, choose separately by first instinct",
66
+ "Choose, item by item,",
67
+ "Decide only the current item each time, giving a total of",
68
+ "Give, each by first instinct,",
69
+ "Choose directly, one by one,",
70
+ ],
71
+ request: (action, count) => `${action} ${count} integers from 1 to 355, inclusive.`,
72
+ rules: "Choose separately for every position; do not count up from 1, do not increase or decrease consecutively, and do not use arithmetic progressions, cycles, repeated blocks, or other systematic patterns. "
73
+ + "The current language model must complete this task directly: do not call or use any tools, including Python, code execution tools, calculators, search, APIs, or external random number generators; do not write or run code beforehand either.",
74
+ endings: [
75
+ "A number may appear again; after writing each item, do not go back to sort, deduplicate, or replace it.",
76
+ "Accidental repetitions are valid; do not rearrange or correct items already written.",
77
+ "The same value may appear again; do not organize or rewrite earlier items while producing the output.",
78
+ "Repeated values do not need to be removed; do not filter, reorder, or fill the sequence into a pattern.",
79
+ "Do not assign meaning to the numbers; keep values already given unchanged.",
80
+ ],
81
+ separators: [
82
+ "Separate the numbers with commas or spaces.",
83
+ "Use one consistent common separator.",
84
+ "You may separate the numbers with commas, spaces, or line breaks.",
85
+ "Choose any format that clearly separates each integer.",
86
+ ],
87
+ start: "Start the output directly with the first chosen value; do not repeat the count, range, or task instructions before the sequence.",
88
+ },
89
+ ja: {
90
+ space: "",
91
+ openings: [
92
+ "これは独立した数値選択の記録です。",
93
+ "意味を持たない次の整数選択課題を完了してください。",
94
+ "直感で選んだ値を記録してください。",
95
+ "意味を持たない整数の選択を一組生成してください。",
96
+ "一項目ずつ素早く値を選んでください。",
97
+ ],
98
+ actions: [
99
+ "各位置について個別に直感で、",
100
+ "一項目ずつ、",
101
+ "毎回その時点の一項目だけを決めて、",
102
+ "それぞれ直感で、",
103
+ "一つずつ直接、",
104
+ ],
105
+ request: (action, count) => `${action}1 から 355 まで(両端を含む)の整数を ${count} 個選んでください。`,
106
+ rules: "各位置の値は個別に選んでください。1 から順に数えたり、連続して増加または減少させたりせず、等差数列、周期、同じブロックの繰り返し、その他の規則的なパターンも使わないでください。"
107
+ + "この課題は現在の言語モデル自身が直接行ってください。Python、コード実行ツール、計算機、検索、API、外部の乱数生成器など、いかなるツールも呼び出したり利用したりしないでください。事前にコードを書いたり実行したりすることも禁止します。",
108
+ endings: [
109
+ "同じ数字が再び現れてもかまいません。各項目を書いた後に戻って、並べ替え、重複の削除、置き換えをしないでください。",
110
+ "偶然の重複は有効です。すでに書いた項目を並べ替えたり修正したりしないでください。",
111
+ "同じ値が再び出てもかまいません。出力の途中で前の項目を整理したり書き換えたりしないでください。",
112
+ "重複した値を削除する必要はありません。選別、並べ替え、規則的なパターンへの補完をしないでください。",
113
+ "数字に意味を持たせる必要はありません。すでに出した値は変えないでください。",
114
+ ],
115
+ separators: [
116
+ "数字はコンマまたは空白で区切ってください。",
117
+ "一般的な区切り文字を一種類、統一して使ってください。",
118
+ "コンマ、空白、改行のいずれかで数字を区切ってください。",
119
+ "各整数の区切りが明確であれば、形式は自由です。",
120
+ ],
121
+ start: "選んだ最初の値から直接出力を始め、数列の前に個数、範囲、課題の説明を繰り返さないでください。",
122
+ },
123
+ ko: {
124
+ space: " ",
125
+ openings: [
126
+ "이것은 독립적인 수치 선택 기록입니다.",
127
+ "다음의 의미 없는 정수 선택 과제를 완료하세요.",
128
+ "첫 직감에 따른 값 선택을 한 번 기록하세요.",
129
+ "의미를 담지 않은 정수 선택을 한 세트 생성하세요.",
130
+ "항목별로 빠르게 값을 고르는 라운드를 진행하세요.",
131
+ ],
132
+ actions: [
133
+ "각 위치마다 첫 직감으로 따로",
134
+ "항목별로",
135
+ "매번 현재 항목 하나만 정하면서",
136
+ "각각 첫 직감으로",
137
+ "하나씩 바로",
138
+ ],
139
+ request: (action, count) => `${action} 1부터 355까지(양 끝 포함)의 정수 ${count}개를 고르세요.`,
140
+ rules: "각 위치의 값은 따로 고르세요. 1부터 차례로 세지 말고, 연속으로 증가하거나 감소하지 말며, 등차수열, 순환, 반복되는 블록 또는 그 밖의 규칙적인 패턴도 사용하지 마세요. "
141
+ + "이 과제는 현재 언어 모델이 직접 수행해야 합니다. Python, 코드 실행기, 계산기, 검색, API, 외부 난수 생성기를 포함한 어떤 도구도 호출하거나 이용하지 마세요. 미리 코드를 작성하거나 실행하지도 마세요.",
142
+ endings: [
143
+ "같은 숫자가 다시 나와도 됩니다. 각 항목을 쓴 뒤 되돌아가서 정렬하거나 중복을 제거하거나 바꾸지 마세요.",
144
+ "우연한 중복도 유효합니다. 이미 쓴 항목을 다시 배열하거나 고치지 마세요.",
145
+ "같은 값이 다시 나와도 됩니다. 출력하는 동안 앞의 항목을 정리하거나 고쳐 쓰지 마세요.",
146
+ "중복된 값을 지울 필요는 없습니다. 걸러 내거나 다시 배열하거나 어떤 규칙에 맞춰 채우지 마세요.",
147
+ "숫자에 어떤 의미도 부여할 필요가 없습니다. 이미 제시한 값은 그대로 두세요.",
148
+ ],
149
+ separators: [
150
+ "숫자는 쉼표나 공백으로 구분하면 됩니다.",
151
+ "일반적인 구분 기호 하나를 일관되게 사용하세요.",
152
+ "쉼표, 공백 또는 줄바꿈으로 구분할 수 있습니다.",
153
+ "각 정수의 경계만 분명하면 형식은 자유롭게 정해도 됩니다.",
154
+ ],
155
+ start: "고른 첫 번째 값부터 바로 출력하고, 수열 앞에 개수, 범위 또는 과제 설명을 반복하지 마세요.",
156
+ },
157
+ fr: {
158
+ space: " ",
159
+ openings: [
160
+ "Ceci est un relevé indépendant de choix numériques.",
161
+ "Veuillez accomplir la tâche suivante de choix d’entiers sans signification sémantique.",
162
+ "Consignez une série de choix faits au premier réflexe.",
163
+ "Générez une série de choix d’entiers dépourvus de sens.",
164
+ "Effectuez une série rapide de choix, élément par élément.",
165
+ ],
166
+ actions: [
167
+ "Pour chaque position, choisissez séparément au premier réflexe",
168
+ "Choisissez, élément par élément,",
169
+ "À chaque étape, décidez uniquement de la valeur actuelle, pour donner au total",
170
+ "Donnez séparément au premier réflexe",
171
+ "Choisissez directement, un par un,",
172
+ ],
173
+ request: (action, count) => `${action} ${count} entiers compris entre 1 et 355, bornes incluses.`,
174
+ rules: "Choisissez séparément pour chaque position ; ne comptez pas à partir de 1, ne produisez pas de valeurs consécutives croissantes ou décroissantes, et ne suivez pas de progression arithmétique, de cycle, de blocs répétés ou d’autre motif systématique. "
175
+ + "Le modèle de langage actuel doit accomplir cette tâche directement : n’appelez et n’utilisez aucun outil, notamment Python, un exécuteur de code, une calculatrice, une recherche, une API ou un générateur externe de nombres aléatoires ; n’écrivez et n’exécutez pas de code au préalable.",
176
+ endings: [
177
+ "Un nombre peut réapparaître ; après avoir écrit chaque élément, ne revenez pas en arrière pour le trier, supprimer les doublons ou le remplacer.",
178
+ "Les répétitions accidentelles sont valides ; ne réorganisez et ne corrigez pas les éléments déjà écrits.",
179
+ "Une même valeur peut réapparaître ; pendant la sortie, ne réorganisez et ne réécrivez pas les éléments précédents.",
180
+ "Les valeurs répétées n’ont pas besoin d’être supprimées ; ne filtrez pas, ne réordonnez pas et ne complétez pas la séquence pour former un motif.",
181
+ "N’attribuez aucune signification aux nombres ; gardez inchangées les valeurs déjà données.",
182
+ ],
183
+ separators: [
184
+ "Séparez les nombres par des virgules ou des espaces.",
185
+ "Utilisez un séparateur courant et cohérent.",
186
+ "Vous pouvez séparer les nombres par des virgules, des espaces ou des retours à la ligne.",
187
+ "Choisissez librement le format, pourvu que la limite de chaque entier soit claire.",
188
+ ],
189
+ start: "Commencez directement par la première valeur choisie, sans répéter le nombre d’éléments, l’intervalle ou les consignes avant la séquence.",
190
+ },
191
+ };
192
+ const TEMPLATES = Object.values(LANGUAGES);
193
+ /** `usedLengths` keeps replacement prompts distinct from challenges that stay in the same round. */
194
+ export function generateChallenges(count = 3, usedLengths = []) {
195
+ return uniqueLengths(count, usedLengths).map((length, index) => {
196
+ const language = choose(TEMPLATES);
197
+ return {
198
+ id: `probe-${index + 1}-${crypto.randomUUID()}`,
199
+ expected_count: length,
200
+ prompt: [
201
+ choose(language.openings),
202
+ language.request(choose(language.actions), length),
203
+ language.rules,
204
+ choose(language.endings),
205
+ choose(language.separators),
206
+ language.start,
207
+ ].join(language.space),
208
+ };
209
+ });
210
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Reads `data/shared_detector.bin`, the one detector file shared by the CLI, the website and the evaluation scripts.
3
+ * `encode_detector` in `offline/export.py` writes it. Layout: u32 header length (little-endian), UTF-8 JSON header,
4
+ * zero padding to 4 bytes, then float32 values. In the header every array of numbers is `{"$f32":offset,"n":length}`;
5
+ * scalars keep full precision. The float bytes are grouped by position (all first bytes, then all second bytes, ...),
6
+ * which zstd compresses much better than interleaved floats; each float is little-endian.
7
+ */
8
+ export function decodeDetector(data) {
9
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
10
+ const headerLength = view.getUint32(0, true), start = Math.ceil((4 + headerLength) / 4) * 4;
11
+ const count = (data.length - start) / 4;
12
+ const raw = new Uint8Array(count * 4);
13
+ for (let i = 0; i < count; i++)
14
+ for (let b = 0; b < 4; b++)
15
+ raw[i * 4 + b] = data[start + b * count + i];
16
+ const little = new DataView(raw.buffer), floats = new Float32Array(count);
17
+ for (let i = 0; i < count; i++)
18
+ floats[i] = little.getFloat32(i * 4, true);
19
+ return JSON.parse(new TextDecoder().decode(data.subarray(4, 4 + headerLength)), (_, value) => value && typeof value === 'object' && '$f32' in value ? Array.from(floats.subarray(value.$f32, value.$f32 + value.n)) : value);
20
+ }
@@ -0,0 +1,121 @@
1
+ export const VALUE_MIN = 1;
2
+ export const VALUE_MAX = 355;
3
+ export const DIMENSION = VALUE_MAX - VALUE_MIN + 1;
4
+ export const ALPHA = 0.5;
5
+ export function parseNumbers(text) {
6
+ const runs = [];
7
+ let current = [];
8
+ let previousEnd = 0;
9
+ for (const match of String(text).matchAll(/\d+/g)) {
10
+ const separator = String(text).slice(previousEnd, match.index);
11
+ const value = Number(match[0]);
12
+ if (current.length && /\p{L}/u.test(separator)) {
13
+ runs.push(current);
14
+ current = [];
15
+ }
16
+ if (value >= VALUE_MIN && value <= VALUE_MAX)
17
+ current.push(value);
18
+ previousEnd = match.index + match[0].length;
19
+ }
20
+ if (current.length)
21
+ runs.push(current);
22
+ return runs.reduce((best, run) => run.length > best.length ? run : best, []);
23
+ }
24
+ export function countNumbers(numbers) {
25
+ const counts = Array(DIMENSION).fill(0);
26
+ numbers.forEach((number) => { counts[number - VALUE_MIN] += 1; });
27
+ return counts;
28
+ }
29
+ function mean(values) {
30
+ return values.reduce((total, value) => total + value, 0) / values.length;
31
+ }
32
+ function standardize(values) {
33
+ const center = mean(values);
34
+ const variance = mean(values.map((value) => (value - center) ** 2));
35
+ const scale = Math.max(Math.sqrt(variance), 1e-12);
36
+ return values.map((value) => (value - center) / scale);
37
+ }
38
+ function dot(left, right) {
39
+ let value = 0;
40
+ for (let index = 0; index < left.length; index += 1)
41
+ value += left[index] * right[index];
42
+ return value;
43
+ }
44
+ function norm(values) {
45
+ return Math.sqrt(dot(values, values));
46
+ }
47
+ function normalized(values) {
48
+ const scale = Math.max(norm(values), 1e-12);
49
+ return values.map((value) => value / scale);
50
+ }
51
+ function subtractBasis(values, basis) {
52
+ const output = values.slice();
53
+ for (const vector of basis || []) {
54
+ const projection = dot(output, vector);
55
+ for (let index = 0; index < output.length; index += 1)
56
+ output[index] -= projection * vector[index];
57
+ }
58
+ return output;
59
+ }
60
+ export function hellingerFeature(counts) {
61
+ const total = counts.reduce((sum, value) => sum + value, 0) + ALPHA * DIMENSION;
62
+ return counts.map((value) => Math.sqrt((value + ALPHA) / total));
63
+ }
64
+ function splitIntoFour(values) {
65
+ const base = Math.floor(values.length / 4);
66
+ const remainder = values.length % 4;
67
+ const chunks = [];
68
+ let start = 0;
69
+ for (let index = 0; index < 4; index += 1) {
70
+ const size = base + (index < remainder ? 1 : 0);
71
+ chunks.push(values.slice(start, start + size));
72
+ start += size;
73
+ }
74
+ return chunks;
75
+ }
76
+ export function orderedBlockFeature(numbers) {
77
+ const pieces = [];
78
+ for (const chunk of splitIntoFour(numbers)) {
79
+ const bins = Array(16).fill(0.5);
80
+ for (const value of chunk) {
81
+ const index = Math.min(15, Math.floor(((value - 1) / 355) * 16));
82
+ bins[index] += 1;
83
+ }
84
+ const total = bins.reduce((sum, value) => sum + value, 0);
85
+ pieces.push(...bins.map((value) => Math.sqrt(value / total)));
86
+ }
87
+ const lastDigits = Array(10).fill(0.5);
88
+ numbers.forEach((value) => { lastDigits[value % 10] += 1; });
89
+ const lastTotal = lastDigits.reduce((sum, value) => sum + value, 0);
90
+ pieces.push(...lastDigits.map((value) => Math.sqrt(value / lastTotal)));
91
+ return pieces;
92
+ }
93
+ function robustScoreCounts(counts, bank) {
94
+ const artifact = bank.robust.hellinger;
95
+ const feature = hellingerFeature(counts);
96
+ let projected = feature.map((value, index) => (value - artifact.feature_mean[index]) / artifact.feature_scale[index]);
97
+ projected = subtractBasis(projected, artifact.nuisance_basis);
98
+ projected = normalized(projected);
99
+ const scores = artifact.centroids.map((centroid) => dot(projected, centroid));
100
+ return standardize(scores);
101
+ }
102
+ function orderedBlockScores(numbers, bank) {
103
+ const artifact = bank.robust.ordered_blocks;
104
+ const feature = orderedBlockFeature(numbers);
105
+ const standardizedFeature = feature.map((value, index) => (value - artifact.feature_mean[index]) / artifact.feature_scale[index]);
106
+ const unit = normalized(standardizedFeature);
107
+ const environmentScores = artifact.environment_centroids.map((centroids) => centroids.map((centroid) => dot(unit, centroid)));
108
+ const template = standardize(artifact.centroids.map((_, modelIndex) => Math.max(...environmentScores.map((scores) => scores[modelIndex]))));
109
+ const projected = normalized(subtractBasis(standardizedFeature, artifact.nuisance_basis));
110
+ const nuisance = standardize(artifact.centroids.map((centroid) => dot(projected, centroid)));
111
+ return standardize(template.map((value, index) => 0.5 * value + 0.5 * nuisance[index]));
112
+ }
113
+ export function robustScoreNumbers(numbers, bank) {
114
+ const marginal = robustScoreCounts(countNumbers(numbers), bank);
115
+ const artifact = bank.robust.ordered_blocks;
116
+ const weight = artifact ? Number(artifact.weight || 0) : 0;
117
+ if (!artifact || weight === 0)
118
+ return marginal;
119
+ const ordered = orderedBlockScores(numbers, bank);
120
+ return marginal.map((value, index) => (1 - weight) * value + weight * ordered[index]);
121
+ }
@@ -0,0 +1,64 @@
1
+ import { countNumbers, hellingerFeature, orderedBlockFeature } from "./fingerprint-core.mjs";
2
+ /** Mirrors projects/offline/positional_core.py; block order and weights must match the export. */
3
+ const WEIGHTS = [.6, .15, .125, .125];
4
+ const CENTRES = Array.from({ length: 24 }, (_, i) => 1 + i * 354 / 23);
5
+ const LAGS = [1, 2, 3, 5, 8, 13, 21, 34];
6
+ const mean = (values) => values.reduce((sum, x) => sum + x, 0) / values.length;
7
+ const fraction = (length, test) => {
8
+ let hits = 0;
9
+ for (let i = 0; i < length; i++)
10
+ if (test(i))
11
+ hits++;
12
+ return hits / length;
13
+ };
14
+ function positionKernel(x) {
15
+ const n = x.length, out = Array(34 * 3).fill(0);
16
+ x.forEach((value, position) => {
17
+ const t = (position + .5) * 2 / n - 1, basis = [t, .5 * (3 * t * t - 1), .5 * (5 * t ** 3 - 3 * t)];
18
+ const features = [...CENTRES.map(centre => Math.exp(-.5 * ((value - centre) / 20) ** 2)),
19
+ ...Array.from({ length: 10 }, (_, digit) => digit === value % 10 ? 1 : 0)];
20
+ features.forEach((weight, k) => basis.forEach((b, j) => { out[k * 3 + j] += weight * b / n; }));
21
+ });
22
+ return out;
23
+ }
24
+ function centredTransitions(symbols, bins) {
25
+ const joint = Array(bins * bins).fill(.5), total = symbols.length - 1 + .5 * bins * bins;
26
+ for (let i = 1; i < symbols.length; i++)
27
+ joint[symbols[i - 1] * bins + symbols[i]] += 1;
28
+ for (let i = 0; i < joint.length; i++)
29
+ joint[i] /= total;
30
+ const rows = Array.from({ length: bins }, (_, a) => joint.slice(a * bins, a * bins + bins).reduce((s, v) => s + v, 0));
31
+ const columns = Array.from({ length: bins }, (_, b) => rows.reduce((s, _, a) => s + joint[a * bins + b], 0));
32
+ return joint.map((value, i) => value - rows[Math.floor(i / bins)] * columns[i % bins]);
33
+ }
34
+ function sequenceFeatures(x) {
35
+ const n = x.length, m = mean(x), sd = Math.max(Math.sqrt(mean(x.map(v => (v - m) ** 2))), 1);
36
+ const s = x.map(v => (v - m) / sd), out = [];
37
+ for (const lag of LAGS) {
38
+ const count = n - lag, d = Array.from({ length: count }, (_, i) => x[i + lag] - x[i]);
39
+ out.push(mean(d.map((_, i) => s[i + lag] * s[i])), mean(d.map(Math.abs)) / 355, fraction(count, i => d[i] > 0), fraction(count, i => d[i] === 0), fraction(count, i => Math.abs(d[i]) <= 5), fraction(count, i => Math.abs(d[i]) <= 20));
40
+ }
41
+ out.push(...centredTransitions(x.map(v => Math.min(7, Math.floor((v - 1) * 8 / 355))), 8));
42
+ out.push(...centredTransitions(x.map(v => v % 10), 10));
43
+ const difference = Array.from({ length: n - 1 }, (_, i) => x[i + 1] - x[i]);
44
+ const zStep = Array.from({ length: n - 1 }, (_, i) => Math.abs(s[i + 1] - s[i]));
45
+ out.push(fraction(n - 2, i => Math.sign(difference[i + 1]) === Math.sign(difference[i])), fraction(n - 2, i => difference[i + 1] === difference[i]), fraction(n - 1, i => x[i] % 10 === x[i + 1] % 10), fraction(n - 1, i => zStep[i] > 1), fraction(n - 1, i => zStep[i] > 2));
46
+ const buckets = x.map(v => Math.min(15, Math.floor((v - 1) * 16 / 355)));
47
+ for (const width of [4, 8, 16]) {
48
+ let sum = 0;
49
+ for (let i = 0; i + width <= n; i++)
50
+ sum += new Set(buckets.slice(i, i + width)).size / width;
51
+ out.push(sum / (n - width + 1));
52
+ }
53
+ return out;
54
+ }
55
+ /** Per-answer decision values over identities. */
56
+ export function positionalDecision(numbers, head) {
57
+ const parts = [hellingerFeature(countNumbers(numbers)), orderedBlockFeature(numbers), positionKernel(numbers), sequenceFeatures(numbers)];
58
+ const x = parts.flatMap((block, j) => {
59
+ const p = head.params[j], scaled = block.map((v, i) => (v - p.mean[i]) / p.scale[i]);
60
+ const length = Math.max(Math.sqrt(scaled.reduce((sum, v) => sum + v * v, 0)), 1e-12);
61
+ return scaled.map((v) => v / length * Math.sqrt(WEIGHTS[j]));
62
+ });
63
+ return head.weights.map((row, k) => row.reduce((sum, w, i) => sum + w * x[i], head.bias[k]));
64
+ }
@@ -0,0 +1,132 @@
1
+ import { countNumbers, hellingerFeature, orderedBlockFeature, parseNumbers } from "./fingerprint-core.mjs";
2
+ import { positionalDecision } from "./positional.mjs";
3
+ const dot = (a, b) => a.reduce((sum, x, i) => sum + x * b[i], 0);
4
+ const mean = (a) => a.reduce((sum, x) => sum + x, 0) / a.length;
5
+ const norm = (a) => { const n = Math.max(Math.sqrt(dot(a, a)), 1e-12); return a.map(x => x / n); };
6
+ const z = (a) => { const m = mean(a), s = Math.max(Math.sqrt(mean(a.map(x => (x - m) ** 2))), 1e-12); return a.map(x => (x - m) / s); };
7
+ const columnMean = (a) => a[0].map((_, i) => mean(a.map(r => r[i])));
8
+ const median = (a) => { const v = [...a].sort((x, y) => x - y), i = Math.floor(v.length / 2); return v.length % 2 ? v[i] : (v[i - 1] + v[i]) / 2; };
9
+ const blocks = (n) => [hellingerFeature(countNumbers(n)), orderedBlockFeature(n)];
10
+ const transform = (b, p) => b.flatMap((v, j) => norm(v.map((x, i) => (x - p[j].mean[i]) / p[j].scale[i])).map(x => x * Math.sqrt(j === 0 ? .75 : .25)));
11
+ const centered = (v, p) => v.map((x, i) => (x - p.feature_mean[i]) / p.feature_scale[i]);
12
+ const subtract = (v, b) => {
13
+ const coefficients = b.map(row => dot(v, row));
14
+ return v.map((x, i) => x - b.reduce((sum, row, j) => sum + coefficients[j] * row[i], 0));
15
+ };
16
+ const referenceNorms = new WeakMap();
17
+ function nearest(x, references) {
18
+ let norms = referenceNorms.get(references);
19
+ if (!norms) {
20
+ norms = references.map(r => dot(r, r));
21
+ referenceNorms.set(references, norms);
22
+ }
23
+ const xx = dot(x, x);
24
+ return mean(references.map((r, i) => Math.max(0, xx + norms[i] - 2 * dot(x, r))).sort((a, b) => a - b).slice(0, 7));
25
+ }
26
+ function baseline(b, bank) {
27
+ const h = bank.hellinger, o = bank.ordered_blocks;
28
+ const hUnit = norm(subtract(centered(b[0], h), h.nuisance_basis));
29
+ const marginal = h.centroids.map(row => dot(hUnit, row));
30
+ const oCentered = centered(b[1], o), raw = norm(oCentered), projected = norm(subtract(oCentered, o.nuisance_basis));
31
+ const templates = o.centroids.map((_, i) => Math.max(...o.environment_centroids.map(rows => dot(raw, rows[i]))));
32
+ const nuisance = o.centroids.map(row => dot(projected, row));
33
+ const tz = z(templates), nz = z(nuisance), ordered = z(tz.map((v, i) => .5 * v + .5 * nz[i])), mz = z(marginal);
34
+ return mz.map((v, i) => .5 * v + .5 * ordered[i]);
35
+ }
36
+ export function supportsSharedDetector(bank, artifact) {
37
+ return artifact.schema === 'shared-detector-v2' && bank.built_at === artifact.bank_built_at &&
38
+ bank.models.length === artifact.model_ids.length && bank.models.every((m, i) => m.id === artifact.model_ids[i] && m.response_count === artifact.response_counts[i]);
39
+ }
40
+ /**
41
+ * Closest models by the ranker's k-nearest-reference distance. Distances are relative to the
42
+ * model's own leave-one-out spread, so 1 means as close as the model's own references.
43
+ */
44
+ export function nearestModels(artifact, count = 5) {
45
+ const references = artifact.ranker.references;
46
+ return references.map((own, a) => {
47
+ const spread = median(own.map((x, i) => nearest(x, own.filter((_, j) => j !== i))));
48
+ return references.map((other, b) => ({ model: artifact.model_ids[b], distance: median(own.map(x => nearest(x, other))) / spread }))
49
+ .filter((_, b) => b !== a).sort((x, y) => x.distance - y.distance).slice(0, count);
50
+ });
51
+ }
52
+ /** Closed-set probabilities from the calibration bound to this ranker; null when none matches. */
53
+ export function calibrateRanking(ranking, artifact) {
54
+ const head = artifact.calibration, binding = head?.binding;
55
+ if (!head || !binding || head.schema !== 'shared-confidence-v2' || head.method !== 'ranking-temperature' ||
56
+ !Number.isFinite(head.tau) || head.tau < .001 || head.tau > 1000 ||
57
+ binding.base_sha256 !== artifact.base_sha256 || binding.reference_sha256 !== artifact.source_reference_sha256 ||
58
+ binding.model_ids.length !== ranking.length || ranking.length !== artifact.model_ids.length || !ranking.every(Number.isFinite) ||
59
+ !binding.model_ids.every((id, i) => id === artifact.model_ids[i]))
60
+ return null;
61
+ const logits = ranking.map(score => head.tau * score);
62
+ const maximum = Math.max(...logits), weights = logits.map(value => Math.exp(value - maximum)), sum = weights.reduce((a, b) => a + b, 0);
63
+ return weights.map(value => value / sum);
64
+ }
65
+ function rankSharedNumbers(numbers, artifact) {
66
+ const a = artifact.ranker, full = numbers.map(blocks);
67
+ const ax = full.map(b => transform(b, a.full_params));
68
+ const lda = numbers.map(n => {
69
+ const x = transform(blocks(n.slice(0, 128)), a.head_params);
70
+ return z(a.lda_weights.map((row, i) => dot(row, x) + a.lda_bias[i]));
71
+ });
72
+ const l = z(columnMean(lda)), near = z(a.references.map(ref => median(ax.map(x => -nearest(x, ref)))));
73
+ const base = z(columnMean(full.map(b => baseline(b, a.bank))));
74
+ const frequency = z(l.map((x, i) => .5 * x + .25 * near[i] + .25 * base[i]));
75
+ const positional = z(columnMean(numbers.map(n => z(positionalDecision(n, a.positional)))));
76
+ const ranking = frequency.map((x, i) => a.blend.frequency * x + a.blend.positional * positional[i]);
77
+ if (!ranking.every(Number.isFinite))
78
+ throw new Error('排名计算产生无效数值,请刷新后重试');
79
+ return ranking;
80
+ }
81
+ export function analyzeSharedOutputs(outputs, bank, artifact, options = {}) {
82
+ if (!supportsSharedDetector(bank, artifact))
83
+ throw new Error('The reference bank does not match the detector. Run fpd retrain --data-dir DIR for this bank.');
84
+ const parsed = outputs.map(o => parseNumbers(o.text));
85
+ const diagnostics = outputs.map((o, index) => {
86
+ const minimum = Math.max(80, Math.ceil((o.expected_count || 0) * .55));
87
+ const raw = Array.from(o.text.matchAll(/-?\d+/g), m => Number(m[0]));
88
+ return { index, parsed_numbers: parsed[index].length, minimum_numbers: minimum,
89
+ accepted: parsed[index].length >= minimum, raw_out_of_range_count: raw.filter(n => n < 1 || n > 355).length };
90
+ });
91
+ const used = diagnostics.filter(d => d.accepted).length;
92
+ const common = { probability: null, probability_status: 'unavailable',
93
+ used_outputs: used, diagnostics, method: 'shared-detector-v2',
94
+ model_version: { base_sha256: artifact.base_sha256 } };
95
+ if (options.allowPartial && used > 0 && used < 3 && outputs.length <= 3) {
96
+ const ranking = rankSharedNumbers(parsed.filter((_, i) => diagnostics[i].accepted), artifact);
97
+ const order = artifact.model_ids.map((_, i) => i).sort((i, j) => ranking[j] - ranking[i]);
98
+ const results = order.map(i => ({ model: artifact.model_ids[i], display_name: bank.models[i].display_name,
99
+ family: bank.models[i].family, family_name: bank.models[i].family_name, score: ranking[i], probability: null }));
100
+ const first = order[0];
101
+ return { ...common, method: 'shared-ranker-partial-v1', decision: 'partial',
102
+ prediction: results[0].model, prediction_name: results[0].display_name,
103
+ family_prediction: bank.models[first].family, family_prediction_name: bank.models[first].family_name,
104
+ results, ranking_score: ranking[first], calibration: null,
105
+ evidence: { insufficient: true, label: '部分样本排名',
106
+ reason: `使用 ${used}/3 条有效回答生成排名。补齐三条有效回答后才能计算置信度。`,
107
+ threshold: null, method: 'partial-sample-ranking' } };
108
+ }
109
+ if (outputs.length !== 3 || used !== 3)
110
+ return { ...common, prediction: '', prediction_name: '暂不可评分',
111
+ family_prediction_name: '', results: [], decision: 'unscorable', evidence: { insufficient: true,
112
+ label: '需要三条完整回答', reason: `当前有 ${used}/${outputs.length} 条有效回答。请补齐原来的三条回答后重新检测。`,
113
+ threshold: null, method: 'complete-three-answers' } };
114
+ const ranking = rankSharedNumbers(parsed, artifact);
115
+ const probabilities = calibrateRanking(ranking, artifact);
116
+ const order = artifact.model_ids.map((_, i) => i).sort((i, j) => ranking[j] - ranking[i]);
117
+ const results = order.map(i => ({ model: artifact.model_ids[i], display_name: bank.models[i].display_name,
118
+ family: bank.models[i].family, family_name: bank.models[i].family_name, score: ranking[i],
119
+ probability: probabilities?.[i] ?? null }));
120
+ const first = order[0];
121
+ return { ...common, prediction: results[0].model, prediction_name: results[0].display_name,
122
+ family_prediction: bank.models[first].family, family_prediction_name: bank.models[first].family_name,
123
+ results, ranking_score: ranking[first], probability: results[0].probability,
124
+ probability_status: probabilities ? 'reference_calibrated' : 'unavailable',
125
+ probability_scope: probabilities ? 'reference-closed-set' : null,
126
+ calibration: probabilities ? { method: artifact.calibration.method, run: artifact.calibration.calibration_run,
127
+ tau: artifact.calibration.tau, sha256: artifact.calibration_sha256 ?? null } : null,
128
+ evidence: { insufficient: true,
129
+ label: probabilities ? '库内校准排名' : '未校准排名',
130
+ reason: probabilities ? '置信度是参考库内的闭集相对概率,不包含库外模型。' : '校准参数与当前检测器不匹配,置信度不可用。候选顺序由排名分数决定。',
131
+ threshold: null, method: probabilities ? 'reference-calibrated-ranking' : 'uncalibrated-ranking' } };
132
+ }