@atlaskit/editor-plugin-autocomplete 3.1.0 → 3.3.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.
@@ -1,18 +1,27 @@
1
1
  /**
2
2
  * Local Slow Lane Client: On-device inference via @mlc-ai/web-llm.
3
3
  *
4
- * Drop-in replacement for the network-based slow-lane-client. Instead of
5
- * calling a backend API, this client uses MLC WebLLM to run a small language
6
- * model (SmolLM 135M) directly in the browser via WebGPU.
4
+ * Drop-in replacement for the network-based slow-lane-client. Instead of calling
5
+ * a backend API, this client runs two models in the browser via WebGPU, in a
6
+ * single MLCEngine, to reproduce the BE encoder's outputs on-device:
7
+ *
8
+ * - Causal LM (SmolLM2-135M-Instruct): one decode step per word boundary. A
9
+ * registered LogitProcessor captures the raw next-token logits, which
10
+ * `computeBePayload` turns into a whole-word `lm_logits` payload — a faithful
11
+ * port of the BE `CausalLMEncoder._get_top_k_probs` (masked softmax over the
12
+ * vocab's first-tokens, prefix expansion, L2 reservation, log-space pooling).
13
+ * - Semantic embedder (Snowflake Arctic Embed S): produces the real 384-d
14
+ * `semantic_vector`. Inputs are wrapped as passages (see `wrapForArctic`) so
15
+ * the runtime vector lands in the same space as the precomputed word bin.
7
16
  *
8
17
  * ── Why main thread (no Web Worker)? ─────────────────────────────────────
9
- * SmolLM 135M is small enough (~270 MB weights, 350-400 MB VRAM) that
10
- * WebGPU inference on the main thread is production-viable:
18
+ * The models are small enough (~640 MB combined VRAM) that WebGPU inference on
19
+ * the main thread is viable:
11
20
  *
12
21
  * - WebGPU GPU compute is inherently async (doesn't block the main thread)
13
- * - CPU overhead (tokenization + post-processing) is only 5-10 ms
14
- * - Single forward pass latency is 50-150 ms — well within autocomplete
15
- * expectations (~250 ms between word boundaries)
22
+ * - CPU overhead (BE-parity post-processing) is a few ms
23
+ * - Per-inference latency is well within autocomplete expectations
24
+ * (~250 ms between word boundaries)
16
25
  *
17
26
  * This avoids all the complexity of Web Workers:
18
27
  * - No CSP workarounds (blob URLs, inline scripts)
@@ -72,15 +81,93 @@ export interface LocalSlowLaneClient {
72
81
  setLmLogits: (logits: Record<string, number> | null) => void;
73
82
  updateContext: (text: string) => void;
74
83
  }
75
- export declare const LOCAL_MLC_MODEL_ID = "SmolLM2-135M-Instruct-q0f16-MLC";
76
- /** HF root for the default weights (includes `tensor-cache.json` for WebLLM 0.2+). */
77
- export declare const LOCAL_MLC_HF_MODEL_REPO = "https://huggingface.co/mlc-ai/SmolLM2-135M-Instruct-q0f16-MLC";
78
- export declare const LOCAL_MLC_MODEL_LIB_WASM_NAME = "SmolLM2-135M-Instruct-q0f16-ctx4k_cs1k-webgpu.wasm";
84
+ export declare const LOCAL_MLC_CAUSAL_MODEL_ID = "SmolLM2-135M-Instruct-q0f16-MLC";
85
+ /**
86
+ * MLC ID for the semantic embedder (Snowflake Arctic Embed S, batch=4 variant).
87
+ *
88
+ * The `-b4` suffix selects the prebuilt variant compiled for a max batch size of
89
+ * 4 (≈239 MB VRAM) rather than `-b32` (≈1023 MB VRAM). Autocomplete embeds one
90
+ * context at a time, so `-b4` is the right fit. This model IS in
91
+ * `prebuiltAppConfig.model_list` of web-llm 0.2.82 — no `customModelConfig` needed.
92
+ */
93
+ export declare const LOCAL_MLC_EMBEDDING_MODEL_ID = "snowflake-arctic-embed-s-q0f32-MLC-b4";
94
+ /**
95
+ * Wrap raw context text with BERT special tokens before embedding.
96
+ *
97
+ * web-llm's `EmbeddingPipeline` does NOT auto-prepend `[CLS]` / append `[SEP]`
98
+ * (the official MLC embeddings example wraps manually). The Python
99
+ * `sentence_transformers` side that generated the word-vector bin adds these
100
+ * inside `model.encode()`, so we must mirror it here for the runtime context
101
+ * vector to land in the same region of Arctic's embedding space as the bin.
102
+ *
103
+ * No query prefix is applied: the semantic step is sentence-to-sentence (`s2s`)
104
+ * similarity ("which words are conceptually similar to this context?"), not
105
+ * sentence-to-passage (`s2p`) retrieval. Arctic's query prefix would misframe
106
+ * the relationship. Encode both sides as passages. See implementation.md §4.3.
107
+ */
108
+ export declare const wrapForArctic: (text: string) => string;
109
+ /**
110
+ * BE-parity constants — must match `CausalLMEncoder` defaults in the Python
111
+ * sidecar (`cc-smarts/python-sidecar/src/causal_lm_encoder.py`) and
112
+ * `SlowLaneEngine` (`typeahead_context_encoding.py`) so local payloads behave
113
+ * identically to the server-client setup.
114
+ */
115
+ export declare const BE_PARITY: {
116
+ /** Final payload size cap (BE: `top_k_words`). */
117
+ readonly TOP_K_WORDS: 2000;
118
+ /** L2 (domain) words admitted unconditionally before pooling (BE: `reserved_l2_slots`). */
119
+ readonly RESERVED_L2_SLOTS: 500;
120
+ /** Log-space additive bias favouring L2 over L3 in the pool (BE: `l2_bias`). */
121
+ readonly L2_BIAS: 1;
122
+ /** Drop words below this probability from the final payload (BE: `> 0.00001`). */
123
+ readonly MIN_PROB: 0.00001;
124
+ /**
125
+ * Word-level approximation of the BE causal LM token limit.
126
+ *
127
+ * BE: `CausalLMEncoder.max_context_tokens = 100` (BPE tokens, left-truncated).
128
+ * FE: no tokenizer available, so we approximate with word count. English text
129
+ * averages ~1.3–1.5 BPE tokens/word, meaning 100 words ≈ 130–150 tokens.
130
+ * Using 100 words keeps the approximation simple and errs on the side of
131
+ * sending slightly more context than the BE sees — acceptable for a PoC.
132
+ */
133
+ readonly MAX_CONTEXT_TOKENS: 100;
134
+ /**
135
+ * Word-level rolling window for the semantic embedder.
136
+ *
137
+ * BE: `SlowLaneEngine.max_context_words = 100` (applied in
138
+ * `typeahead_context_encoding.py` before calling `SemanticEncoder.encode`).
139
+ * Truncated identically here so the runtime Arctic vector lands in the same
140
+ * region of the embedding space as the precomputed word-vector bin.
141
+ */
142
+ readonly MAX_CONTEXT_WORDS: 100;
143
+ };
144
+ /**
145
+ * Convert a raw next-token logit vector into a whole-word probability payload,
146
+ * faithfully porting the BE `CausalLMEncoder._get_top_k_probs`
147
+ * (`cc-smarts/python-sidecar/src/causal_lm_encoder.py`).
148
+ *
149
+ * Steps: (1) numerically-stable masked softmax over only the token ids present
150
+ * in the prefix-expansion map; (2) spread each token's probability to every
151
+ * whole word sharing that first token, taking the max; (3) reserve the top L2
152
+ * words unconditionally; (4) rank the remainder in a log-space pool with an
153
+ * additive L2 bias; (5) emit raw probabilities for the survivors, lowercased
154
+ * and trimmed at `MIN_PROB`.
155
+ *
156
+ * :params:
157
+ * rawLogits: Full-vocabulary logits from the LM's single decode step
158
+ * prefixMap: Map of first-token id to the words starting with that token
159
+ * domainWords: Set of L2 (domain) words, for tier-aware ranking
160
+ * :returns:
161
+ * A record of lowercase word to probability — the BE `lm_logits` payload
162
+ */
163
+ export declare const computeBePayload: (rawLogits: Float32Array, prefixMap: Map<number, string[]>, domainWords: Set<string>,
79
164
  /**
80
- * Original target repo (add-basics fine-tune). **Not compatible with WebLLM 0.2.x** (no `tensor-cache.json`).
81
- * @see module doc above
165
+ * Pre-derived token-ID array for the softmax mask. Defaults to the
166
+ * module-level `prefixMapTokenIds` (zero allocation in production). Pass
167
+ * `Array.from(prefixMap.keys())` in tests that supply a custom prefixMap so
168
+ * the softmax mask stays consistent with the iteration in Step 2.
82
169
  */
83
- export declare const HUGGINGFACE_TB_SMOLLM_ADD_BASICS_REPO = "https://huggingface.co/HuggingFaceTB/smollm-135M-instruct-add-basics-q0f16-MLC";
170
+ validTokenIds?: number[]) => Record<string, number>;
84
171
  /**
85
172
  * Create a local slow-lane client powered by MLC WebLLM.
86
173
  *
@@ -1,18 +1,27 @@
1
1
  /**
2
2
  * Local Slow Lane Client: On-device inference via @mlc-ai/web-llm.
3
3
  *
4
- * Drop-in replacement for the network-based slow-lane-client. Instead of
5
- * calling a backend API, this client uses MLC WebLLM to run a small language
6
- * model (SmolLM 135M) directly in the browser via WebGPU.
4
+ * Drop-in replacement for the network-based slow-lane-client. Instead of calling
5
+ * a backend API, this client runs two models in the browser via WebGPU, in a
6
+ * single MLCEngine, to reproduce the BE encoder's outputs on-device:
7
+ *
8
+ * - Causal LM (SmolLM2-135M-Instruct): one decode step per word boundary. A
9
+ * registered LogitProcessor captures the raw next-token logits, which
10
+ * `computeBePayload` turns into a whole-word `lm_logits` payload — a faithful
11
+ * port of the BE `CausalLMEncoder._get_top_k_probs` (masked softmax over the
12
+ * vocab's first-tokens, prefix expansion, L2 reservation, log-space pooling).
13
+ * - Semantic embedder (Snowflake Arctic Embed S): produces the real 384-d
14
+ * `semantic_vector`. Inputs are wrapped as passages (see `wrapForArctic`) so
15
+ * the runtime vector lands in the same space as the precomputed word bin.
7
16
  *
8
17
  * ── Why main thread (no Web Worker)? ─────────────────────────────────────
9
- * SmolLM 135M is small enough (~270 MB weights, 350-400 MB VRAM) that
10
- * WebGPU inference on the main thread is production-viable:
18
+ * The models are small enough (~640 MB combined VRAM) that WebGPU inference on
19
+ * the main thread is viable:
11
20
  *
12
21
  * - WebGPU GPU compute is inherently async (doesn't block the main thread)
13
- * - CPU overhead (tokenization + post-processing) is only 5-10 ms
14
- * - Single forward pass latency is 50-150 ms — well within autocomplete
15
- * expectations (~250 ms between word boundaries)
22
+ * - CPU overhead (BE-parity post-processing) is a few ms
23
+ * - Per-inference latency is well within autocomplete expectations
24
+ * (~250 ms between word boundaries)
16
25
  *
17
26
  * This avoids all the complexity of Web Workers:
18
27
  * - No CSP workarounds (blob URLs, inline scripts)
@@ -72,15 +81,93 @@ export interface LocalSlowLaneClient {
72
81
  setLmLogits: (logits: Record<string, number> | null) => void;
73
82
  updateContext: (text: string) => void;
74
83
  }
75
- export declare const LOCAL_MLC_MODEL_ID = "SmolLM2-135M-Instruct-q0f16-MLC";
76
- /** HF root for the default weights (includes `tensor-cache.json` for WebLLM 0.2+). */
77
- export declare const LOCAL_MLC_HF_MODEL_REPO = "https://huggingface.co/mlc-ai/SmolLM2-135M-Instruct-q0f16-MLC";
78
- export declare const LOCAL_MLC_MODEL_LIB_WASM_NAME = "SmolLM2-135M-Instruct-q0f16-ctx4k_cs1k-webgpu.wasm";
84
+ export declare const LOCAL_MLC_CAUSAL_MODEL_ID = "SmolLM2-135M-Instruct-q0f16-MLC";
85
+ /**
86
+ * MLC ID for the semantic embedder (Snowflake Arctic Embed S, batch=4 variant).
87
+ *
88
+ * The `-b4` suffix selects the prebuilt variant compiled for a max batch size of
89
+ * 4 (≈239 MB VRAM) rather than `-b32` (≈1023 MB VRAM). Autocomplete embeds one
90
+ * context at a time, so `-b4` is the right fit. This model IS in
91
+ * `prebuiltAppConfig.model_list` of web-llm 0.2.82 — no `customModelConfig` needed.
92
+ */
93
+ export declare const LOCAL_MLC_EMBEDDING_MODEL_ID = "snowflake-arctic-embed-s-q0f32-MLC-b4";
94
+ /**
95
+ * Wrap raw context text with BERT special tokens before embedding.
96
+ *
97
+ * web-llm's `EmbeddingPipeline` does NOT auto-prepend `[CLS]` / append `[SEP]`
98
+ * (the official MLC embeddings example wraps manually). The Python
99
+ * `sentence_transformers` side that generated the word-vector bin adds these
100
+ * inside `model.encode()`, so we must mirror it here for the runtime context
101
+ * vector to land in the same region of Arctic's embedding space as the bin.
102
+ *
103
+ * No query prefix is applied: the semantic step is sentence-to-sentence (`s2s`)
104
+ * similarity ("which words are conceptually similar to this context?"), not
105
+ * sentence-to-passage (`s2p`) retrieval. Arctic's query prefix would misframe
106
+ * the relationship. Encode both sides as passages. See implementation.md §4.3.
107
+ */
108
+ export declare const wrapForArctic: (text: string) => string;
109
+ /**
110
+ * BE-parity constants — must match `CausalLMEncoder` defaults in the Python
111
+ * sidecar (`cc-smarts/python-sidecar/src/causal_lm_encoder.py`) and
112
+ * `SlowLaneEngine` (`typeahead_context_encoding.py`) so local payloads behave
113
+ * identically to the server-client setup.
114
+ */
115
+ export declare const BE_PARITY: {
116
+ /** Final payload size cap (BE: `top_k_words`). */
117
+ readonly TOP_K_WORDS: 2000;
118
+ /** L2 (domain) words admitted unconditionally before pooling (BE: `reserved_l2_slots`). */
119
+ readonly RESERVED_L2_SLOTS: 500;
120
+ /** Log-space additive bias favouring L2 over L3 in the pool (BE: `l2_bias`). */
121
+ readonly L2_BIAS: 1;
122
+ /** Drop words below this probability from the final payload (BE: `> 0.00001`). */
123
+ readonly MIN_PROB: 0.00001;
124
+ /**
125
+ * Word-level approximation of the BE causal LM token limit.
126
+ *
127
+ * BE: `CausalLMEncoder.max_context_tokens = 100` (BPE tokens, left-truncated).
128
+ * FE: no tokenizer available, so we approximate with word count. English text
129
+ * averages ~1.3–1.5 BPE tokens/word, meaning 100 words ≈ 130–150 tokens.
130
+ * Using 100 words keeps the approximation simple and errs on the side of
131
+ * sending slightly more context than the BE sees — acceptable for a PoC.
132
+ */
133
+ readonly MAX_CONTEXT_TOKENS: 100;
134
+ /**
135
+ * Word-level rolling window for the semantic embedder.
136
+ *
137
+ * BE: `SlowLaneEngine.max_context_words = 100` (applied in
138
+ * `typeahead_context_encoding.py` before calling `SemanticEncoder.encode`).
139
+ * Truncated identically here so the runtime Arctic vector lands in the same
140
+ * region of the embedding space as the precomputed word-vector bin.
141
+ */
142
+ readonly MAX_CONTEXT_WORDS: 100;
143
+ };
144
+ /**
145
+ * Convert a raw next-token logit vector into a whole-word probability payload,
146
+ * faithfully porting the BE `CausalLMEncoder._get_top_k_probs`
147
+ * (`cc-smarts/python-sidecar/src/causal_lm_encoder.py`).
148
+ *
149
+ * Steps: (1) numerically-stable masked softmax over only the token ids present
150
+ * in the prefix-expansion map; (2) spread each token's probability to every
151
+ * whole word sharing that first token, taking the max; (3) reserve the top L2
152
+ * words unconditionally; (4) rank the remainder in a log-space pool with an
153
+ * additive L2 bias; (5) emit raw probabilities for the survivors, lowercased
154
+ * and trimmed at `MIN_PROB`.
155
+ *
156
+ * :params:
157
+ * rawLogits: Full-vocabulary logits from the LM's single decode step
158
+ * prefixMap: Map of first-token id to the words starting with that token
159
+ * domainWords: Set of L2 (domain) words, for tier-aware ranking
160
+ * :returns:
161
+ * A record of lowercase word to probability — the BE `lm_logits` payload
162
+ */
163
+ export declare const computeBePayload: (rawLogits: Float32Array, prefixMap: Map<number, string[]>, domainWords: Set<string>,
79
164
  /**
80
- * Original target repo (add-basics fine-tune). **Not compatible with WebLLM 0.2.x** (no `tensor-cache.json`).
81
- * @see module doc above
165
+ * Pre-derived token-ID array for the softmax mask. Defaults to the
166
+ * module-level `prefixMapTokenIds` (zero allocation in production). Pass
167
+ * `Array.from(prefixMap.keys())` in tests that supply a custom prefixMap so
168
+ * the softmax mask stays consistent with the iteration in Step 2.
82
169
  */
83
- export declare const HUGGINGFACE_TB_SMOLLM_ADD_BASICS_REPO = "https://huggingface.co/HuggingFaceTB/smollm-135M-instruct-add-basics-q0f16-MLC";
170
+ validTokenIds?: number[]) => Record<string, number>;
84
171
  /**
85
172
  * Create a local slow-lane client powered by MLC WebLLM.
86
173
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atlaskit/editor-plugin-autocomplete",
3
- "version": "3.1.0",
3
+ "version": "3.3.0",
4
4
  "description": "Client-side text autocomplete plugin for @atlaskit/editor-core",
5
5
  "author": "Atlassian Pty Ltd",
6
6
  "license": "Apache-2.0",
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Offline build script: generate `first_token_to_words.json`.
4
+
5
+ This is a ONE-TIME / build-time tool. It is never imported by the plugin and
6
+ never runs in CI. It reproduces the prefix-expansion map that the BE sidecar
7
+ builds in-memory at runtime (`cc-smarts/python-sidecar/src/causal_lm_encoder.py`
8
+ `CausalLMEncoder._ensure_loaded`), so the local (client-only) slow-lane client
9
+ can ship it as a static artifact instead of running a tokenizer in the browser.
10
+
11
+ The output maps each SmolLM2 first-token id to every L2/L3 vocabulary word whose
12
+ space-prefixed encoding starts with that token. The local client loads it and,
13
+ per inference, spreads the next-token logit mass over whole words (masked softmax
14
+ + prefix expansion) to match the BE's whole-word `lm_logits` payload.
15
+
16
+ Three details MUST match the BE exactly, or the map is silently wrong:
17
+ 1. Tokenizer = HuggingFaceTB/SmolLM2-135M (base; vocab identical to Instruct).
18
+ 2. Leading space: encode(" " + word) — BPE tokenizes " word" != "word".
19
+ 3. add_special_tokens=False — no BOS/EOS, we want the word's own first token.
20
+
21
+ Usage:
22
+ pip install transformers # torch NOT required (SmolLM2 uses a fast tokenizer)
23
+ python scripts/gen_first_token_to_words.py
24
+
25
+ Run it from anywhere — paths are resolved relative to this file's location.
26
+ """
27
+
28
+ import os
29
+ import json
30
+ import collections
31
+
32
+ from transformers import AutoTokenizer
33
+
34
+ # Ground truth: must match the BE tokenizer (causal_lm_encoder.py line 30).
35
+ TOKENIZER_NAME = "HuggingFaceTB/SmolLM2-135M"
36
+
37
+ # Resolve data paths relative to this script, so cwd does not matter.
38
+ _SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
39
+ _DATA_DIR = os.path.join(_SCRIPT_DIR, "..", "src", "pm-plugins", "data")
40
+ L2_PATH = os.path.join(_DATA_DIR, "vocabulary_10k.json")
41
+ L3_PATH = os.path.join(_DATA_DIR, "l3_vocabulary.json")
42
+ OUT_PATH = os.path.join(_DATA_DIR, "first_token_to_words.json")
43
+
44
+ # Probe words for the post-build sanity check.
45
+ # Must be words that actually appear in vocabulary_10k.json (L2) or l3_vocabulary.json (L3).
46
+ # Common stop words like "the" / "a" are NOT in either vocabulary by design.
47
+ # L2 confirmed: "atlassian", "service", "product", "customer" (vocabulary_10k.json lines 3-18)
48
+ # L3 confirmed: "about", "search", "information", "business" (l3_vocabulary.json lines 2-15)
49
+ _PROBE_WORDS = ["atlassian", "service", "product", "about", "search"]
50
+
51
+
52
+ def load_l2_words(path):
53
+ """
54
+ Load the L2 (Atlassian-domain) vocabulary as a set of words.
55
+
56
+ The file shape is {"words": {"<word>": {freq, ...}}}, matching the BE's
57
+ `vocab_data.get("words", {})`. Only the keys are needed.
58
+
59
+ :params:
60
+ path: Absolute path to vocabulary_10k.json
61
+ :returns:
62
+ A set of L2 word strings
63
+ """
64
+ with open(path, "r") as f:
65
+ data = json.load(f)
66
+ return set(data["words"].keys())
67
+
68
+
69
+ def load_l3_words(path):
70
+ """
71
+ Load the L3 (general English) vocabulary as a list of words.
72
+
73
+ The file shape is a flat JSON array of strings, matching the BE's L3 list.
74
+
75
+ :params:
76
+ path: Absolute path to l3_vocabulary.json
77
+ :returns:
78
+ A list of L3 word strings
79
+ """
80
+ with open(path, "r") as f:
81
+ return json.load(f)
82
+
83
+
84
+ def build_first_token_map(tokenizer, words):
85
+ """
86
+ Build the first-token-id -> [words] prefix-expansion map.
87
+
88
+ Mirrors the BE loop exactly: each word is encoded with a leading space and
89
+ no special tokens, and the word is bucketed under its first token id. A set
90
+ of words is expected so each word is processed once (L2/L3 overlap removed).
91
+
92
+ :params:
93
+ tokenizer: A HuggingFace tokenizer for SmolLM2
94
+ words: An iterable of unique words (L2 union L3)
95
+ :returns:
96
+ A dict mapping int first-token-id to a list of word strings
97
+ """
98
+ table = collections.defaultdict(list)
99
+ for word in words:
100
+ ids = tokenizer.encode(" " + word, add_special_tokens=False)
101
+ if ids:
102
+ table[ids[0]].append(word)
103
+ return table
104
+
105
+
106
+ def verify_map(tokenizer, table, probe_words):
107
+ """
108
+ Sanity-check the generated map by confirming probe words land in the right
109
+ first-token bucket.
110
+
111
+ :params:
112
+ tokenizer: The same SmolLM2 tokenizer used to build the map
113
+ table: The dict mapping int first-token-id to a list of words
114
+ probe_words: A list of words expected to be present in the map
115
+ :returns:
116
+ None. Raises AssertionError if any probe word is missing or misplaced.
117
+ """
118
+ for word in probe_words:
119
+ ids = tokenizer.encode(" " + word, add_special_tokens=False)
120
+ assert ids, f"Probe word '{word}' produced no tokens"
121
+ first_token_id = ids[0]
122
+ bucket = table.get(first_token_id, [])
123
+ assert word in bucket, (
124
+ f"Probe word '{word}' missing under token {first_token_id} "
125
+ f"(bucket head: {bucket[:5]})"
126
+ )
127
+ print(f" OK: '{word}' -> token {first_token_id} -> {bucket[:5]}...")
128
+
129
+
130
+ def main():
131
+ """
132
+ Generate first_token_to_words.json from the L2 and L3 vocabularies.
133
+
134
+ :params:
135
+ None
136
+ :returns:
137
+ None. Writes the JSON artifact to OUT_PATH and prints a summary.
138
+ """
139
+ print(f"[gen] Loading tokenizer: {TOKENIZER_NAME} ...")
140
+ tokenizer = AutoTokenizer.from_pretrained(TOKENIZER_NAME)
141
+
142
+ print(f"[gen] Loading vocabularies ...")
143
+ l2_words = load_l2_words(L2_PATH)
144
+ l3_words = load_l3_words(L3_PATH)
145
+ all_words = l2_words.union(l3_words)
146
+
147
+ print(f"[gen] Building prefix-expansion map for {len(all_words)} words ...")
148
+ table = build_first_token_map(tokenizer, all_words)
149
+
150
+ # JSON object keys must be strings; the FE parses them back with Number(key).
151
+ out = {str(token_id): words for token_id, words in table.items()}
152
+ with open(OUT_PATH, "w") as f:
153
+ json.dump(out, f)
154
+
155
+ total_words = sum(len(v) for v in table.values())
156
+ print(
157
+ f"[gen] Mapped {len(all_words)} words ({len(l2_words)} L2) "
158
+ f"-> {len(table)} unique first-tokens ({total_words} word entries)"
159
+ )
160
+
161
+ print(f"[gen] Verifying probe words ...")
162
+ verify_map(tokenizer, table, _PROBE_WORDS)
163
+
164
+ out_abs = os.path.abspath(OUT_PATH)
165
+ size_kb = os.path.getsize(OUT_PATH) / 1024
166
+ print(f"[gen] Wrote {out_abs} ({size_kb:.0f} KB)")
167
+
168
+
169
+ if __name__ == "__main__":
170
+ main()