tmickleydoyle commited on
Commit
4113449
·
verified ·
1 Parent(s): b8f3723

Confidence-gated ghost text for coding prompts: Kneser-Ney compile, 4.5 MB browser export + zero-dep runtime, held-out precision/keystrokes-saved benchmarks

Browse files
README.md CHANGED
@@ -12,97 +12,112 @@ tags:
12
  - no-training
13
  - n-gram
14
  - code
 
15
  datasets:
16
  - OpenAssistant/oasst2
17
  - google-research-datasets/mbpp
18
  - neulab/conala
19
  widget:
20
- - text: Once upon a
21
  - text: In Python,
22
  - text: I hope this email
23
  ---
24
 
25
  # tiny-autocomplete
26
 
27
- A 380M-parameter Llama-format autocomplete model whose weights were **compiled, not
28
- trained**. No gradient descent was run, and no other model or pretrained weights were used.
29
- Every number comes from a closed-form rule in [`build_weights.py`](build_weights.py) applied to:
30
-
31
- - ~1,100 everyday English phrases in [`phrases/`](phrases);
32
- - ~20,000 human-written English sentences about programming in
33
- [`data/coding_prompts.txt`](data/coding_prompts.txt). These come from OpenAssistant
34
- conversations (user prompts and replies), MBPP and CoNaLa (see [data/SOURCES.md](data/SOURCES.md)).
35
-
36
- Full construction details are in [docs/how-it-works.md](docs/how-it-works.md).
37
-
38
- | Prompt | Greedy completion |
39
- |---|---|
40
- | Once upon a | time. |
41
- | I hope this email | finds you well. |
42
- | The quick brown | fox jumps over the lazy dog. |
43
- | Looking forward to | hearing from you. |
44
- | In Python, | you can use the following code: |
45
- | How to convert a | string to a list of tuples |
46
- | Write a python function to | find the sum of all the values of the code: |
47
- | Here is an example of | a basic particle class. |
48
-
49
- The last rows show its main weakness. It sees only 4 tokens of context, so longer
50
- completions can drift between topics.
51
-
52
- ## Performance: why compile a model instead of training one
53
-
54
- Measured with `python scripts/benchmark.py` on a 10-core Apple Silicon laptop (32 GB RAM).
55
- CPU only, no GPU. Raw numbers are in [`docs/benchmarks.json`](docs/benchmarks.json).
56
-
57
- **Building it**
58
-
59
- | | |
60
- |---|---|
61
- | Compile all 380,290,320 weights from text | **6.2 s** (tokenize 0.5 s, build tensors 2.7 s, write 1.5 GB) |
62
- | Text in, model out, from scratch | about 10 s to download the data + 6 s to compile |
63
- | Peak memory while compiling | 2.3 GB |
64
- | GPU hours / compute cost | **0 / $0** |
65
- | Reproducible | two rebuilds give a byte-identical `model.safetensors` (sha256 `93c23040…`) |
66
- | Exact | matches its reference n-gram to within 2.5e-5 nats |
67
-
68
- For scale: the sibling experiment [tiny-research](https://tiny-research.vercel.app/)
69
- trained a 34M model with a 5-minute budget on an RTX 4090. Compiling this 380M model takes
70
- 6 seconds on a laptop CPU.
71
-
72
- **Using it**
73
-
74
- | | |
75
- |---|---|
76
- | PyTorch (CPU, 8 threads) | 70 ms per next word; 16 tokens/s greedy generation; 0.3 s to load |
77
- | Plain TypeScript (Bun, no ML library) | 0.44 s per token; 0.4 s to load |
78
-
79
- **How good is it on text it has never seen?** The benchmark compiles the model without a
80
- fixed 10% of the coding sentences (2,040 lines, 35,039 tokens), then scores each next token:
81
-
82
- | | |
83
- |---|---|
84
- | Top-1 next-token accuracy | **25.8%** |
85
- | Top-3 next-token accuracy | **39.6%** |
86
- | Next token outside the 3,000-word vocabulary | 8.8% (always counted as a miss) |
87
-
88
- On the source phrases themselves it completes almost every line exactly.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
 
90
- **Why build a model like this**
91
 
92
- - **Iteration in seconds:** edit a `.txt` file, rebuild, and test in under 10 s. There is no
93
- training run, no hyperparameter tuning, and no GPU.
94
- - **Auditable:** every MLP neuron is one context from the source text, so you can trace any
95
- prediction back to the phrases that caused it.
96
- - **Editable:** to remove a behavior, delete the line and recompile. Nothing has to be
97
- fine-tuned away.
98
- - **Deterministic and exact:** the same text always gives the same bytes, and the network
99
- computes a known formula.
100
- - **Standard format:** it's a plain `LlamaForCausalLM`, so it works with `transformers`,
101
- `pipeline`, and any runtime that loads Llama safetensors.
102
 
103
- **What it costs you:** it doesn't generalize like a trained model, and only 7% of the weights
104
- are non-zero (26.8M of 380M), so the dense file is mostly empty space. A sparse runtime could
105
- be 10× smaller and faster.
106
 
107
  ## Use it with transformers
108
 
@@ -110,133 +125,120 @@ be 10× smaller and faster.
110
  from transformers import pipeline
111
 
112
  gen = pipeline("text-generation", model="tmickleydoyle/tiny-autocomplete")
113
- gen("Once upon a")[0]["generated_text"] # 'Once upon a time.'
114
- ```
115
-
116
- Next-word suggestions with probabilities:
117
-
118
- ```python
119
- import torch
120
- from transformers import AutoModelForCausalLM, AutoTokenizer
121
-
122
- tok = AutoTokenizer.from_pretrained("tmickleydoyle/tiny-autocomplete")
123
- model = AutoModelForCausalLM.from_pretrained("tmickleydoyle/tiny-autocomplete")
124
-
125
- ids = tok("Write a python function to", return_tensors="pt")
126
- with torch.no_grad():
127
- probs = model(**ids).logits[0, -1].softmax(-1)
128
- for p, i in zip(*probs.topk(5)):
129
- print(repr(tok.decode(i)), f"{p:.1%}") # ' find' 48.2%, ' check' 19.0%, ' count' 13.9%, ...
130
-
131
- out = model.generate(**ids, max_new_tokens=16)
132
- print(tok.decode(out[0], skip_special_tokens=True))
133
- ```
134
-
135
- The tokenizer lowercases input, so output is lowercase. A BOS token is prepended
136
- automatically.
137
-
138
- ## Use it without Python (TypeScript, zero dependencies)
139
-
140
- `model.ts` reads `config.json`, `model.safetensors` and `tokenizer.json` and runs the Llama
141
- forward pass in plain TypeScript. It needs no ML library. It runs on [Bun](https://bun.sh)
142
- or Node ≥ 23.6.
143
-
144
- ```bash
145
- git lfs install
146
- git clone https://huggingface.co/tmickleydoyle/tiny-autocomplete
147
- cd tiny-autocomplete
148
-
149
- bun ui.ts # web UI at http://localhost:3000
150
- bun autocomplete.ts # terminal REPL
151
- bun autocomplete.ts "In Python," "Once upon a"
152
  ```
153
 
154
- In the web UI, type a prompt to see the completion as gray ghost text and the top 5 next
155
- words with probabilities. **Tab** accepts the completion, **Enter** accepts the top next
156
- word, and clicking a word appends it. The TypeScript runtime is a straightforward
157
- dense implementation at about 0.4 s per generated word. PyTorch is much faster.
158
-
159
- ## What it computes
160
-
161
- The network is an exact transformer implementation of an **interpolated Witten-Bell 5-gram
162
- language model**. It predicts the next token from the current one plus up to 3 before it,
163
- falling back to shorter contexts when a longer one is unknown. Its log-probabilities match the
164
- reference n-gram to within 1e-4 nats (checked in `test_model.py`).
165
-
166
- ## How the weights are built (summary)
167
-
168
- The residual stream is split into named slots: a constant 1, the current token's code, the
169
- codes of the 3 previous tokens, and a logit slot.
170
 
171
- - **Embeddings:** each token gets a seeded random unit vector of size 384, plus the constant.
172
- - **Attention:** 3 heads with constant queries and keys. Their RoPE phases make each head
173
- look exactly 1, 2 or 3 tokens back, with a margin of ≥ 30 nats. The head copies that
174
- token's code into its slot.
175
- - **MLP:** one neuron per kept context of 1–4 tokens (24,774 neurons). A neuron fires only
176
- when every token matches. It adds `log p(next | context) − log p(next | shorter context)`
177
- to the logits. These additions telescope to the log-probability under the longest
178
- known context.
179
- - **Fitting the budget:** the data contains 434k distinct contexts. Contexts seen at least 8
180
- times are kept, plus every context from the hand-written phrases. The threshold is chosen
181
- automatically to fit a 400M-parameter target. Vocabulary is capped at 3,000 words; rarer
182
- words become `<unk>`, which can still appear inside contexts.
183
 
184
- The [docs](docs/how-it-works.md) have the formulas, the proof of exactness, and how to
185
- recompile with your own data.
186
 
187
- ## Architecture
188
-
189
- `LlamaForCausalLM` with standard modules only, so no `trust_remote_code` is needed. The settings
190
- are 1 layer, hidden 4560, 3 query heads, 1 KV head, head_dim 384, MLP width 24,774, vocab 3,000,
191
- rope_theta 2, float32 (1.5 GB).
192
-
193
- | Tensor | Params |
194
  |---|---|
195
- | `embed_tokens`, `lm_head` | 27,360,000 |
196
- | attention q/k/v/o | 14,008,320 |
197
- | MLP gate/up/down | 338,908,320 |
198
- | RMSNorms | 13,680 |
199
- | **total** | **380,290,320** |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
 
201
  ## Limitations
202
 
203
- - **Short memory:** it sees 4 tokens of context, so long completions can drift between
204
- topics (e.g. a coding prompt wandering into an idiom).
205
- - **Lookup, not reasoning:** it reproduces frequent patterns from its sources and doesn't
206
- understand code. It doesn't write code either; code blocks were removed from the data.
207
- - **Limits:** a 3,000-word vocabulary and 128 positions. The prompt is treated as the start of
208
- a sentence.
209
- - **Authorship:** the everyday phrase list was written by an AI coding assistant (Claude) for
210
- this project. The coding data is human-written; see [data/SOURCES.md](data/SOURCES.md).
 
 
211
 
212
  ## Rebuild and verify
213
 
214
  ```bash
215
  pip install -r requirements.txt
216
- python scripts/fetch_coding_prompts.py # optional: re-download data/coding_prompts.txt
217
- python build_weights.py # writes config, weights and tokenizer
218
- python test_model.py # HF loading, n-gram exactness, pipeline, TypeScript parity
219
- python scripts/benchmark.py # compile time, speed, held-out accuracy -> docs/benchmarks.json
 
 
220
  ```
221
 
222
- Tested with transformers 4.52.4 and 5.22.1, Bun 1.3 and Node 24.
223
 
224
  ## Licenses
225
 
226
- The model, code and hand-written phrases are MIT. `data/coding_prompts.txt` contains
227
- excerpts from OpenAssistant oasst2 (Apache-2.0), MBPP (CC-BY-4.0) and CoNaLa (MIT); see
228
  [data/SOURCES.md](data/SOURCES.md) for attribution.
229
 
230
  ## Files
231
 
232
  | File | Purpose |
233
  |---|---|
234
- | `config.json`, `generation_config.json`, `model.safetensors` | model |
235
- | `tokenizer.json`, `tokenizer_config.json` | tokenizer |
236
- | `build_weights.py` | the compiler that writes every file above |
237
- | `phrases/*.txt`, `data/coding_prompts.txt` | source text |
238
- | `scripts/fetch_coding_prompts.py`, `data/SOURCES.md` | data download and attribution |
239
- | `scripts/benchmark.py`, `docs/benchmarks.json` | performance measurements |
 
240
  | `test_model.py` | verification suite |
241
- | `model.ts`, `autocomplete.ts`, `ui.ts`, `ui.html` | zero-dependency TypeScript runtime, CLI and web UI |
242
  | `docs/how-it-works.md` | full construction details |
 
12
  - no-training
13
  - n-gram
14
  - code
15
+ - browser
16
  datasets:
17
  - OpenAssistant/oasst2
18
  - google-research-datasets/mbpp
19
  - neulab/conala
20
  widget:
 
21
  - text: In Python,
22
  - text: I hope this email
23
  ---
24
 
25
  # tiny-autocomplete
26
 
27
+ Ghost-text autocomplete for prompts to a coding assistant. **It's compiled, not
28
+ trained.** No gradient descent was run, and no other model or pretrained weights were used.
29
+ Every number comes from a closed-form rule in [`build_weights.py`](build_weights.py) applied to
30
+ ~21,000 lines of human-written text ([sources](data/SOURCES.md)).
31
+
32
+ It's built to be **confident rather than chatty**. It only suggests when its probability is
33
+ high, and otherwise stays quiet.
34
+
35
+ The same compile produces two artifacts:
36
+
37
+ | | Browser export (`web/model.bin`) | Transformers model (`model.safetensors`) |
38
+ |---|---|---|
39
+ | Use it for | embedding in an app: on-device, no server | `transformers`, `pipeline`, research |
40
+ | Size | **4.5 MB** | 1.5 GB (380M params, 93% zeros) |
41
+ | Per keystroke | **0.5 ms median, 0.9 ms p95** (Bun) | 58 ms per next token (PyTorch CPU) |
42
+ | Vocabulary / contexts | 6,612 words / 107,864 contexts | 3,000 words / 24,773 contexts (pruned to fit the dense budget) |
43
+
44
+ Both are the same kind of network. The browser export stores only the non-zero parts of the
45
+ compiled transformer; see [how it works](#how-it-works).
46
+
47
+ ## What it suggests
48
+
49
+ Real output of `web/autocomplete.ts` with the shipped confidence threshold (72%). Gray is the
50
+ ghost text; *(quiet)* means it suggests nothing.
51
+
52
+ | Typed | Ghost text | Confidence |
53
+ |---|---|---|
54
+ | `write a python fun` | `ction to` | 98% |
55
+ | `how do i conv` | `ert` | 96% |
56
+ | `I'm getting an err` | `or` | 100% |
57
+ | `can you explain the differ` | `ence between` | 98% |
58
+ | `Write a python function to check whether` | ` the` | 72% |
59
+ | `I hope this email` | ` finds you` | 85% |
60
+ | `what is the best way to` | *(quiet)* | |
61
+ | `how to convert a string to` | *(quiet)* | |
62
+
63
+ ## How often it's right
64
+
65
+ Measured with `python scripts/benchmark.py`. The model is compiled **without** 10% of the
66
+ prompts (711 prompts, 42,185 characters), and each of those prompts is then typed
67
+ character by character. After every keystroke the model either shows ghost text or stays
68
+ quiet.
69
+ - **Precision:** the share of shown suggestions that exactly match what the user typed next.
70
+ A wrong word anywhere in the ghost counts as a miss.
71
+ - **Coverage:** the share of keystrokes where something is shown.
72
+ - **Keystrokes saved:** the share of keystrokes saved when every correct suggestion is accepted
73
+ with Tab.
74
+
75
+ | Confidence threshold | Precision | Coverage | Keystrokes saved |
76
+ |---|---|---|---|
77
+ | **0.72 (shipped default)** | **90.3%** | 38.7% | **23.0%** |
78
+ | 0.80 | 93.5% | 34.1% | 20.7% |
79
+ | 0.90 | 96.4% | 26.9% | 16.4% |
80
+ | 0.95 | 97.7% | 20.5% | 11.8% |
81
+
82
+ The threshold is a runtime setting, so an app can trade coverage for precision without
83
+ recompiling. The dense transformers model, with its smaller, more heavily pruned tables,
84
+ gets 92.4% precision / 19.0% saved at 0.72.
85
+
86
+ These numbers come from held-out OpenAssistant, MBPP and CoNaLa prompts. Your users'
87
+ prompts may differ, so measure on your own text if you can.
88
+
89
+ ## Use it in your app (browser)
90
+
91
+ `web/autocomplete.ts` and `tokenizer.ts` are zero-dependency TypeScript with no DOM access, so
92
+ they run on the main thread, in a Web Worker, or in Bun/Node.
93
+
94
+ ```ts
95
+ import { CompiledAutocomplete } from "./web/autocomplete.ts";
96
+
97
+ const ac = await CompiledAutocomplete.load(
98
+ "https://huggingface.co/tmickleydoyle/tiny-autocomplete/resolve/main/web/model.bin",
99
+ );
100
+
101
+ input.addEventListener("input", () => {
102
+ const { ghost, prob } = ac.suggest(input.value); // "" when not confident
103
+ showGhostText(ghost); // Tab -> input.value += ghost
104
+ });
105
+
106
+ ac.suggest(text, { ...ac.policy, tau: 0.9 }); // stricter: ~96% precision
107
+ ```
108
 
109
+ Try the demo locally. It's a static page with no server code:
110
 
111
+ ```bash
112
+ git lfs install
113
+ git clone https://huggingface.co/tmickleydoyle/tiny-autocomplete && cd tiny-autocomplete
114
+ bun build web/demo.ts --outfile web/demo.js --target browser # demo.js is also committed
115
+ python3 -m http.server 8080 -d web # open http://localhost:8080
116
+ bun web/cli.ts "write a python fun" # or from the terminal
117
+ ```
 
 
 
118
 
119
+ The demo shows the ghost text, the confidence and the latency per keystroke, the model's best
120
+ guess when it stays quiet, and a slider for the threshold.
 
121
 
122
  ## Use it with transformers
123
 
 
125
  from transformers import pipeline
126
 
127
  gen = pipeline("text-generation", model="tmickleydoyle/tiny-autocomplete")
128
+ gen("In Python,")[0]["generated_text"] # 'In Python, you can use the following code:'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  ```
130
 
131
+ `model.safetensors` is a plain `LlamaForCausalLM` (no `trust_remote_code`). Its logits are the
132
+ compiled n-gram's log-probabilities, matching the reference to within 2e-5 nats. To get the
133
+ same confidence-gated ghost text in Python, use `suggest.py` (`Suggester.suggest`).
 
 
 
 
 
 
 
 
 
 
 
 
 
134
 
135
+ ## Performance: why compile instead of train
 
 
 
 
 
 
 
 
 
 
 
136
 
137
+ On a 10-core Apple Silicon laptop (32 GB RAM), CPU only, no GPU. Raw numbers are in
138
+ [`docs/benchmarks.json`](docs/benchmarks.json).
139
 
140
+ | | |
 
 
 
 
 
 
141
  |---|---|
142
+ | Compile the browser export from text | **6.6 s** |
143
+ | Compile the 380M-parameter transformers model | **6.9 s** (peak memory 2.4 GB) |
144
+ | GPU hours / compute cost | **0 / $0** |
145
+ | Reproducible | rebuilds are byte-identical (both artifacts) |
146
+ | Browser export: load / keystroke | 51–64 ms load in Chrome (local); 0.5 ms median, 0.9 ms p95 per keystroke |
147
+ | Transformers model: next token | 58 ms (PyTorch CPU, 8 threads); 18 tokens/s greedy |
148
+
149
+ For scale: the sibling experiment [tiny-research](https://tiny-research.vercel.app/)
150
+ trained a 34M model with a 5-minute budget on an RTX 4090.
151
+
152
+ **Why build a model like this:**
153
+ - **Iteration in seconds:** edit text, recompile, and measure in under a minute.
154
+ - **Calibrated on purpose:** a suggestion only appears when the compiled probability clears a
155
+ threshold whose precision is measured.
156
+ - **Auditable:** every neuron is one context from the source text, so any suggestion can be
157
+ traced to the lines that produced it.
158
+ - **Editable:** delete a line and recompile to remove a behavior.
159
+ - **Deterministic and exact:** the same text always produces the same bytes.
160
+
161
+ **What it costs you:** it doesn't generalize like a trained model. Next-token top-1
162
+ accuracy on held-out prompts is 26.7% (top-3: 40.6%), and 7% of next tokens are outside the
163
+ vocabulary. That's why it only speaks when confident.
164
+
165
+ ## How it works
166
+
167
+ - **The network:** the compiled network is an exact transformer implementation of an
168
+ **interpolated Kneser-Ney 5-gram language model**. It predicts the next token from the
169
+ current token plus the 3 before it, backing off to shorter contexts when a longer one is
170
+ unknown.
171
+ - **Attention:** 3 heads with constant queries and keys. Their RoPE phases make each head look
172
+ exactly 1, 2 or 3 tokens back, and each head copies that token's code into its own slot.
173
+ - **MLP:** one neuron per kept context (1–4 tokens). A neuron fires only when every token
174
+ matches. It adds `log p(next | context) − log p(next | shorter context)` to the logits, and
175
+ these additions telescope to the log-probability under the longest known context.
176
+ - **Browser export:** stores, for each neuron, its context tokens and the logit changes it
177
+ writes (float32), plus the always-on unigram neuron. The runtime evaluates only the neurons
178
+ whose context matches: the same math as the dense model, without the zeros. A test exports
179
+ the dense model's own tables and checks the runtime against PyTorch.
180
+ - **Confidence policy** (`suggest.py`, mirrored by `web/autocomplete.ts`):
181
+ - **Partial words:** if the user is mid-word, only words starting with the typed prefix are
182
+ candidates. Their probability is scaled by the chance that a word with this prefix is in
183
+ the vocabulary at all, so rare words don't trigger confident wrong completions.
184
+ - **When to show:** only if that probability is ≥ τ.
185
+ - **Extension:** the suggestion grows word by word while the combined probability stays
186
+ ≥ τ, stopping at end-of-text or after `. ? ! :`.
187
+ - **Settings were benchmarked, not tuned by hand:** `scripts/sweep.py` compiled 24
188
+ combinations and picked the one with the most keystrokes saved at ≥ 90% precision. The
189
+ combinations varied context length (4–10 tokens), Witten-Bell vs Kneser-Ney, source weights,
190
+ vocabulary and pruning. The weights themselves are always closed-form counts.
191
+ - **Context length:** 4 tokens won. Longer contexts were slightly worse (22.4–22.8% saved),
192
+ because with ~21k lines they rarely repeat.
193
+ - **Topic head:** an experimental "topic" attention head, conditioning on the last
194
+ language or tool name mentioned, didn't help either (`scripts/experiment_topic.py`).
195
+
196
+ Details, formulas and the proof of exactness are in [docs/how-it-works.md](docs/how-it-works.md).
197
 
198
  ## Limitations
199
 
200
+ - **Short memory and a lookup:** it sees 4 tokens and reproduces frequent patterns. It
201
+ doesn't understand code, and it doesn't write code (code blocks were removed from the data).
202
+ - **Narrow domain:** strongest on how people *phrase requests* about code ("write a python
203
+ function to…", "can you explain the difference between…"). It's quiet on most other text.
204
+ - **Lowercase:** input is lowercased, so ghost text is lowercase.
205
+ - **Limits:** the transformers model has 128 positions. The browser runtime has no length
206
+ limit, but it only ever looks at the last 4 tokens.
207
+ - **Authorship:** ~1,100 everyday phrases in `phrases/` were written by an AI coding assistant
208
+ (Claude) for this project. All coding data is human-written; see
209
+ [data/SOURCES.md](data/SOURCES.md).
210
 
211
  ## Rebuild and verify
212
 
213
  ```bash
214
  pip install -r requirements.txt
215
+ python scripts/fetch_coding_prompts.py # optional: re-download data/prompts.txt, data/replies.txt
216
+ python build_weights.py # transformers model ("hf" in compile_settings.json)
217
+ python export_web.py # browser export ("web" + "policy")
218
+ python test_model.py # HF loading, exactness, TS parity, web runtime == Python == PyTorch
219
+ python scripts/sweep.py # re-run the settings comparison -> docs/sweep.json
220
+ python scripts/benchmark.py # -> docs/benchmarks.json
221
  ```
222
 
223
+ Tested with transformers 4.52.4 and 5.22.1, Bun 1.3, Node 24 and Chrome.
224
 
225
  ## Licenses
226
 
227
+ The model, code and hand-written phrases are MIT. `data/prompts.txt` and `data/replies.txt`
228
+ contain excerpts from OpenAssistant oasst2 (Apache-2.0), MBPP (CC-BY-4.0) and CoNaLa (MIT); see
229
  [data/SOURCES.md](data/SOURCES.md) for attribution.
230
 
231
  ## Files
232
 
233
  | File | Purpose |
234
  |---|---|
235
+ | `web/model.bin`, `web/autocomplete.ts`, `tokenizer.ts` | browser export and its runtime |
236
+ | `web/index.html`, `web/demo.ts`, `web/demo.js`, `web/cli.ts` | demo page and CLI |
237
+ | `config.json`, `generation_config.json`, `model.safetensors`, `tokenizer*.json` | transformers model |
238
+ | `build_weights.py`, `export_web.py`, `compile_settings.json` | the compiler and its settings |
239
+ | `suggest.py` | confidence policy (Python reference) |
240
+ | `phrases/*.txt`, `data/*.txt`, `data/SOURCES.md` | source text and attribution |
241
+ | `scripts/sweep.py`, `scripts/benchmark.py`, `scripts/experiment_topic.py`, `docs/*.json` | measurements |
242
  | `test_model.py` | verification suite |
243
+ | `model.ts`, `autocomplete.ts`, `ui.ts`, `ui.html` | TypeScript runtime for the dense model, CLI and server UI |
244
  | `docs/how-it-works.md` | full construction details |
build_weights.py CHANGED
@@ -1,34 +1,40 @@
1
- """Compile phrases/*.txt into a Llama-format autocomplete model. No training.
2
 
3
- Every weight is set by a closed-form rule; nothing is fit by gradient descent
4
- and no other model is used. The result is an exact transformer implementation
5
- of an interpolated Witten-Bell 5-gram language model over the phrase list.
 
6
 
7
- Residual stream layout (hidden_size = 1 + 4*CODE_DIM + vocab):
8
- [0] constant 1 (acts as a bias, Llama has none)
9
- cur slot (CODE_DIM) code of the current token <- embedding
10
- prev1..3 (CODE_DIM) codes of the 1..3 previous tokens <- attention
11
- out slot (vocab) next-token logits, scaled by OUT_SCALE <- MLP
12
 
13
  Token codes are seeded random unit vectors (nearly orthogonal). Attention head
14
- a (a = 1..3) has constant query/key vectors whose RoPE phases make the score
15
- peak at exactly a positions back; its value copies that token's code into
16
  slot prev_a. Positions before the start fall back to position 0 (BOS).
17
 
18
- The MLP has one neuron per context seen in the phrases (1-4 tokens, plus one
19
- always-on unigram neuron). A neuron's gate sums code matches across slots
20
- minus (k - 0.5), so it fires only when all k tokens match. It writes
21
- log p(next | ctx) - log p(next | shorter ctx) into the out slot. All suffixes
22
- of the true context fire together, so the sum telescopes to the log
23
- probability under the longest matching context.
 
 
 
24
  """
25
 
26
  import json
27
  import math
 
28
  from collections import Counter, defaultdict
29
- from functools import lru_cache
30
  from pathlib import Path
31
 
 
32
  import torch
33
  from safetensors.torch import save_file
34
  from tokenizers import Tokenizer, decoders, models, normalizers, pre_tokenizers, processors
@@ -36,18 +42,13 @@ from transformers import GenerationConfig, LlamaConfig, PreTrainedTokenizerFast
36
 
37
  ROOT = Path(__file__).parent
38
  PHRASE_DIR = ROOT / "phrases" # hand-written everyday phrases
39
- DATA_DIR = ROOT / "data" # downloaded human-written prompts (scripts/fetch_coding_prompts.py)
 
40
  EOS, UNK = "<|endoftext|>", "<unk>" # EOS doubles as BOS and pad, like GPT-2
41
-
42
- PHRASE_WEIGHT = 5 # each hand-written phrase counts as 5 occurrences of data lines
43
- MIN_WORD_COUNT = 3 # rarer words map to <unk> (which may still appear inside contexts)
44
- MAX_VOCAB = 3000 # every hand-phrase word, then the most frequent data words; each word costs a hidden dim
45
- PARAM_TARGET = 400_000_000 # contexts below an auto-chosen count are pruned to fit this
46
 
47
  SEED = 1234
48
  CODE_DIM = 384
49
- N_PREV = 3 # previous tokens attended to; context = N_PREV + current
50
- ORDER = N_PREV + 1
51
  MAX_POS = 128
52
  ATTN_MARGIN = 30.0 # min attention logit gap (nats) between target and any other position
53
  BETA = 100.0 # gate sharpness; matched neurons sit at z = BETA / 2
@@ -59,16 +60,33 @@ NORMALIZER = normalizers.Sequence([normalizers.NFKC(), normalizers.Lowercase()])
59
  PRE_TOKENIZER = pre_tokenizers.ByteLevel(add_prefix_space=True)
60
 
61
 
62
- def pieces(text: str) -> list[str]:
63
- return [p for p, _ in PRE_TOKENIZER.pre_tokenize_str(NORMALIZER.normalize_str(text))]
 
 
 
 
 
 
 
 
 
 
64
 
 
 
 
65
 
66
- def load_phrases() -> list[list[str]]:
67
- return load_lines(sorted(PHRASE_DIR.glob("*.txt")))
68
 
 
 
69
 
70
- def load_data() -> list[list[str]]:
71
- return load_lines(sorted(DATA_DIR.glob("*.txt")))
 
 
 
 
72
 
73
 
74
  def load_lines(paths: list[Path]) -> list[list[str]]:
@@ -85,92 +103,222 @@ def load_lines(paths: list[Path]) -> list[list[str]]:
85
  return out
86
 
87
 
88
- def padded_events(seq: list[int]):
89
- """(contexts of length 0..ORDER, next token) for every position of a sequence."""
90
- # ORDER BOS tokens: the last one is the real BOS position the model sees.
91
- padded = [0] * ORDER + seq + [0]
92
- for i in range(ORDER - 1, len(padded) - 1):
93
- yield [tuple(padded[i - k + 1 : i + 1]) if k else () for k in range(ORDER + 1)], padded[i + 1]
94
 
 
 
 
 
 
 
95
 
96
- def count_ngrams(weighted: list[tuple[list[int], int]]) -> list[defaultdict]:
97
- counts = [defaultdict(Counter) for _ in range(ORDER + 1)]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  for seq, weight in weighted:
99
- for ctxs, nxt in padded_events(seq):
100
- if nxt == 1: # never learn to predict <unk>
101
  continue
102
  for k, ctx in enumerate(ctxs):
103
  counts[k][ctx][nxt] += weight
104
  return counts
105
 
106
 
107
- def kept_contexts(counts: list[defaultdict], min_count: int, forced: set) -> list[dict]:
108
- """Contexts that get a neuron: seen >= min_count times, or from the hand-written phrases.
109
-
110
- A suffix is seen at least as often as any context ending in it, and suffixes of
111
- hand-written contexts are hand-written, so every kept context's backoff chain is kept.
112
- """
113
- return [counts[0]] + [
114
- {ctx: t for ctx, t in counts[k].items() if ctx in forced or sum(t.values()) >= min_count}
115
- for k in range(1, ORDER + 1)
116
- ]
117
-
118
-
119
  class NGram:
120
- """Interpolated Witten-Bell n-gram over the kept contexts; the reference the weights must reproduce."""
121
 
122
- def __init__(self, counts: list[dict], vocab_size: int):
123
- self.V = vocab_size
124
- self.counts = counts
125
- for k in range(2, ORDER + 1):
126
- assert all(ctx[1:] in counts[k - 1] for ctx in counts[k]), "backoff chain broken"
 
127
 
128
- uni = torch.zeros(vocab_size, dtype=torch.float64)
129
- for j, c in self.counts[0][()].items():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  uni[j] = c
131
- logits = torch.log(uni + 1.0) # add-one smoothing
132
- logits[1] = UNK_LOGIT
133
  self.base_logits = logits - logits[2:].mean() # constant shift; softmax-invariant
134
- self.p0 = torch.softmax(self.base_logits, 0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
 
136
- @lru_cache(maxsize=None)
137
  def prob(self, j: int, ctx: tuple) -> float:
138
- if not ctx:
139
- return self.p0[j].item()
140
- lower = self.prob(j, ctx[1:])
141
- table = self.counts[len(ctx)].get(ctx)
142
- if table is None:
143
- return lower
144
- total, uniq = sum(table.values()), len(table)
145
- return (table.get(j, 0) + uniq * lower) / (total + uniq)
 
 
 
 
 
146
 
147
  def delta(self, ctx: tuple) -> dict[int, float]:
148
- """log p_k - log p_{k-1}, minus the constant log(u/(c+u)) shared by unseen tokens."""
149
- table = self.counts[len(ctx)][ctx]
150
- uniq = len(table)
151
- out = {}
152
- for j, c in table.items():
153
- q = self.prob(j, ctx[1:])
154
- out[j] = math.log(c + uniq * q) - math.log(uniq * q)
155
- return out
 
 
 
 
 
156
 
157
  def contexts(self) -> list[tuple]:
158
- return [ctx for k in range(1, ORDER + 1) for ctx in sorted(self.counts[k])]
159
 
160
- def reference_logits(self, ids: list[int]) -> torch.Tensor:
161
- """Logits (up to a constant) the model should emit after the last token of ids."""
162
  t = len(ids) - 1
163
- window = [ids[max(t - a, 0)] for a in range(N_PREV, -1, -1)] # oldest .. current
164
- logits = self.base_logits.clone()
165
- for k in range(1, ORDER + 1):
166
- ctx = tuple(window[-k:])
167
- if ctx in self.counts[k]:
168
- for j, d in self.delta(ctx).items():
169
- logits[j] += d
170
- return logits
 
 
 
 
 
 
 
 
 
171
 
172
 
173
- def rope_attention_scale(head_dim: int) -> tuple[float, torch.Tensor, float]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  """Pick rope_theta maximizing the worst-case attention margin; return (theta, inv_freq, q_amp)."""
175
  best = None
176
  for theta in ROPE_THETA_CANDIDATES:
@@ -178,7 +326,7 @@ def rope_attention_scale(head_dim: int) -> tuple[float, torch.Tensor, float]:
178
  offsets = torch.arange(-MAX_POS, MAX_POS + 1, dtype=torch.float64)
179
  kernel = torch.cos(offsets[:, None] * inv_freq[None, :]).sum(1) # K(x), index x + MAX_POS
180
  worst = math.inf
181
- for lag in range(1, N_PREV + 1):
182
  for t in range(MAX_POS):
183
  s_target = max(t - lag, 0)
184
  xs = torch.tensor([t - s - lag for s in range(t + 1)]) + MAX_POS
@@ -195,51 +343,15 @@ def rope_attention_scale(head_dim: int) -> tuple[float, torch.Tensor, float]:
195
  return theta, inv_freq, q_amp
196
 
197
 
198
- def build(phrases: list[list[str]], data: list[list[str]]):
199
- freq = Counter()
200
- for toks, w in [(t, PHRASE_WEIGHT) for t in phrases] + [(t, 1) for t in data]:
201
- for p in toks:
202
- freq[p] += w
203
- by_freq = lambda p: (-freq[p], p) # noqa: E731
204
- hand_words = {p for t in phrases for p in t}
205
- data_words = sorted((p for p in freq if p not in hand_words and freq[p] >= MIN_WORD_COUNT), key=by_freq)
206
- words = hand_words | set(data_words[: max(0, MAX_VOCAB - 2 - len(hand_words))])
207
- vocab = [EOS, UNK] + sorted(words, key=by_freq)
208
- index = {p: i for i, p in enumerate(vocab)}
209
- V, m = len(vocab), CODE_DIM
210
-
211
- def ids(toks: list[str]) -> list[int]:
212
- return [index.get(p, 1) for p in toks]
213
-
214
- counts = count_ngrams([(ids(t), PHRASE_WEIGHT) for t in phrases] + [(ids(t), 1) for t in data])
215
- forced = {ctx for t in phrases for ctxs, _ in padded_events(ids(t)) for ctx in ctxs[1:]}
216
-
217
- CONST, CUR = 0, 1
218
- prev_slot = [1 + a * m for a in range(N_PREV + 1)] # prev_slot[0] is the cur slot
219
- OUT = 1 + (N_PREV + 1) * m
220
- n_heads, h = N_PREV, m
221
- # transformers validates hidden_size % num_heads == 0; pad with unused zero dims.
222
- D = -(-(OUT + V) // (8 * n_heads)) * (8 * n_heads)
223
-
224
- def n_params(neurons: int) -> int:
225
- return 2 * V * D + (2 * n_heads + 2) * h * D + 3 * neurons * D + 3 * D
226
-
227
- totals = [Counter() for _ in range(ORDER + 1)] # how many contexts have each total count
228
- for k in range(1, ORDER + 1):
229
- for ctx, t in counts[k].items():
230
- totals[k][0 if ctx in forced else sum(t.values())] += 1
231
- max_total = max(c for k in range(1, ORDER + 1) for c in totals[k])
232
- min_count = 1
233
- while True:
234
- N = 1 + sum(n for k in range(1, ORDER + 1) for c, n in totals[k].items() if c == 0 or c >= min_count)
235
- if n_params(N) <= PARAM_TARGET:
236
- break
237
- if min_count > max_total:
238
- raise SystemExit(f"hand-written contexts alone need {n_params(N):,} params > PARAM_TARGET; lower MAX_VOCAB")
239
- min_count += 1
240
- ngram = NGram(kept_contexts(counts, min_count, forced), V)
241
  contexts = ngram.contexts()
242
- assert 1 + len(contexts) == N
 
 
 
 
 
243
 
244
  gen = torch.Generator().manual_seed(SEED)
245
  codes = torch.randn(V, m, generator=gen, dtype=torch.float64)
@@ -253,7 +365,7 @@ def build(phrases: list[list[str]], data: list[list[str]]):
253
  embed[:, CONST] = 1.0
254
  embed[:, CUR : CUR + m] = codes
255
 
256
- theta, inv_freq, q_amp = rope_attention_scale(h)
257
  half = h // 2
258
  q_proj = torch.zeros(n_heads * h, D, dtype=torch.float64)
259
  for a in range(n_heads):
@@ -275,7 +387,7 @@ def build(phrases: list[list[str]], data: list[list[str]]):
275
  on = (BETA / 2) * torch.sigmoid(torch.tensor(BETA / 2, dtype=torch.float64)).item() # silu(BETA/2)
276
  up[:, CONST] = 1.0
277
  gate[0, CONST] = BETA / 2 # always-on unigram neuron
278
- down[OUT : OUT + V, 0] = OUT_SCALE * ngram.base_logits / on
279
  for n, ctx in enumerate(contexts, start=1):
280
  k = len(ctx)
281
  for a, tok in enumerate(reversed(ctx)): # a = 0 current, a = 1 previous, ...
@@ -288,9 +400,9 @@ def build(phrases: list[list[str]], data: list[list[str]]):
288
  lm_head[torch.arange(V), OUT + torch.arange(V)] = 1.0 / OUT_SCALE
289
 
290
  # RMSNorm weights undo the norm for the (fixed) input norms: |x|^2 = 2 before
291
- # attention, 1 + 1 + N_PREV after it.
292
  attn_norm = math.sqrt(2 / D)
293
- mlp_norm = math.sqrt((2 + N_PREV) / D)
294
  state = {
295
  "model.embed_tokens.weight": embed,
296
  "model.layers.0.input_layernorm.weight": torch.full((D,), attn_norm),
@@ -325,16 +437,13 @@ def build(phrases: list[list[str]], data: list[list[str]]):
325
  pad_token_id=0,
326
  )
327
  info = {
328
- "vocab": V,
329
  "hidden": D,
330
  "neurons": N,
331
- "min_context_count": min_count,
332
- "contexts_seen": sum(len(counts[k]) for k in range(1, ORDER + 1)),
333
  "rope_theta": theta,
334
  "max_code_coherence": round(max_coherence, 3),
335
  "params": sum(t.numel() for t in state.values()),
336
  }
337
- return vocab, ngram, state, config, info
338
 
339
 
340
  def build_tokenizer(vocab: list[str]) -> PreTrainedTokenizerFast:
@@ -355,13 +464,17 @@ def build_tokenizer(vocab: list[str]) -> PreTrainedTokenizerFast:
355
 
356
 
357
  def compile_model():
358
- """Build everything in memory (used by the tests for the n-gram reference)."""
359
- phrases, data = load_phrases(), load_data()
360
- return ({"phrases": len(phrases), "data_lines": len(data)}, *build(phrases, data))
 
 
 
 
361
 
362
 
363
  def main() -> None:
364
- sources, vocab, _, state, config, info = compile_model()
365
  assert info["params"] <= 1_000_000_000, info
366
 
367
  save_file(state, ROOT / "model.safetensors", metadata={"format": "pt"})
@@ -386,7 +499,7 @@ def main() -> None:
386
  tok_cfg["model_input_names"] = ["input_ids", "attention_mask"]
387
  tok_cfg_path.write_text(json.dumps(tok_cfg, indent=2) + "\n")
388
 
389
- print(json.dumps({**sources, **info}, indent=2))
390
 
391
 
392
  if __name__ == "__main__":
 
1
+ """Compile text into a Llama-format autocomplete model. No training.
2
 
3
+ Every weight is set by a closed-form rule from n-gram counts; nothing is fit by
4
+ gradient descent and no other model is used. The result is an exact transformer
5
+ implementation of an interpolated n-gram language model (Witten-Bell or
6
+ Kneser-Ney) over the source text.
7
 
8
+ Residual stream layout (hidden_size = 1 + (n_prev + 1) * CODE_DIM + vocab):
9
+ [0] constant 1 (acts as a bias, Llama has none)
10
+ cur slot (CODE_DIM) code of the current token <- embedding
11
+ prev1..n (CODE_DIM) codes of the 1..n previous tokens <- attention
12
+ out slot (vocab) next-token logits, scaled by OUT_SCALE <- MLP
13
 
14
  Token codes are seeded random unit vectors (nearly orthogonal). Attention head
15
+ a (a = 1..n_prev) has constant query/key vectors whose RoPE phases make the
16
+ score peak at exactly a positions back; its value copies that token's code into
17
  slot prev_a. Positions before the start fall back to position 0 (BOS).
18
 
19
+ The MLP has one neuron per kept context (1..n_prev+1 tokens) plus one always-on
20
+ unigram neuron. A neuron's gate sums code matches across slots minus (k - 0.5),
21
+ so it fires only when all k tokens match. It writes
22
+ log p(next | ctx) - log p(next | shorter ctx) into the out slot. All suffixes of
23
+ the true context fire together, so the sum telescopes to the log probability
24
+ under the longest kept context.
25
+
26
+ Compile settings live in compile_settings.json ("hf" for this dense model,
27
+ "web" for the sparse browser export written by export_web.py).
28
  """
29
 
30
  import json
31
  import math
32
+ import zlib
33
  from collections import Counter, defaultdict
34
+ from dataclasses import dataclass
35
  from pathlib import Path
36
 
37
+ import numpy as np
38
  import torch
39
  from safetensors.torch import save_file
40
  from tokenizers import Tokenizer, decoders, models, normalizers, pre_tokenizers, processors
 
42
 
43
  ROOT = Path(__file__).parent
44
  PHRASE_DIR = ROOT / "phrases" # hand-written everyday phrases
45
+ DATA_DIR = ROOT / "data" # human-written prompts/replies (scripts/fetch_coding_prompts.py)
46
+ SETTINGS_PATH = ROOT / "compile_settings.json"
47
  EOS, UNK = "<|endoftext|>", "<unk>" # EOS doubles as BOS and pad, like GPT-2
48
+ EOS_ID, UNK_ID = 0, 1
 
 
 
 
49
 
50
  SEED = 1234
51
  CODE_DIM = 384
 
 
52
  MAX_POS = 128
53
  ATTN_MARGIN = 30.0 # min attention logit gap (nats) between target and any other position
54
  BETA = 100.0 # gate sharpness; matched neurons sit at z = BETA / 2
 
60
  PRE_TOKENIZER = pre_tokenizers.ByteLevel(add_prefix_space=True)
61
 
62
 
63
+ @dataclass(frozen=True)
64
+ class Settings:
65
+ n_prev: int = 3 # previous tokens attended to; context = n_prev + current token
66
+ smoothing: str = "wb" # "wb" (Witten-Bell) or "kn" (interpolated Kneser-Ney)
67
+ discount: float = 0.75 # Kneser-Ney absolute discount
68
+ phrase_weight: float = 5.0 # weight of one hand-written phrase (phrases/*.txt)
69
+ prompt_weight: float = 1.0 # weight of one user prompt (data/prompts.txt)
70
+ reply_weight: float = 1.0 # weight of one assistant reply sentence (data/replies.txt)
71
+ max_vocab: int = 3000
72
+ min_word_count: float = 3
73
+ min_count: float | None = None # None: smallest context count that fits param_target
74
+ param_target: int = 400_000_000
75
 
76
+ @property
77
+ def order(self) -> int:
78
+ return self.n_prev + 1
79
 
 
 
80
 
81
+ def load_settings(name: str) -> Settings:
82
+ return Settings(**json.loads(SETTINGS_PATH.read_text())[name])
83
 
84
+
85
+ # ---------- text ----------
86
+
87
+
88
+ def pieces(text: str) -> list[str]:
89
+ return [p for p, _ in PRE_TOKENIZER.pre_tokenize_str(NORMALIZER.normalize_str(text))]
90
 
91
 
92
  def load_lines(paths: list[Path]) -> list[list[str]]:
 
103
  return out
104
 
105
 
106
+ def load_phrases() -> list[list[str]]:
107
+ return load_lines(sorted(PHRASE_DIR.glob("*.txt")))
108
+
 
 
 
109
 
110
+ def load_sources() -> dict[str, list[list[str]]]:
111
+ return {
112
+ "phrases": load_phrases(),
113
+ "prompts": load_lines([DATA_DIR / "prompts.txt"]),
114
+ "replies": load_lines([DATA_DIR / "replies.txt"]),
115
+ }
116
 
117
+
118
+ def heldout_split(lines: list[list[str]], pct: int = 10) -> tuple[list, list]:
119
+ """Deterministic split by content hash: (train, test)."""
120
+ test = [t for t in lines if zlib.crc32(" ".join(t).encode()) % 100 < pct]
121
+ train = [t for t in lines if zlib.crc32(" ".join(t).encode()) % 100 >= pct]
122
+ return train, test
123
+
124
+
125
+ # ---------- counting ----------
126
+
127
+
128
+ def weighted_sources(s: Settings, sources: dict) -> list[tuple[list[list[str]], float]]:
129
+ weights = {"phrases": s.phrase_weight, "prompts": s.prompt_weight, "replies": s.reply_weight}
130
+ return [(sources[name], w) for name, w in weights.items() if w > 0 and name in sources]
131
+
132
+
133
+ def build_vocab(s: Settings, sources: dict) -> list[str]:
134
+ freq = Counter()
135
+ for lines, w in weighted_sources(s, sources):
136
+ for toks in lines:
137
+ for p in toks:
138
+ freq[p] += w
139
+ by_freq = lambda p: (-freq[p], p) # noqa: E731
140
+ hand = {p for t in sources["phrases"] for p in t} if s.phrase_weight > 0 else set()
141
+ rest = sorted((p for p in freq if p not in hand and freq[p] >= s.min_word_count), key=by_freq)
142
+ words = hand | set(rest[: max(0, s.max_vocab - 2 - len(hand))])
143
+ return [EOS, UNK] + sorted(words, key=by_freq)
144
+
145
+
146
+ def padded_events(seq: list[int], order: int):
147
+ """(contexts of length 0..order, next token) for every position of a sequence."""
148
+ # `order` BOS tokens: the last one is the real BOS position the model sees.
149
+ padded = [EOS_ID] * order + seq + [EOS_ID]
150
+ for i in range(order - 1, len(padded) - 1):
151
+ yield [tuple(padded[i - k + 1 : i + 1]) if k else () for k in range(order + 1)], padded[i + 1]
152
+
153
+
154
+ def count_ngrams(weighted: list[tuple[list[int], float]], order: int) -> list[defaultdict]:
155
+ counts = [defaultdict(Counter) for _ in range(order + 1)]
156
  for seq, weight in weighted:
157
+ for ctxs, nxt in padded_events(seq, order):
158
+ if nxt == UNK_ID: # never learn to predict <unk>
159
  continue
160
  for k, ctx in enumerate(ctxs):
161
  counts[k][ctx][nxt] += weight
162
  return counts
163
 
164
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  class NGram:
166
+ """Interpolated n-gram over the kept contexts; the reference the weights must reproduce.
167
 
168
+ Witten-Bell: p_k(j) = (c_j + u * p_{k-1}(j)) / (c + u)
169
+ Kneser-Ney: p_k(j) = (c_j - d_j) / c + (sum_j d_j / c) * p_{k-1}(j), d_j = min(D, c_j),
170
+ with continuation counts below the top order (KenLM-style)
171
+ Both have the form p_k(j) = A_j + gamma * p_{k-1}(j), so each context's neuron can write
172
+ log p_k(j) - log(gamma * p_{k-1}(j)) for its seen tokens and 0 elsewhere.
173
+ """
174
 
175
+ def __init__(self, raw: list[dict], keep: list[set], vocab_size: int, s: Settings):
176
+ self.s, self.V, self.order = s, vocab_size, s.order
177
+ self.support = {ctx: sum(raw[k][ctx].values()) for k in range(1, self.order + 1) for ctx in keep[k]}
178
+ if s.smoothing == "kn":
179
+ cont = [defaultdict(Counter) for _ in range(self.order)]
180
+ for k in range(self.order):
181
+ for ctx, table in raw[k + 1].items():
182
+ for j in table:
183
+ cont[k][ctx[1:]][j] += 1
184
+ self.tables = [cont[0][()]] + [{c: cont[k][c] for c in keep[k]} for k in range(1, self.order)]
185
+ self.tables.append({c: raw[self.order][c] for c in keep[self.order]})
186
+ elif s.smoothing == "wb":
187
+ self.tables = [raw[0][()]] + [{c: raw[k][c] for c in keep[k]} for k in range(1, self.order + 1)]
188
+ else:
189
+ raise ValueError(s.smoothing)
190
+ for k in range(2, self.order + 1):
191
+ assert all(c[1:] in self.tables[k - 1] for c in self.tables[k]), "backoff chain broken"
192
+
193
+ uni = np.zeros(vocab_size)
194
+ for j, c in self.tables[0].items():
195
  uni[j] = c
196
+ logits = np.log(uni + 1.0) # add-one smoothing
197
+ logits[UNK_ID] = UNK_LOGIT
198
  self.base_logits = logits - logits[2:].mean() # constant shift; softmax-invariant
199
+ p0 = np.exp(self.base_logits - self.base_logits.max())
200
+ self.p0 = p0 / p0.sum()
201
+ self._prob, self._delta, self._stats = {}, {}, {}
202
+
203
+ def _table_stats(self, ctx: tuple):
204
+ if ctx not in self._stats:
205
+ table = self.tables[len(ctx)][ctx]
206
+ total = sum(table.values())
207
+ if self.s.smoothing == "wb":
208
+ u = len(table)
209
+ self._stats[ctx] = (table, {j: c / (total + u) for j, c in table.items()}, u / (total + u))
210
+ else:
211
+ d = {j: min(self.s.discount, c) for j, c in table.items()}
212
+ self._stats[ctx] = (table, {j: (c - d[j]) / total for j, c in table.items()}, sum(d.values()) / total)
213
+ return self._stats[ctx]
214
 
 
215
  def prob(self, j: int, ctx: tuple) -> float:
216
+ key = (j, ctx)
217
+ if key not in self._prob:
218
+ if not ctx:
219
+ p = self.p0[j]
220
+ else:
221
+ lower = self.prob(j, ctx[1:])
222
+ if ctx in self.tables[len(ctx)]:
223
+ _, a, gamma = self._table_stats(ctx)
224
+ p = a.get(j, 0.0) + gamma * lower
225
+ else:
226
+ p = lower
227
+ self._prob[key] = p
228
+ return self._prob[key]
229
 
230
  def delta(self, ctx: tuple) -> dict[int, float]:
231
+ """log p_k - log p_{k-1}, minus the constant log(gamma) shared by unseen tokens."""
232
+ if ctx not in self._delta:
233
+ table, a, gamma = self._table_stats(ctx)
234
+ out = {}
235
+ for j in table:
236
+ q = gamma * self.prob(j, ctx[1:])
237
+ out[j] = math.log(a[j] + q) - math.log(q)
238
+ self._delta[ctx] = out
239
+ return self._delta[ctx]
240
+
241
+ def delta_arrays(self, ctx: tuple) -> tuple[np.ndarray, np.ndarray]:
242
+ d = self.delta(ctx)
243
+ return np.fromiter(d.keys(), dtype=np.int64, count=len(d)), np.fromiter(d.values(), dtype=np.float64, count=len(d))
244
 
245
  def contexts(self) -> list[tuple]:
246
+ return [ctx for k in range(1, self.order + 1) for ctx in sorted(self.tables[k])]
247
 
248
+ def window(self, ids: list[int]) -> tuple:
249
+ """Last order tokens (oldest .. current); positions before 0 read BOS like the attention heads."""
250
  t = len(ids) - 1
251
+ return tuple(ids[max(t - a, 0)] for a in range(self.s.n_prev, -1, -1))
252
+
253
+ def predict(self, ids: list[int]) -> tuple[np.ndarray, float, int]:
254
+ """(logits up to a constant, support of the longest matched context, its length)."""
255
+ window = self.window(ids)
256
+ logits = self.base_logits.copy()
257
+ support, length = 0.0, 0
258
+ for k in range(1, self.order + 1):
259
+ ctx = window[-k:]
260
+ if ctx in self.tables[k]:
261
+ idx, vals = self.delta_arrays(ctx)
262
+ logits[idx] += vals
263
+ support, length = self.support[ctx], k
264
+ return logits, support, length
265
+
266
+ def reference_logits(self, ids: list[int]) -> torch.Tensor:
267
+ return torch.from_numpy(self.predict(ids)[0])
268
 
269
 
270
+ def compile_tables(s: Settings, sources: dict):
271
+ """vocab, NGram and stats for settings s (min_count=None fits the dense param budget)."""
272
+ vocab = build_vocab(s, sources)
273
+ index = {p: i for i, p in enumerate(vocab)}
274
+ weighted = [([index.get(p, UNK_ID) for p in toks], w) for lines, w in weighted_sources(s, sources) for toks in lines]
275
+ raw = count_ngrams(weighted, s.order)
276
+ forced = set()
277
+ if s.phrase_weight > 0:
278
+ for toks in sources["phrases"]:
279
+ for ctxs, _ in padded_events([index[p] for p in toks], s.order):
280
+ forced.update(ctxs[1:])
281
+
282
+ totals = {ctx: sum(t.values()) for k in range(1, s.order + 1) for ctx, t in raw[k].items()}
283
+ min_count = s.min_count
284
+ if min_count is None:
285
+ # Smallest count threshold whose kept neurons (forced + total >= threshold) fit the budget.
286
+ hist = Counter(0 if ctx in forced else c for ctx, c in totals.items())
287
+ thresholds = sorted(c for c in hist if c > 0)
288
+ running, kept_at = hist[0], {}
289
+ for c in reversed(thresholds):
290
+ running += hist[c]
291
+ kept_at[c] = running
292
+ fits = [c for c in thresholds if dense_params(len(vocab), s.n_prev, 1 + kept_at[c]) <= s.param_target]
293
+ if not fits:
294
+ raise SystemExit("hand-written contexts alone exceed param_target; lower max_vocab")
295
+ min_count = fits[0]
296
+ keep = [set()] + [{c for c in raw[k] if c in forced or totals[c] >= min_count} for k in range(1, s.order + 1)]
297
+ ngram = NGram(raw, keep, len(vocab), s)
298
+ stats = {
299
+ "vocab": len(vocab),
300
+ "min_context_count": min_count,
301
+ "contexts_seen": len(totals),
302
+ "contexts_kept": sum(len(k) for k in keep),
303
+ }
304
+ return vocab, ngram, stats
305
+
306
+
307
+ # ---------- dense transformer ----------
308
+
309
+
310
+ def hidden_size(V: int, n_prev: int) -> int:
311
+ # transformers validates hidden_size % num_heads == 0; pad with unused zero dims.
312
+ raw = 1 + (n_prev + 1) * CODE_DIM + V
313
+ return -(-raw // (8 * n_prev)) * (8 * n_prev)
314
+
315
+
316
+ def dense_params(V: int, n_prev: int, neurons: int) -> int:
317
+ D, h = hidden_size(V, n_prev), CODE_DIM
318
+ return 2 * V * D + (2 * n_prev + 2) * h * D + 3 * neurons * D + 3 * D
319
+
320
+
321
+ def rope_attention_scale(head_dim: int, n_prev: int) -> tuple[float, torch.Tensor, float]:
322
  """Pick rope_theta maximizing the worst-case attention margin; return (theta, inv_freq, q_amp)."""
323
  best = None
324
  for theta in ROPE_THETA_CANDIDATES:
 
326
  offsets = torch.arange(-MAX_POS, MAX_POS + 1, dtype=torch.float64)
327
  kernel = torch.cos(offsets[:, None] * inv_freq[None, :]).sum(1) # K(x), index x + MAX_POS
328
  worst = math.inf
329
+ for lag in range(1, n_prev + 1):
330
  for t in range(MAX_POS):
331
  s_target = max(t - lag, 0)
332
  xs = torch.tensor([t - s - lag for s in range(t + 1)]) + MAX_POS
 
343
  return theta, inv_freq, q_amp
344
 
345
 
346
+ def build_dense(vocab: list[str], ngram: NGram, s: Settings):
347
+ V, m, n_prev = len(vocab), CODE_DIM, s.n_prev
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
348
  contexts = ngram.contexts()
349
+ N = 1 + len(contexts)
350
+ CONST, CUR = 0, 1
351
+ prev_slot = [1 + a * m for a in range(n_prev + 1)] # prev_slot[0] is the cur slot
352
+ OUT = 1 + (n_prev + 1) * m
353
+ n_heads, h = n_prev, m
354
+ D = hidden_size(V, n_prev)
355
 
356
  gen = torch.Generator().manual_seed(SEED)
357
  codes = torch.randn(V, m, generator=gen, dtype=torch.float64)
 
365
  embed[:, CONST] = 1.0
366
  embed[:, CUR : CUR + m] = codes
367
 
368
+ theta, inv_freq, q_amp = rope_attention_scale(h, n_prev)
369
  half = h // 2
370
  q_proj = torch.zeros(n_heads * h, D, dtype=torch.float64)
371
  for a in range(n_heads):
 
387
  on = (BETA / 2) * torch.sigmoid(torch.tensor(BETA / 2, dtype=torch.float64)).item() # silu(BETA/2)
388
  up[:, CONST] = 1.0
389
  gate[0, CONST] = BETA / 2 # always-on unigram neuron
390
+ down[OUT : OUT + V, 0] = torch.from_numpy(OUT_SCALE * ngram.base_logits / on).float()
391
  for n, ctx in enumerate(contexts, start=1):
392
  k = len(ctx)
393
  for a, tok in enumerate(reversed(ctx)): # a = 0 current, a = 1 previous, ...
 
400
  lm_head[torch.arange(V), OUT + torch.arange(V)] = 1.0 / OUT_SCALE
401
 
402
  # RMSNorm weights undo the norm for the (fixed) input norms: |x|^2 = 2 before
403
+ # attention, 1 + 1 + n_prev after it.
404
  attn_norm = math.sqrt(2 / D)
405
+ mlp_norm = math.sqrt((2 + n_prev) / D)
406
  state = {
407
  "model.embed_tokens.weight": embed,
408
  "model.layers.0.input_layernorm.weight": torch.full((D,), attn_norm),
 
437
  pad_token_id=0,
438
  )
439
  info = {
 
440
  "hidden": D,
441
  "neurons": N,
 
 
442
  "rope_theta": theta,
443
  "max_code_coherence": round(max_coherence, 3),
444
  "params": sum(t.numel() for t in state.values()),
445
  }
446
+ return state, config, info
447
 
448
 
449
  def build_tokenizer(vocab: list[str]) -> PreTrainedTokenizerFast:
 
464
 
465
 
466
  def compile_model():
467
+ """Build the dense ("hf") model in memory (used by the tests for the n-gram reference)."""
468
+ s = load_settings("hf")
469
+ sources = load_sources()
470
+ vocab, ngram, stats = compile_tables(s, sources)
471
+ state, config, info = build_dense(vocab, ngram, s)
472
+ lines = {name: len(v) for name, v in sources.items()}
473
+ return lines, vocab, ngram, state, config, {**stats, **info}
474
 
475
 
476
  def main() -> None:
477
+ lines, vocab, _, state, config, info = compile_model()
478
  assert info["params"] <= 1_000_000_000, info
479
 
480
  save_file(state, ROOT / "model.safetensors", metadata={"format": "pt"})
 
499
  tok_cfg["model_input_names"] = ["input_ids", "attention_mask"]
500
  tok_cfg_path.write_text(json.dumps(tok_cfg, indent=2) + "\n")
501
 
502
+ print(json.dumps({"lines": lines, **info}, indent=2))
503
 
504
 
505
  if __name__ == "__main__":
compile_settings.json ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_comment": "Chosen by scripts/sweep.py (docs/sweep.json): best keystrokes saved at >= 90% precision on held-out prompts. hf = dense transformers model (vocab capped for width); web = sparse browser export.",
3
+ "hf": {
4
+ "n_prev": 3,
5
+ "smoothing": "kn",
6
+ "phrase_weight": 1.0,
7
+ "prompt_weight": 1.0,
8
+ "reply_weight": 1.0,
9
+ "max_vocab": 3000,
10
+ "min_count": null,
11
+ "param_target": 400000000
12
+ },
13
+ "web": {
14
+ "n_prev": 3,
15
+ "smoothing": "kn",
16
+ "phrase_weight": 1.0,
17
+ "prompt_weight": 1.0,
18
+ "reply_weight": 1.0,
19
+ "max_vocab": 8000,
20
+ "min_count": 2
21
+ },
22
+ "policy": {
23
+ "tau": 0.72,
24
+ "min_support": 0,
25
+ "max_words": 8
26
+ }
27
+ }
data/SOURCES.md CHANGED
@@ -1,19 +1,30 @@
1
  # Data sources
2
 
3
- `coding_prompts.txt` is produced by `scripts/fetch_coding_prompts.py`. Every line is
4
- human-written English prose taken from the datasets below. LLM-generated messages are
5
- excluded (OASST `synthetic` flag). The script keeps only prose: it removes code blocks,
6
- code-looking lines and markdown, keeps up to 2 sentences per prompt and 5 per reply, and
7
- keeps sentences of 3–24 words.
8
 
9
- | Dataset | What is used | License | Attribution |
10
- |---|---|---|---|
11
- | [OpenAssistant/oasst2](https://huggingface.co/datasets/OpenAssistant/oasst2) | English prompter **and** assistant messages (all turns, reviewed, not deleted, not synthetic) from conversations whose opening prompt is about code | Apache-2.0 | Köpf et al., *OpenAssistant Conversations: Democratizing Large Language Model Alignment*, 2023 |
12
- | [google-research-datasets/mbpp](https://huggingface.co/datasets/google-research-datasets/mbpp) | task descriptions (`text`), all splits | CC-BY-4.0 | Austin et al., *Program Synthesis with Large Language Models*, 2021 |
13
- | [neulab/conala](https://huggingface.co/datasets/neulab/conala) | curated intents (`intent`), paired train + test | MIT | Yin et al., *Learning to Mine Aligned Code and Natural Language Pairs from Stack Overflow*, MSR 2018 |
 
14
 
15
- Counts from the last fetch: 17,352 OASST sentences, 938 MBPP, 2,820 CoNaLa, and 19,974
16
- unique lines after removing duplicates.
17
 
18
- The model weights contain only n-gram statistics derived from these lines, plus the
19
- hand-written phrases in `../phrases/`. No dataset was used to train anything.
 
 
 
 
 
 
 
 
 
 
 
 
1
  # Data sources
2
 
3
+ `prompts.txt` and `replies.txt` are produced by `scripts/fetch_coding_prompts.py`. Every line is
4
+ human-written English prose taken from the datasets below. LLM-generated messages are excluded
5
+ (OASST `synthetic` flag). The script keeps only prose: it removes code blocks, code-looking
6
+ lines and markdown, keeps up to 2 sentences per prompt and 5 per reply, and keeps sentences
7
+ of 3–24 words.
8
 
9
+ | File | Dataset | What is used | License | Attribution |
10
+ |---|---|---|---|---|
11
+ | `prompts.txt` | [OpenAssistant/oasst2](https://huggingface.co/datasets/OpenAssistant/oasst2) | English **user** messages (all turns, reviewed, not deleted, not synthetic) from conversations whose opening prompt is about code | Apache-2.0 | Köpf et al., *OpenAssistant Conversations: Democratizing Large Language Model Alignment*, 2023 |
12
+ | `prompts.txt` | [google-research-datasets/mbpp](https://huggingface.co/datasets/google-research-datasets/mbpp) | task descriptions (`text`), all splits | CC-BY-4.0 | Austin et al., *Program Synthesis with Large Language Models*, 2021 |
13
+ | `prompts.txt` | [neulab/conala](https://huggingface.co/datasets/neulab/conala) | curated intents (`intent`), paired train + test | MIT | Yin et al., *Learning to Mine Aligned Code and Natural Language Pairs from Stack Overflow*, MSR 2018 |
14
+ | `replies.txt` | [OpenAssistant/oasst2](https://huggingface.co/datasets/OpenAssistant/oasst2) | English **assistant** messages, written by human volunteers, from the same conversations | Apache-2.0 | as above |
15
 
16
+ Counts from the last fetch:
 
17
 
18
+ | Source | Sentences |
19
+ |---|---|
20
+ | OASST user turns | 4,169 |
21
+ | MBPP | 938 |
22
+ | CoNaLa | 2,820 |
23
+ | OASST assistant turns | 13,183 |
24
+
25
+ After removing duplicates: **7,133** lines in `prompts.txt` and **12,841** in `replies.txt`.
26
+
27
+ The weights contain only n-gram statistics derived from these lines, plus the hand-written
28
+ phrases in `../phrases/`. No dataset was used to train anything. 10% of `prompts.txt` (chosen
29
+ by content hash) is held out when measuring precision and keystrokes saved
30
+ (`scripts/sweep.py`, `scripts/benchmark.py`).
data/prompts.txt ADDED
The diff for this file is too large to render. See raw diff
 
data/{coding_prompts.txt → replies.txt} RENAMED
The diff for this file is too large to render. See raw diff
 
docs/benchmarks.json CHANGED
@@ -7,41 +7,123 @@
7
  "python": "3.13.4",
8
  "torch": "2.15.0"
9
  },
10
- "compile": {
11
- "compile_seconds": 6.2,
12
- "compile_runs": [
13
- 6.4,
14
- 6.2
15
  ],
16
- "peak_memory_gb": 2.3,
17
  "byte_identical_rebuild": true,
18
- "model_sha256": "93c23040ccf6393710b0ee1d2fff98d57dc4193a27c4911cac7374e2c28f50f6",
19
- "file_gb": 1.52,
20
- "tokenize_seconds": 0.49,
21
- "build_tensors_seconds": 2.74,
 
 
22
  "source_lines": 21106,
23
  "params": 380290320,
24
  "nonzero_params": 26792230,
 
 
25
  "contexts_seen": 434284,
26
- "neurons": 24774
 
 
 
 
 
 
 
 
 
 
 
 
27
  },
28
  "pytorch": {
29
- "load_seconds": 0.3,
30
- "next_word_ms": 70.3,
31
- "generate_tokens_per_second": 16.4,
32
  "threads": 8
33
  },
34
- "typescript": {
35
- "runtime": "bun, plain TypeScript, no ML library",
36
- "load_seconds": 0.4,
37
- "seconds_per_token": 0.44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  },
39
- "max_logprob_error_vs_ngram": 2.4948840973593178e-05,
40
- "heldout": {
41
- "heldout_lines": 2040,
42
- "heldout_tokens": 35039,
43
- "next_token_top1": 0.258,
44
- "next_token_top3": 0.396,
45
- "oov_rate": 0.088
46
  }
47
  }
 
7
  "python": "3.13.4",
8
  "torch": "2.15.0"
9
  },
10
+ "compile_dense": {
11
+ "seconds": 6.9,
12
+ "runs": [
13
+ 6.9,
14
+ 7.0
15
  ],
 
16
  "byte_identical_rebuild": true,
17
+ "sha256": "ae857db3e321c35782192c198a7778987964e9cc147d2c67428fea9bf5777100",
18
+ "mb": 1521.16,
19
+ "peak_memory_gb": 2.4,
20
+ "tokenize_seconds": 0.48,
21
+ "count_seconds": 2.37,
22
+ "build_tensors_seconds": 1.07,
23
  "source_lines": 21106,
24
  "params": 380290320,
25
  "nonzero_params": 26792230,
26
+ "vocab": 3000,
27
+ "min_context_count": 8.0,
28
  "contexts_seen": 434284,
29
+ "contexts_kept": 24773
30
+ },
31
+ "compile_web": {
32
+ "seconds": 6.6,
33
+ "runs": [
34
+ 7.2,
35
+ 6.6
36
+ ],
37
+ "byte_identical_rebuild": true,
38
+ "sha256": "d770bf4ec6644ccab5646e2b1244b58b185378b371c52b1c263b5a0ebf3f8499",
39
+ "mb": 4.49,
40
+ "contexts": 107864,
41
+ "vocab": 6612
42
  },
43
  "pytorch": {
44
+ "load_seconds": 0.1,
45
+ "next_word_ms": 57.9,
46
+ "generate_tokens_per_second": 18.0,
47
  "threads": 8
48
  },
49
+ "web_keystroke_latency": {
50
+ "runtime": "web/autocomplete.ts on Bun",
51
+ "median_ms": 0.5,
52
+ "p95_ms": 0.92,
53
+ "max_ms": 3.48
54
+ },
55
+ "max_logprob_error_vs_ngram": 1.6793374655321713e-05,
56
+ "heldout_typing_web": {
57
+ "policy": {
58
+ "tau": 0.72,
59
+ "min_support": 0,
60
+ "max_words": 8
61
+ },
62
+ "heldout_prompts": 711,
63
+ "characters": 42185,
64
+ "tau=0.72": {
65
+ "precision": 0.9029,
66
+ "coverage": 0.3866,
67
+ "keystrokes_saved": 0.2296,
68
+ "shown": 16034
69
+ },
70
+ "tau=0.8": {
71
+ "precision": 0.9351,
72
+ "coverage": 0.3412,
73
+ "keystrokes_saved": 0.2066,
74
+ "shown": 14153
75
+ },
76
+ "tau=0.9": {
77
+ "precision": 0.9644,
78
+ "coverage": 0.2692,
79
+ "keystrokes_saved": 0.1642,
80
+ "shown": 11165
81
+ },
82
+ "tau=0.95": {
83
+ "precision": 0.9773,
84
+ "coverage": 0.2049,
85
+ "keystrokes_saved": 0.1176,
86
+ "shown": 8497
87
+ }
88
+ },
89
+ "heldout_typing_dense_tables": {
90
+ "policy": {
91
+ "tau": 0.72,
92
+ "min_support": 0,
93
+ "max_words": 8
94
+ },
95
+ "heldout_prompts": 711,
96
+ "characters": 42185,
97
+ "tau=0.72": {
98
+ "precision": 0.9243,
99
+ "coverage": 0.3245,
100
+ "keystrokes_saved": 0.1902,
101
+ "shown": 13458
102
+ },
103
+ "tau=0.8": {
104
+ "precision": 0.9516,
105
+ "coverage": 0.2698,
106
+ "keystrokes_saved": 0.1574,
107
+ "shown": 11191
108
+ },
109
+ "tau=0.9": {
110
+ "precision": 0.9736,
111
+ "coverage": 0.1921,
112
+ "keystrokes_saved": 0.1057,
113
+ "shown": 7966
114
+ },
115
+ "tau=0.95": {
116
+ "precision": 0.9843,
117
+ "coverage": 0.1322,
118
+ "keystrokes_saved": 0.0698,
119
+ "shown": 5481
120
+ }
121
  },
122
+ "heldout_next_token_dense": {
123
+ "heldout_lines": 711,
124
+ "heldout_tokens": 9466,
125
+ "next_token_top1": 0.267,
126
+ "next_token_top3": 0.406,
127
+ "oov_rate": 0.07
 
128
  }
129
  }
docs/experiment_topic.json ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "topic_words": 48,
3
+ "results": [
4
+ {
5
+ "lambda": 0.0,
6
+ "tau": 0.72,
7
+ "min_support": 0,
8
+ "precision": 0.9029,
9
+ "coverage": 0.3866,
10
+ "keystrokes_saved": 0.2296,
11
+ "shown": 16034
12
+ },
13
+ {
14
+ "lambda": 0.25,
15
+ "tau": 0.72,
16
+ "min_support": 0,
17
+ "precision": 0.9032,
18
+ "coverage": 0.3864,
19
+ "keystrokes_saved": 0.2297,
20
+ "shown": 16026
21
+ },
22
+ {
23
+ "lambda": 0.5,
24
+ "tau": 0.72,
25
+ "min_support": 0,
26
+ "precision": 0.9031,
27
+ "coverage": 0.386,
28
+ "keystrokes_saved": 0.2293,
29
+ "shown": 16007
30
+ },
31
+ {
32
+ "lambda": 1.0,
33
+ "tau": 0.73,
34
+ "min_support": 0,
35
+ "precision": 0.904,
36
+ "coverage": 0.3811,
37
+ "keystrokes_saved": 0.2281,
38
+ "shown": 15804
39
+ }
40
+ ]
41
+ }
docs/how-it-works.md CHANGED
@@ -1,60 +1,62 @@
1
  # How tiny-autocomplete was built
2
 
3
- This model was **compiled, not trained**. No gradient descent, no optimizer, no
4
- pretrained weights, and no other model's outputs were used. `build_weights.py` turns
5
- text into a standard `LlamaForCausalLM` checkpoint using closed-form rules.
6
- Running it twice gives byte-identical weights.
 
 
7
 
8
- The network computes an **interpolated Witten-Bell 5-gram language model** exactly. It
9
- predicts the next token from the current token and up to 3 before it.
 
10
 
11
  ## 1. Source text and tokenizer
12
 
13
- There are two sources:
 
 
 
 
14
 
15
- - **`phrases/*.txt`:** 1,139 common English sentences, one per line, in four files: social
16
- phrases, everyday requests, idioms and proverbs, and opinions and work phrases. This
17
- list was written for this project by an AI coding assistant (Claude). Each phrase counts
18
- as **5** occurrences, and all of its contexts are always kept (section 5).
19
- - **`data/coding_prompts.txt`:** 19,967 human-written English sentences about programming.
20
- `scripts/fetch_coding_prompts.py` downloads them from OpenAssistant oasst2 (prompts and
21
- replies in coding conversations, with synthetic messages excluded), MBPP and CoNaLa. It
22
- strips code and markdown and keeps short prose sentences. See `data/SOURCES.md` for
23
- licenses and attribution. Each line counts once.
24
 
25
  The tokenizer is word-level with GPT-2 style byte-level pre-tokenization:
26
 
27
  - NFKC normalization, then lowercase.
28
  - Split on the GPT-2 regex. Words keep their leading space as `Ġ`, and punctuation and
29
  contractions (`'s`, `'t`, ...) become their own tokens.
30
- - The vocabulary is every word in the hand-written phrases, then the most frequent data
31
- words seen ≥ 3 times, up to 3,000 entries in total. Each word costs one hidden dimension
32
- (the logit slot), so the cap matters. The two specials are `<|endoftext|>` (id 0, used as
33
- BOS, EOS and pad) and `<unk>` (id 1). Rarer words become `<unk>`. `<unk>` can appear
34
- inside contexts ("convert <unk> to a") but is never predicted.
 
35
  - A post-processor prepends BOS, so every prompt starts at position 0 with `<|endoftext|>`.
36
 
37
  ## 2. The reference n-gram
38
 
39
- Each line becomes `[BOS, BOS, BOS, BOS, w1, ..., wn, EOS]`. For every position, the
40
- compiler counts which token follows each context of length k = 0..4 that ends there
41
- (weighted by source).
42
 
43
  - **Order 0:** add-one-smoothed unigram, with `<unk>` pinned at logit -30.
44
- - **Order k ≥ 1:** Witten-Bell interpolation with the next-shorter context (drop the
45
- oldest token):
46
 
47
  ```
48
- p_k(j | ctx) = (c(ctx, j) + u(ctx) * p_{k-1}(j | ctx[1:])) / (c(ctx) + u(ctx))
 
49
  ```
50
 
51
- Here `c` is a count and `u(ctx)` is the number of distinct tokens seen after `ctx`.
52
- An unseen context backs off: `p_k = p_{k-1}`.
 
 
53
 
54
- The model must reproduce `p_K(· | longest kept context)`, where K is the length of the
55
- longest context that has a neuron (section 5).
56
 
57
- ## 3. The residual stream as named slots
58
 
59
  | Dims | Slot | Written by |
60
  |---|---|---|
@@ -66,20 +68,19 @@ longest context that has a neuron (section 5).
66
  | 1537–4536 | **logits**, one dim per vocab entry, scaled by 1e-5 | MLP |
67
  | 4537–4559 | unused padding (transformers wants hidden % heads == 0) | — |
68
 
69
- **Token codes** are seeded Gaussian vectors in 384 dimensions, normalized to unit
70
- length. With 3,000 of them, the largest |cosine| between two different codes is 0.256.
71
- That's far enough below 1 that a "match" (dot = 1) and a "non-match" (dot ≤ 0.26) can't
72
- be confused.
73
 
74
  ## 4. Attention: previous-token heads from RoPE alone
75
 
76
- Llama has no absolute positions. The only position signal is RoPE, which rotates query
77
- and key pairs by `position * inv_freq[i]`.
78
 
79
- - The **key** for every token is the same constant (read from the constant dim): 1 on
80
- the first half of each RoPE pair.
81
  - The **query** for head *a* is also constant, with each pair's phase set to
82
- `-a * inv_freq[i]`. The attention score between positions t and s is then
83
 
84
  ```
85
  score(t, s) = A * Σ_i cos((t - s - a) * inv_freq[i]) / sqrt(head_dim)
@@ -88,91 +89,154 @@ and key pairs by `position * inv_freq[i]`.
88
  which peaks exactly when `s = t - a`.
89
  - When `t < a` there is no position `a` back, and the closest position is 0, which is BOS.
90
  The model then "sees" BOS in that slot, matching the BOS padding used when counting.
91
- - `rope_theta` is picked from a candidate list to maximize the worst-case gap between the
92
- target position and every other position, for all t < 128 and a ∈ {1, 2, 3}. It came out
93
- to 2. The amplitude `A` then scales that worst gap to **30 nats**, so softmax puts
94
- > 0.9999999 weight on the target.
95
  - **Value** = the current-token code. **Output projection** = identity into slot *a*.
96
 
97
- All 3 heads share one key/value head (`num_key_value_heads = 1`).
98
 
99
  ## 5. MLP: one neuron per kept context
100
 
101
- Llama's MLP is `down(silu(gate(x)) * up(x))`. The compiler creates one neuron for each
102
- **kept** context (lengths 1–4) and one always-on unigram neuron.
103
-
104
- **Pruning to a budget.** The sources contain 434,284 distinct contexts, and each neuron
105
- costs 3 × hidden ≈ 13.7k parameters. A context is kept if it was seen at least
106
- `min_count` times or comes from the hand-written phrases. `min_count` is raised from 1
107
- until the model fits `PARAM_TARGET` (400M). It came out to **8**, keeping 24,773
108
- contexts. Pruning never breaks a backoff chain: a context's shorter suffix is always seen
109
- at least as often, and suffixes of hand-written contexts are hand-written. The reference
110
- n-gram uses the same kept set, so the model still matches it exactly.
111
-
112
- For a context `(c_{k-1}, ..., c_1, c_0)` of length k (c_0 = current token):
113
 
114
  - **gate** = `100 * (code(c_0)·cur + code(c_1)·prev1 + ... - (k - 0.5) * const)`.
115
- If all k tokens match, this is `+50`. If any one fails (dot ≤ 0.26), it's at most
116
- `100 * (0.26 - 0.5) = -24`, and `silu(-24)` ≈ -1e-9, effectively off.
 
117
  - **up** = 1 (the constant dim).
118
- - **down** writes, for each token j seen after this context,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
 
120
- ```
121
- delta_j = log p_k(j | ctx) - log p_{k-1}(j | ctx[1:])
122
- ```
123
 
124
- into the logit slot. The constant `log(u/(c+u))` shared by every unseen token is
125
- dropped, since softmax ignores constant shifts. That makes the column sparse.
 
 
126
 
127
- **Why this is exact.** If a context of length k is kept, so are all its shorter
128
- suffixes, and their neurons fire too. The unigram neuron writes `log p_0`, and the
129
- neurons for lengths 1..K add their deltas. The sum telescopes to `log p_K(· | longest
130
- known context)` plus a constant. No neuron has to know which context is the longest.
131
 
132
- ## 6. Norms and output head
 
 
 
 
133
 
134
- - The residual has a known fixed norm at each point: |x|² = 2 before attention and 5
135
- after it. Each RMSNorm weight is set to `sqrt(norm² / hidden)`, so the norms leave the
136
- stream unchanged.
137
- - The MLP writes logits scaled by 1e-5, so they barely change the final norm (< 0.001%).
138
- At 1e-4, the 3,000-entry logit slot shifted the norm enough to cause 1e-2-nat errors on
139
- extreme logits.
140
- - `lm_head` is `1e5` times the identity on the logit slot.
 
 
 
 
 
 
 
141
 
142
- ## 7. Verification (`test_model.py`)
143
 
144
- The tests check the following:
145
- - **Hugging Face loading:** the files load with `AutoModelForCausalLM`, `AutoTokenizer` and
146
- `pipeline("text-generation")` on transformers 4.52 and 5.22.
147
- - **Exactness:** next-token log-probs match the reference n-gram within 1e-3 nats (observed
148
- ~3e-5). This is checked on 200 phrase prefixes and 50 random word-salad sequences of up to
149
- 80 tokens.
150
- - **Completions:** a set of prompts produce the expected greedy completions.
151
- - **Unknown words:** out-of-vocabulary words back off cleanly.
152
- - **TypeScript parity:** the zero-dependency TypeScript runtime (`model.ts`) gives the same
153
- top-3 tokens and log-probs as PyTorch.
154
 
155
- ## 8. Changing it
 
156
 
157
- Edit or add lines in `phrases/*.txt`, or add any `.txt` file to `data/` (one sentence per
158
- line). Then run `python build_weights.py` and `python test_model.py`. Adding data never
159
- increases the size; it raises the pruning threshold instead, so only the most frequent
160
- patterns survive. Settings at the top of `build_weights.py`:
 
 
 
161
 
162
- | Constant | Meaning |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  |---|---|
164
- | `PARAM_TARGET` | parameter budget the pruning threshold is fitted to (400M) |
165
- | `MAX_VOCAB`, `MIN_WORD_COUNT` | vocabulary cap and minimum word frequency |
166
- | `PHRASE_WEIGHT` | how many data occurrences one hand-written phrase counts as |
167
- | `CODE_DIM` | token-code size (bigger means fewer near-collisions and a larger model) |
168
- | `N_PREV` | how many previous tokens are attended to (n-gram order = N_PREV + 2) |
169
- | `MAX_POS` | longest sequence the attention margin is guaranteed for |
170
- | `ATTN_MARGIN`, `BETA` | sharpness of attention and of the neuron gates |
171
 
172
  ## History
173
 
174
  1. **120 parameters** (hidden size 2): one fixed next word for each of 22 words, with no
175
  context.
176
- 2. **91M parameters:** the compiled 5-gram over the 1,139 hand-written phrases.
177
- 3. **380M parameters (current):** adds ~20k human-written coding sentences, plus a
178
- vocabulary cap and count pruning to stay within the budget.
 
 
 
 
1
  # How tiny-autocomplete was built
2
 
3
+ This model was **compiled, not trained**. No gradient descent, no optimizer, no pretrained
4
+ weights, and no other model's outputs were used.
5
+ - `build_weights.py` turns text into a standard `LlamaForCausalLM` checkpoint using
6
+ closed-form rules.
7
+ - `export_web.py` writes the same kind of network in sparse form for the browser.
8
+ - Rebuilds are byte-identical.
9
 
10
+ The network computes an **interpolated Kneser-Ney 5-gram language model** exactly. It predicts
11
+ the next token from the current token and the 3 before it. Witten-Bell smoothing and other
12
+ context lengths are supported too; the choice was made by measurement (section 9).
13
 
14
  ## 1. Source text and tokenizer
15
 
16
+ | Source | Lines | Weight |
17
+ |---|---|---|
18
+ | `phrases/*.txt`: everyday English, written for this project by an AI coding assistant (Claude) | 1,139 | 1 |
19
+ | `data/prompts.txt`: what people type to an assistant (OASST user turns, MBPP, CoNaLa) | 7,133 | 1 |
20
+ | `data/replies.txt`: human-written assistant replies from OASST coding conversations | 12,834 | 1 |
21
 
22
+ `scripts/fetch_coding_prompts.py` downloads the data, removes code blocks, code-looking lines and
23
+ markdown, and keeps short prose sentences. Licenses and attribution are in `data/SOURCES.md`.
 
 
 
 
 
 
 
24
 
25
  The tokenizer is word-level with GPT-2 style byte-level pre-tokenization:
26
 
27
  - NFKC normalization, then lowercase.
28
  - Split on the GPT-2 regex. Words keep their leading space as `Ġ`, and punctuation and
29
  contractions (`'s`, `'t`, ...) become their own tokens.
30
+ - Vocabulary:
31
+ - every hand-written word, then the most frequent data words seen ≥ 3 times;
32
+ - capped at **3,000** for the dense model, where each word costs one hidden dimension;
33
+ - capped at 8,000 for the browser export, where **6,612** words qualify;
34
+ - two specials: `<|endoftext|>` (id 0, used as BOS, EOS and pad) and `<unk>` (id 1);
35
+ - rarer words become `<unk>`, which can appear inside contexts but is never predicted.
36
  - A post-processor prepends BOS, so every prompt starts at position 0 with `<|endoftext|>`.
37
 
38
  ## 2. The reference n-gram
39
 
40
+ Each line becomes `[BOS × 4, w1, ..., wn, EOS]`. For every position, the compiler counts which
41
+ token follows each context of length k = 0..4 that ends there, weighted by source.
 
42
 
43
  - **Order 0:** add-one-smoothed unigram, with `<unk>` pinned at logit -30.
44
+ - **Order k ≥ 1:** interpolate with the next-shorter context `ctx[1:]`. Both smoothing methods
45
+ have the form `p_k(j) = A_j + γ · p_{k-1}(j)`:
46
 
47
  ```
48
+ Witten-Bell: A_j = c_j / (c + u) γ = u / (c + u)
49
+ Kneser-Ney: A_j = (c_j - d_j) / c, d_j = min(0.75, c_j) γ = Σ d_j / c
50
  ```
51
 
52
+ Here `c_j` is the count of token j after the context, `c` the total, and `u` the number of
53
+ distinct next tokens. Kneser-Ney uses **continuation counts** (how many distinct tokens
54
+ precede `ctx + j`) for every order below the top one, as KenLM does. An unseen or pruned
55
+ context backs off: `p_k = p_{k-1}`.
56
 
57
+ The model must reproduce `p_K(· | longest kept context)`.
 
58
 
59
+ ## 3. The residual stream as named slots (dense model)
60
 
61
  | Dims | Slot | Written by |
62
  |---|---|---|
 
68
  | 1537–4536 | **logits**, one dim per vocab entry, scaled by 1e-5 | MLP |
69
  | 4537–4559 | unused padding (transformers wants hidden % heads == 0) | — |
70
 
71
+ **Token codes** are seeded Gaussian vectors in 384 dimensions, normalized to unit length. With
72
+ 3,000 of them, the largest |cosine| between two different codes is 0.256. That's far enough
73
+ below 1 that a "match" (dot = 1) and a "non-match" (dot ≤ 0.26) can't be confused.
 
74
 
75
  ## 4. Attention: previous-token heads from RoPE alone
76
 
77
+ Llama has no absolute positions. The only position signal is RoPE, which rotates query and key
78
+ pairs by `position * inv_freq[i]`.
79
 
80
+ - The **key** for every token is the same constant (read from the constant dim): 1 on the
81
+ first half of each RoPE pair.
82
  - The **query** for head *a* is also constant, with each pair's phase set to
83
+ `-a * inv_freq[i]`. Then
84
 
85
  ```
86
  score(t, s) = A * Σ_i cos((t - s - a) * inv_freq[i]) / sqrt(head_dim)
 
89
  which peaks exactly when `s = t - a`.
90
  - When `t < a` there is no position `a` back, and the closest position is 0, which is BOS.
91
  The model then "sees" BOS in that slot, matching the BOS padding used when counting.
92
+ - `rope_theta` is picked to maximize the worst-case gap between the target position and every
93
+ other position, for all t < 128 and every head. It came out to 2. `A` scales that gap to
94
+ **30 nats**.
 
95
  - **Value** = the current-token code. **Output projection** = identity into slot *a*.
96
 
97
+ All heads share one key/value head (`num_key_value_heads = 1`).
98
 
99
  ## 5. MLP: one neuron per kept context
100
 
101
+ Llama's MLP is `down(silu(gate(x)) * up(x))`. The compiler creates one neuron for each **kept**
102
+ context (lengths 1–4) and one always-on unigram neuron. For a context `(c_{k-1}, ..., c_0)`
103
+ (c_0 = current token):
 
 
 
 
 
 
 
 
 
104
 
105
  - **gate** = `100 * (code(c_0)·cur + code(c_1)·prev1 + ... - (k - 0.5) * const)`.
106
+ - If all k tokens match, this is `+50`.
107
+ - If any one fails, it's at most `100 * (0.26 - 0.5) = -24`, and `silu(-24)` ≈ -1e-9,
108
+ effectively off.
109
  - **up** = 1 (the constant dim).
110
+ - **down** writes, for each token j seen after the context, `log p_k(j) − log(γ · p_{k-1}(j))`.
111
+ Every unseen token would get the same `log γ`, which softmax ignores, so the column is sparse.
112
+
113
+ **Why this is exact.** If a context of length k is kept, so are its shorter suffixes, and
114
+ their neurons fire too. The unigram neuron writes `log p_0`, and the neurons for lengths 1..K
115
+ add their deltas. The sum telescopes to `log p_K(· | longest kept context)` plus a constant.
116
+
117
+ **Pruning.** A context is kept if it was seen at least `min_count` times (weighted) or comes
118
+ from the hand-written phrases. A suffix is always seen at least as often as the contexts
119
+ ending in it, so pruning never breaks a backoff chain. The reference n-gram uses the same kept
120
+ set, so the model still matches it exactly.
121
+ - **Dense model:** `min_count` is the smallest value that fits 400M parameters. That's **8**,
122
+ keeping 24,773 of 434,284 contexts. Each neuron costs 3 × hidden ≈ 13.7k parameters.
123
+ - **Browser export:** `min_count` = **2**, keeping 107,864 of 520,490 contexts, because a
124
+ sparse neuron costs only a few bytes.
125
+
126
+ ## 6. Norms and output head (dense model)
127
+
128
+ - The residual has a known fixed norm at each point: |x|² = 2 before attention and 5 after.
129
+ Each RMSNorm weight is `sqrt(norm² / hidden)`, so the norms leave the stream unchanged.
130
+ - The MLP writes logits scaled by 1e-5, so they barely change the final norm (< 0.001%). At
131
+ 1e-4, the 3,000-entry logit slot shifted the norm enough to cause 1e-2-nat errors on extreme
132
+ logits.
133
+ - `lm_head` is `1e5` times the identity on the logit slot.
134
 
135
+ ## 7. Browser export (`web/model.bin`, `web/autocomplete.ts`)
 
 
136
 
137
+ 93% of the dense weights are zero. The attention and embedding weights are fixed by
138
+ construction, and each MLP neuron is fully described by:
139
+ - which tokens its gate matches (its context);
140
+ - which logits its down-projection changes, and by how much.
141
 
142
+ `export_web.py` stores exactly that, plus the unigram neuron and the vocabulary:
143
+ - **contents:** per context its token ids, its count (support) and the logit changes it
144
+ writes (as float32);
145
+ - **size:** 4.5 MB for 107,864 contexts.
146
 
147
+ `web/autocomplete.ts` looks up the neurons whose context matches the last 1–4 tokens (hash
148
+ maps per context length), adds their deltas to the unigram logits, and applies softmax.
149
+ - **Speed:** 0.5 ms median per keystroke.
150
+ - **Exactness:** a test exports the dense model's own tables and checks that the runtime's
151
+ top-5 next tokens and log-probs equal PyTorch's.
152
 
153
+ ## 8. Confidence policy (`suggest.py`, mirrored by `web/autocomplete.ts`)
154
+
155
+ Nothing here is learned; the policy reads the compiled probabilities.
156
+
157
+ 1. **Candidates.** The last pre-token is the word being typed (just `Ġ` after a space). Only
158
+ vocabulary tokens starting with it are candidates (`fun` → `function`).
159
+ 2. **Probability.** Candidate probabilities are renormalized over the candidates, then scaled
160
+ by `1 − P(OOV | prefix)`: the weighted share of source words with this prefix that are
161
+ outside the vocabulary. Without that, a prefix whose only in-vocabulary completion is
162
+ `function` would get 100% even when the user is typing `funky`.
163
+ 3. **Show** the top candidate only if its probability ≥ τ (and the matched context's support
164
+ ≥ `min_support`; the sweep found 0 best).
165
+ 4. **Extend** greedily word by word while the product of probabilities stays ≥ τ, up to 8
166
+ words, stopping at end-of-text or after `. ? ! :`.
167
 
168
+ τ = 0.72 by default. It's a runtime argument (`ac.suggest(text, {...ac.policy, tau})`).
169
 
170
+ ## 9. Choosing the settings (`scripts/sweep.py`)
 
 
 
 
 
 
 
 
 
171
 
172
+ The compile rules are fixed. A handful of compile settings and the threshold were picked by
173
+ benchmarking:
174
 
175
+ 1. Hold out 10% of `data/prompts.txt` (711 prompts) and compile without them.
176
+ 2. Type each held-out prompt one character at a time. After each keystroke, record the
177
+ suggestion chain.
178
+ 3. Score every threshold: **precision** (shown suggestions that exactly match the next
179
+ characters), **coverage**, and **keystrokes saved** (accepting each correct suggestion with
180
+ one Tab).
181
+ 4. Keep the setting with the most keystrokes saved at precision ≥ 90%, and an export ≤ 50 MB.
182
 
183
+ Highlights from `docs/sweep.json` (keystrokes saved at ≥ 90% precision, 0.01 threshold grid):
184
+
185
+ | Setting | Keystrokes saved |
186
+ |---|---|
187
+ | Witten-Bell, 4-token context (phrases ×5) | 21.7% |
188
+ | **Kneser-Ney, 4-token context (phrases ×5)** | **22.5%** |
189
+ | Kneser-Ney, 5 / 6 / 8 / 10-token context (phrases ×5) | 22.2% / 22.0% / 21.6% / 21.6% |
190
+ | no assistant replies (prompts + phrases only) | 20.1–20.9% |
191
+ | assistant replies ×0.3 | 21.8–22.4% |
192
+ | **final: KN, 4 tokens, all sources ×1, vocab 8,000, min_count 2** | **23.0%** |
193
+ | final settings with 5 / 6 / 8 / 10-token context | 22.8% / 22.7% / 22.4% / 22.4% |
194
+
195
+ - **Longer contexts:** they lose slightly. With ~21k lines, 5+-token contexts rarely repeat,
196
+ and when they don't match the model is back to the same 4-token prediction.
197
+ - **Topic head** (`scripts/experiment_topic.py`): a content-addressed head that would carry
198
+ the most recent language or tool name (python, sql, react, …) from anywhere earlier in the
199
+ prompt.
200
+ - Tested as a closed-form log-ratio bias `λ · log p(w | topic) / p(w)`.
201
+ - At best it left keystrokes saved at 23.0% (22.97% at λ = 0.25), and λ = 1 was worse.
202
+ - It wasn't compiled.
203
+
204
+ ## 10. Verification (`test_model.py`)
205
+
206
+ - **Hugging Face loading** with `AutoModelForCausalLM`, `AutoTokenizer` and `pipeline` on
207
+ transformers 4.52 and 5.22.
208
+ - **Exactness:** dense log-probs match the reference n-gram within 1e-3 nats (observed ~2e-5)
209
+ on 200 phrase prefixes and 50 random sequences of up to 80 tokens.
210
+ - **Completions:** a set of greedy completions; unknown words back off cleanly.
211
+ - **Dense TypeScript runtime** (`model.ts`): same top-3 tokens and log-probs as PyTorch.
212
+ - **Browser runtime:**
213
+ - the same suggestion chains (text, probability, support) as `suggest.py` on 300 random
214
+ prefixes;
215
+ - the same distribution as PyTorch when given the dense model's tables;
216
+ - `model.bin` ≤ 50 MB.
217
+
218
+ ## 11. Changing it
219
+
220
+ Edit `phrases/*.txt` or `data/*.txt` (one sentence per line), or the settings in
221
+ `compile_settings.json`. Then run `python build_weights.py && python export_web.py &&
222
+ python test_model.py`. Run `python scripts/sweep.py` to re-check the settings.
223
+
224
+ | Setting | Meaning |
225
  |---|---|
226
+ | `n_prev` | previous tokens attended to (context = n_prev + 1) |
227
+ | `smoothing`, `discount` | `kn` or `wb`; Kneser-Ney discount |
228
+ | `phrase_weight`, `prompt_weight`, `reply_weight` | how much each source counts |
229
+ | `max_vocab`, `min_word_count` | vocabulary cap and minimum word frequency |
230
+ | `min_count` / `param_target` | pruning threshold, or the dense budget to fit it to |
231
+ | `policy.tau`, `policy.min_support`, `policy.max_words` | confidence policy shipped in `model.bin` |
 
232
 
233
  ## History
234
 
235
  1. **120 parameters** (hidden size 2): one fixed next word for each of 22 words, with no
236
  context.
237
+ 2. **91M parameters:** the compiled 5-gram over 1,139 hand-written phrases.
238
+ 3. **380M parameters:** adds ~20k human-written coding sentences, with a vocabulary cap and
239
+ count pruning to fit the budget.
240
+ 4. **Current:** Kneser-Ney smoothing; a confidence-gated ghost-text policy; settings chosen by
241
+ held-out precision and keystrokes saved; a 4.5 MB browser export with a zero-dependency
242
+ runtime.
docs/sweep.json ADDED
@@ -0,0 +1,2181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "target_precision": 0.9,
3
+ "max_export_mb": 50,
4
+ "heldout_prompts": 711,
5
+ "best": {
6
+ "settings": {
7
+ "n_prev": 3,
8
+ "smoothing": "kn",
9
+ "discount": 0.75,
10
+ "phrase_weight": 1.0,
11
+ "prompt_weight": 1,
12
+ "reply_weight": 1.0,
13
+ "max_vocab": 8000,
14
+ "min_word_count": 3,
15
+ "min_count": 2,
16
+ "param_target": 400000000
17
+ },
18
+ "vocab": 6550,
19
+ "min_context_count": 2,
20
+ "contexts_seen": 509522,
21
+ "contexts_kept": 105467,
22
+ "export_mb": 4.2,
23
+ "best": {
24
+ "tau": 0.72,
25
+ "min_support": 0,
26
+ "precision": 0.9029,
27
+ "coverage": 0.3866,
28
+ "keystrokes_saved": 0.2296,
29
+ "shown": 16034
30
+ },
31
+ "seconds": 14.7
32
+ },
33
+ "runs": [
34
+ {
35
+ "settings": {
36
+ "n_prev": 3,
37
+ "smoothing": "wb",
38
+ "discount": 0.75,
39
+ "phrase_weight": 5,
40
+ "prompt_weight": 1,
41
+ "reply_weight": 1,
42
+ "max_vocab": 8000,
43
+ "min_word_count": 3,
44
+ "min_count": 2,
45
+ "param_target": 400000000
46
+ },
47
+ "vocab": 6550,
48
+ "min_context_count": 2,
49
+ "contexts_seen": 509522,
50
+ "contexts_kept": 105467,
51
+ "export_mb": 4.2,
52
+ "best": {
53
+ "tau": 0.81,
54
+ "min_support": 0,
55
+ "precision": 0.9023,
56
+ "coverage": 0.3647,
57
+ "keystrokes_saved": 0.2173,
58
+ "shown": 15125
59
+ },
60
+ "seconds": 13.3
61
+ },
62
+ {
63
+ "settings": {
64
+ "n_prev": 4,
65
+ "smoothing": "wb",
66
+ "discount": 0.75,
67
+ "phrase_weight": 5,
68
+ "prompt_weight": 1,
69
+ "reply_weight": 1,
70
+ "max_vocab": 8000,
71
+ "min_word_count": 3,
72
+ "min_count": 2,
73
+ "param_target": 400000000
74
+ },
75
+ "vocab": 6550,
76
+ "min_context_count": 2,
77
+ "contexts_seen": 754606,
78
+ "contexts_kept": 125943,
79
+ "export_mb": 4.9,
80
+ "best": {
81
+ "tau": 0.83,
82
+ "min_support": 0,
83
+ "precision": 0.9021,
84
+ "coverage": 0.3555,
85
+ "keystrokes_saved": 0.2116,
86
+ "shown": 14744
87
+ },
88
+ "seconds": 15.0
89
+ },
90
+ {
91
+ "settings": {
92
+ "n_prev": 5,
93
+ "smoothing": "wb",
94
+ "discount": 0.75,
95
+ "phrase_weight": 5,
96
+ "prompt_weight": 1,
97
+ "reply_weight": 1,
98
+ "max_vocab": 8000,
99
+ "min_word_count": 3,
100
+ "min_count": 2,
101
+ "param_target": 400000000
102
+ },
103
+ "vocab": 6550,
104
+ "min_context_count": 2,
105
+ "contexts_seen": 1005811,
106
+ "contexts_kept": 143202,
107
+ "export_mb": 5.4,
108
+ "best": {
109
+ "tau": 0.84,
110
+ "min_support": 0,
111
+ "precision": 0.9019,
112
+ "coverage": 0.3499,
113
+ "keystrokes_saved": 0.2084,
114
+ "shown": 14512
115
+ },
116
+ "seconds": 16.2
117
+ },
118
+ {
119
+ "settings": {
120
+ "n_prev": 7,
121
+ "smoothing": "wb",
122
+ "discount": 0.75,
123
+ "phrase_weight": 5,
124
+ "prompt_weight": 1,
125
+ "reply_weight": 1,
126
+ "max_vocab": 8000,
127
+ "min_word_count": 3,
128
+ "min_count": 2,
129
+ "param_target": 400000000
130
+ },
131
+ "vocab": 6550,
132
+ "min_context_count": 2,
133
+ "contexts_seen": 1514926,
134
+ "contexts_kept": 173445,
135
+ "export_mb": 6.6,
136
+ "best": {
137
+ "tau": 0.84,
138
+ "min_support": 0,
139
+ "precision": 0.9012,
140
+ "coverage": 0.3502,
141
+ "keystrokes_saved": 0.2079,
142
+ "shown": 14524
143
+ },
144
+ "seconds": 18.4
145
+ },
146
+ {
147
+ "settings": {
148
+ "n_prev": 9,
149
+ "smoothing": "wb",
150
+ "discount": 0.75,
151
+ "phrase_weight": 5,
152
+ "prompt_weight": 1,
153
+ "reply_weight": 1,
154
+ "max_vocab": 8000,
155
+ "min_word_count": 3,
156
+ "min_count": 2,
157
+ "param_target": 400000000
158
+ },
159
+ "vocab": 6550,
160
+ "min_context_count": 2,
161
+ "contexts_seen": 2027338,
162
+ "contexts_kept": 201278,
163
+ "export_mb": 7.7,
164
+ "best": {
165
+ "tau": 0.84,
166
+ "min_support": 0,
167
+ "precision": 0.9012,
168
+ "coverage": 0.3502,
169
+ "keystrokes_saved": 0.2079,
170
+ "shown": 14524
171
+ },
172
+ "seconds": 20.2
173
+ },
174
+ {
175
+ "settings": {
176
+ "n_prev": 3,
177
+ "smoothing": "kn",
178
+ "discount": 0.75,
179
+ "phrase_weight": 5,
180
+ "prompt_weight": 1,
181
+ "reply_weight": 1,
182
+ "max_vocab": 8000,
183
+ "min_word_count": 3,
184
+ "min_count": 2,
185
+ "param_target": 400000000
186
+ },
187
+ "vocab": 6550,
188
+ "min_context_count": 2,
189
+ "contexts_seen": 509522,
190
+ "contexts_kept": 105467,
191
+ "export_mb": 4.2,
192
+ "best": {
193
+ "tau": 0.73,
194
+ "min_support": 0,
195
+ "precision": 0.9033,
196
+ "coverage": 0.3819,
197
+ "keystrokes_saved": 0.2251,
198
+ "shown": 15837
199
+ },
200
+ "seconds": 14.1
201
+ },
202
+ {
203
+ "settings": {
204
+ "n_prev": 4,
205
+ "smoothing": "kn",
206
+ "discount": 0.75,
207
+ "phrase_weight": 5,
208
+ "prompt_weight": 1,
209
+ "reply_weight": 1,
210
+ "max_vocab": 8000,
211
+ "min_word_count": 3,
212
+ "min_count": 2,
213
+ "param_target": 400000000
214
+ },
215
+ "vocab": 6550,
216
+ "min_context_count": 2,
217
+ "contexts_seen": 754606,
218
+ "contexts_kept": 125943,
219
+ "export_mb": 4.9,
220
+ "best": {
221
+ "tau": 0.74,
222
+ "min_support": 0,
223
+ "precision": 0.9023,
224
+ "coverage": 0.3779,
225
+ "keystrokes_saved": 0.2224,
226
+ "shown": 15673
227
+ },
228
+ "seconds": 15.7
229
+ },
230
+ {
231
+ "settings": {
232
+ "n_prev": 5,
233
+ "smoothing": "kn",
234
+ "discount": 0.75,
235
+ "phrase_weight": 5,
236
+ "prompt_weight": 1,
237
+ "reply_weight": 1,
238
+ "max_vocab": 8000,
239
+ "min_word_count": 3,
240
+ "min_count": 2,
241
+ "param_target": 400000000
242
+ },
243
+ "vocab": 6550,
244
+ "min_context_count": 2,
245
+ "contexts_seen": 1005811,
246
+ "contexts_kept": 143202,
247
+ "export_mb": 5.4,
248
+ "best": {
249
+ "tau": 0.75,
250
+ "min_support": 0,
251
+ "precision": 0.9029,
252
+ "coverage": 0.3739,
253
+ "keystrokes_saved": 0.2196,
254
+ "shown": 15506
255
+ },
256
+ "seconds": 17.7
257
+ },
258
+ {
259
+ "settings": {
260
+ "n_prev": 7,
261
+ "smoothing": "kn",
262
+ "discount": 0.75,
263
+ "phrase_weight": 5,
264
+ "prompt_weight": 1,
265
+ "reply_weight": 1,
266
+ "max_vocab": 8000,
267
+ "min_word_count": 3,
268
+ "min_count": 2,
269
+ "param_target": 400000000
270
+ },
271
+ "vocab": 6550,
272
+ "min_context_count": 2,
273
+ "contexts_seen": 1514926,
274
+ "contexts_kept": 173445,
275
+ "export_mb": 6.6,
276
+ "best": {
277
+ "tau": 0.76,
278
+ "min_support": 0,
279
+ "precision": 0.9026,
280
+ "coverage": 0.3693,
281
+ "keystrokes_saved": 0.2162,
282
+ "shown": 15315
283
+ },
284
+ "seconds": 21.5
285
+ },
286
+ {
287
+ "settings": {
288
+ "n_prev": 9,
289
+ "smoothing": "kn",
290
+ "discount": 0.75,
291
+ "phrase_weight": 5,
292
+ "prompt_weight": 1,
293
+ "reply_weight": 1,
294
+ "max_vocab": 8000,
295
+ "min_word_count": 3,
296
+ "min_count": 2,
297
+ "param_target": 400000000
298
+ },
299
+ "vocab": 6550,
300
+ "min_context_count": 2,
301
+ "contexts_seen": 2027338,
302
+ "contexts_kept": 201278,
303
+ "export_mb": 7.7,
304
+ "best": {
305
+ "tau": 0.76,
306
+ "min_support": 0,
307
+ "precision": 0.9017,
308
+ "coverage": 0.3693,
309
+ "keystrokes_saved": 0.216,
310
+ "shown": 15316
311
+ },
312
+ "seconds": 24.4
313
+ },
314
+ {
315
+ "settings": {
316
+ "n_prev": 3,
317
+ "smoothing": "kn",
318
+ "discount": 0.75,
319
+ "phrase_weight": 1.0,
320
+ "prompt_weight": 1,
321
+ "reply_weight": 0.0,
322
+ "max_vocab": 8000,
323
+ "min_word_count": 3,
324
+ "min_count": 2,
325
+ "param_target": 400000000
326
+ },
327
+ "vocab": 2830,
328
+ "min_context_count": 2,
329
+ "contexts_seen": 133364,
330
+ "contexts_kept": 33000,
331
+ "export_mb": 1.3,
332
+ "best": {
333
+ "tau": 0.7,
334
+ "min_support": 0,
335
+ "precision": 0.901,
336
+ "coverage": 0.3569,
337
+ "keystrokes_saved": 0.2089,
338
+ "shown": 14803
339
+ },
340
+ "seconds": 8.8
341
+ },
342
+ {
343
+ "settings": {
344
+ "n_prev": 3,
345
+ "smoothing": "kn",
346
+ "discount": 0.75,
347
+ "phrase_weight": 1.0,
348
+ "prompt_weight": 1,
349
+ "reply_weight": 0.3,
350
+ "max_vocab": 8000,
351
+ "min_word_count": 3,
352
+ "min_count": 2,
353
+ "param_target": 400000000
354
+ },
355
+ "vocab": 3841,
356
+ "min_context_count": 2,
357
+ "contexts_seen": 454608,
358
+ "contexts_kept": 40744,
359
+ "export_mb": 2.6,
360
+ "best": {
361
+ "tau": 0.69,
362
+ "min_support": 0,
363
+ "precision": 0.9026,
364
+ "coverage": 0.3784,
365
+ "keystrokes_saved": 0.2238,
366
+ "shown": 15694
367
+ },
368
+ "seconds": 12.2
369
+ },
370
+ {
371
+ "settings": {
372
+ "n_prev": 3,
373
+ "smoothing": "kn",
374
+ "discount": 0.75,
375
+ "phrase_weight": 1.0,
376
+ "prompt_weight": 1,
377
+ "reply_weight": 1.0,
378
+ "max_vocab": 8000,
379
+ "min_word_count": 3,
380
+ "min_count": 2,
381
+ "param_target": 400000000
382
+ },
383
+ "vocab": 6550,
384
+ "min_context_count": 2,
385
+ "contexts_seen": 509522,
386
+ "contexts_kept": 105467,
387
+ "export_mb": 4.2,
388
+ "best": {
389
+ "tau": 0.72,
390
+ "min_support": 0,
391
+ "precision": 0.9029,
392
+ "coverage": 0.3866,
393
+ "keystrokes_saved": 0.2296,
394
+ "shown": 16034
395
+ },
396
+ "seconds": 14.7
397
+ },
398
+ {
399
+ "settings": {
400
+ "n_prev": 3,
401
+ "smoothing": "kn",
402
+ "discount": 0.75,
403
+ "phrase_weight": 5.0,
404
+ "prompt_weight": 1,
405
+ "reply_weight": 0.0,
406
+ "max_vocab": 8000,
407
+ "min_word_count": 3,
408
+ "min_count": 2,
409
+ "param_target": 400000000
410
+ },
411
+ "vocab": 2830,
412
+ "min_context_count": 2,
413
+ "contexts_seen": 133364,
414
+ "contexts_kept": 33000,
415
+ "export_mb": 1.3,
416
+ "best": {
417
+ "tau": 0.72,
418
+ "min_support": 0,
419
+ "precision": 0.9034,
420
+ "coverage": 0.3489,
421
+ "keystrokes_saved": 0.2007,
422
+ "shown": 14472
423
+ },
424
+ "seconds": 8.4
425
+ },
426
+ {
427
+ "settings": {
428
+ "n_prev": 3,
429
+ "smoothing": "kn",
430
+ "discount": 0.75,
431
+ "phrase_weight": 5.0,
432
+ "prompt_weight": 1,
433
+ "reply_weight": 0.3,
434
+ "max_vocab": 8000,
435
+ "min_word_count": 3,
436
+ "min_count": 2,
437
+ "param_target": 400000000
438
+ },
439
+ "vocab": 3841,
440
+ "min_context_count": 2,
441
+ "contexts_seen": 454608,
442
+ "contexts_kept": 40744,
443
+ "export_mb": 2.6,
444
+ "best": {
445
+ "tau": 0.7,
446
+ "min_support": 0,
447
+ "precision": 0.9006,
448
+ "coverage": 0.3758,
449
+ "keystrokes_saved": 0.2178,
450
+ "shown": 15588
451
+ },
452
+ "seconds": 11.9
453
+ },
454
+ {
455
+ "settings": {
456
+ "n_prev": 3,
457
+ "smoothing": "kn",
458
+ "discount": 0.75,
459
+ "phrase_weight": 1.0,
460
+ "prompt_weight": 1,
461
+ "reply_weight": 1.0,
462
+ "max_vocab": 3000,
463
+ "min_word_count": 3,
464
+ "min_count": 2,
465
+ "param_target": 400000000
466
+ },
467
+ "vocab": 3000,
468
+ "min_context_count": 2,
469
+ "contexts_seen": 425514,
470
+ "contexts_kept": 96265,
471
+ "export_mb": 4.0,
472
+ "best": {
473
+ "tau": 0.7,
474
+ "min_support": 2,
475
+ "precision": 0.9,
476
+ "coverage": 0.3601,
477
+ "keystrokes_saved": 0.2123,
478
+ "shown": 14933
479
+ },
480
+ "seconds": 11.2
481
+ },
482
+ {
483
+ "settings": {
484
+ "n_prev": 3,
485
+ "smoothing": "kn",
486
+ "discount": 0.75,
487
+ "phrase_weight": 1.0,
488
+ "prompt_weight": 1,
489
+ "reply_weight": 1.0,
490
+ "max_vocab": 3000,
491
+ "min_word_count": 3,
492
+ "min_count": 3,
493
+ "param_target": 400000000
494
+ },
495
+ "vocab": 3000,
496
+ "min_context_count": 3,
497
+ "contexts_seen": 425514,
498
+ "contexts_kept": 54867,
499
+ "export_mb": 3.0,
500
+ "best": {
501
+ "tau": 0.69,
502
+ "min_support": 0,
503
+ "precision": 0.9016,
504
+ "coverage": 0.358,
505
+ "keystrokes_saved": 0.2091,
506
+ "shown": 14849
507
+ },
508
+ "seconds": 11.1
509
+ },
510
+ {
511
+ "settings": {
512
+ "n_prev": 3,
513
+ "smoothing": "kn",
514
+ "discount": 0.75,
515
+ "phrase_weight": 1.0,
516
+ "prompt_weight": 1,
517
+ "reply_weight": 1.0,
518
+ "max_vocab": 8000,
519
+ "min_word_count": 3,
520
+ "min_count": 3,
521
+ "param_target": 400000000
522
+ },
523
+ "vocab": 6550,
524
+ "min_context_count": 3,
525
+ "contexts_seen": 509522,
526
+ "contexts_kept": 58669,
527
+ "export_mb": 3.1,
528
+ "best": {
529
+ "tau": 0.7,
530
+ "min_support": 0,
531
+ "precision": 0.9007,
532
+ "coverage": 0.3888,
533
+ "keystrokes_saved": 0.2294,
534
+ "shown": 16126
535
+ },
536
+ "seconds": 13.5
537
+ },
538
+ {
539
+ "settings": {
540
+ "n_prev": 3,
541
+ "smoothing": "kn",
542
+ "discount": 0.75,
543
+ "phrase_weight": 1.0,
544
+ "prompt_weight": 1,
545
+ "reply_weight": 1.0,
546
+ "max_vocab": 16000,
547
+ "min_word_count": 3,
548
+ "min_count": 2,
549
+ "param_target": 400000000
550
+ },
551
+ "vocab": 6550,
552
+ "min_context_count": 2,
553
+ "contexts_seen": 509522,
554
+ "contexts_kept": 105467,
555
+ "export_mb": 4.2,
556
+ "best": {
557
+ "tau": 0.72,
558
+ "min_support": 0,
559
+ "precision": 0.9029,
560
+ "coverage": 0.3866,
561
+ "keystrokes_saved": 0.2296,
562
+ "shown": 16034
563
+ },
564
+ "seconds": 13.2
565
+ },
566
+ {
567
+ "settings": {
568
+ "n_prev": 3,
569
+ "smoothing": "kn",
570
+ "discount": 0.75,
571
+ "phrase_weight": 1.0,
572
+ "prompt_weight": 1,
573
+ "reply_weight": 1.0,
574
+ "max_vocab": 16000,
575
+ "min_word_count": 3,
576
+ "min_count": 3,
577
+ "param_target": 400000000
578
+ },
579
+ "vocab": 6550,
580
+ "min_context_count": 3,
581
+ "contexts_seen": 509522,
582
+ "contexts_kept": 58669,
583
+ "export_mb": 3.1,
584
+ "best": {
585
+ "tau": 0.7,
586
+ "min_support": 0,
587
+ "precision": 0.9007,
588
+ "coverage": 0.3888,
589
+ "keystrokes_saved": 0.2294,
590
+ "shown": 16126
591
+ },
592
+ "seconds": 12.9
593
+ },
594
+ {
595
+ "settings": {
596
+ "n_prev": 4,
597
+ "smoothing": "kn",
598
+ "discount": 0.75,
599
+ "phrase_weight": 1.0,
600
+ "prompt_weight": 1,
601
+ "reply_weight": 1.0,
602
+ "max_vocab": 8000,
603
+ "min_word_count": 3,
604
+ "min_count": 2,
605
+ "param_target": 400000000
606
+ },
607
+ "vocab": 6550,
608
+ "min_context_count": 2,
609
+ "contexts_seen": 754606,
610
+ "contexts_kept": 125943,
611
+ "export_mb": 4.9,
612
+ "best": {
613
+ "tau": 0.73,
614
+ "min_support": 0,
615
+ "precision": 0.9025,
616
+ "coverage": 0.382,
617
+ "keystrokes_saved": 0.2279,
618
+ "shown": 15841
619
+ },
620
+ "seconds": 14.6
621
+ },
622
+ {
623
+ "settings": {
624
+ "n_prev": 5,
625
+ "smoothing": "kn",
626
+ "discount": 0.75,
627
+ "phrase_weight": 1.0,
628
+ "prompt_weight": 1,
629
+ "reply_weight": 1.0,
630
+ "max_vocab": 8000,
631
+ "min_word_count": 3,
632
+ "min_count": 2,
633
+ "param_target": 400000000
634
+ },
635
+ "vocab": 6550,
636
+ "min_context_count": 2,
637
+ "contexts_seen": 1005811,
638
+ "contexts_kept": 143202,
639
+ "export_mb": 5.4,
640
+ "best": {
641
+ "tau": 0.73,
642
+ "min_support": 2,
643
+ "precision": 0.9003,
644
+ "coverage": 0.3816,
645
+ "keystrokes_saved": 0.2273,
646
+ "shown": 15826
647
+ },
648
+ "seconds": 16.2
649
+ },
650
+ {
651
+ "settings": {
652
+ "n_prev": 7,
653
+ "smoothing": "kn",
654
+ "discount": 0.75,
655
+ "phrase_weight": 1.0,
656
+ "prompt_weight": 1,
657
+ "reply_weight": 1.0,
658
+ "max_vocab": 8000,
659
+ "min_word_count": 3,
660
+ "min_count": 2,
661
+ "param_target": 400000000
662
+ },
663
+ "vocab": 6550,
664
+ "min_context_count": 2,
665
+ "contexts_seen": 1514926,
666
+ "contexts_kept": 173445,
667
+ "export_mb": 6.6,
668
+ "best": {
669
+ "tau": 0.74,
670
+ "min_support": 0,
671
+ "precision": 0.9001,
672
+ "coverage": 0.3788,
673
+ "keystrokes_saved": 0.2244,
674
+ "shown": 15710
675
+ },
676
+ "seconds": 19.1
677
+ },
678
+ {
679
+ "settings": {
680
+ "n_prev": 9,
681
+ "smoothing": "kn",
682
+ "discount": 0.75,
683
+ "phrase_weight": 1.0,
684
+ "prompt_weight": 1,
685
+ "reply_weight": 1.0,
686
+ "max_vocab": 8000,
687
+ "min_word_count": 3,
688
+ "min_count": 2,
689
+ "param_target": 400000000
690
+ },
691
+ "vocab": 6550,
692
+ "min_context_count": 2,
693
+ "contexts_seen": 2027338,
694
+ "contexts_kept": 201278,
695
+ "export_mb": 7.7,
696
+ "best": {
697
+ "tau": 0.74,
698
+ "min_support": 2,
699
+ "precision": 0.9008,
700
+ "coverage": 0.378,
701
+ "keystrokes_saved": 0.2244,
702
+ "shown": 15678
703
+ },
704
+ "seconds": 22.9
705
+ }
706
+ ],
707
+ "best_grid": [
708
+ {
709
+ "tau": 0.5,
710
+ "min_support": 0,
711
+ "precision": 0.7865,
712
+ "coverage": 0.5115,
713
+ "keystrokes_saved": 0.2789,
714
+ "shown": 21215
715
+ },
716
+ {
717
+ "tau": 0.5,
718
+ "min_support": 2,
719
+ "precision": 0.7871,
720
+ "coverage": 0.5107,
721
+ "keystrokes_saved": 0.2786,
722
+ "shown": 21180
723
+ },
724
+ {
725
+ "tau": 0.5,
726
+ "min_support": 5,
727
+ "precision": 0.8083,
728
+ "coverage": 0.3863,
729
+ "keystrokes_saved": 0.2119,
730
+ "shown": 16021
731
+ },
732
+ {
733
+ "tau": 0.5,
734
+ "min_support": 10,
735
+ "precision": 0.8177,
736
+ "coverage": 0.3212,
737
+ "keystrokes_saved": 0.1777,
738
+ "shown": 13322
739
+ },
740
+ {
741
+ "tau": 0.51,
742
+ "min_support": 0,
743
+ "precision": 0.7919,
744
+ "coverage": 0.5048,
745
+ "keystrokes_saved": 0.2761,
746
+ "shown": 20937
747
+ },
748
+ {
749
+ "tau": 0.51,
750
+ "min_support": 2,
751
+ "precision": 0.7925,
752
+ "coverage": 0.504,
753
+ "keystrokes_saved": 0.2759,
754
+ "shown": 20902
755
+ },
756
+ {
757
+ "tau": 0.51,
758
+ "min_support": 5,
759
+ "precision": 0.8132,
760
+ "coverage": 0.3811,
761
+ "keystrokes_saved": 0.2099,
762
+ "shown": 15805
763
+ },
764
+ {
765
+ "tau": 0.51,
766
+ "min_support": 10,
767
+ "precision": 0.8217,
768
+ "coverage": 0.3173,
769
+ "keystrokes_saved": 0.1761,
770
+ "shown": 13161
771
+ },
772
+ {
773
+ "tau": 0.52,
774
+ "min_support": 0,
775
+ "precision": 0.797,
776
+ "coverage": 0.4991,
777
+ "keystrokes_saved": 0.2743,
778
+ "shown": 20699
779
+ },
780
+ {
781
+ "tau": 0.52,
782
+ "min_support": 2,
783
+ "precision": 0.7976,
784
+ "coverage": 0.4983,
785
+ "keystrokes_saved": 0.2741,
786
+ "shown": 20666
787
+ },
788
+ {
789
+ "tau": 0.52,
790
+ "min_support": 5,
791
+ "precision": 0.818,
792
+ "coverage": 0.3768,
793
+ "keystrokes_saved": 0.2085,
794
+ "shown": 15628
795
+ },
796
+ {
797
+ "tau": 0.52,
798
+ "min_support": 10,
799
+ "precision": 0.8261,
800
+ "coverage": 0.3142,
801
+ "keystrokes_saved": 0.1752,
802
+ "shown": 13030
803
+ },
804
+ {
805
+ "tau": 0.53,
806
+ "min_support": 0,
807
+ "precision": 0.803,
808
+ "coverage": 0.4929,
809
+ "keystrokes_saved": 0.2724,
810
+ "shown": 20441
811
+ },
812
+ {
813
+ "tau": 0.53,
814
+ "min_support": 2,
815
+ "precision": 0.8037,
816
+ "coverage": 0.4921,
817
+ "keystrokes_saved": 0.2722,
818
+ "shown": 20408
819
+ },
820
+ {
821
+ "tau": 0.53,
822
+ "min_support": 5,
823
+ "precision": 0.8236,
824
+ "coverage": 0.372,
825
+ "keystrokes_saved": 0.2073,
826
+ "shown": 15430
827
+ },
828
+ {
829
+ "tau": 0.53,
830
+ "min_support": 10,
831
+ "precision": 0.8309,
832
+ "coverage": 0.3104,
833
+ "keystrokes_saved": 0.1742,
834
+ "shown": 12873
835
+ },
836
+ {
837
+ "tau": 0.54,
838
+ "min_support": 0,
839
+ "precision": 0.8066,
840
+ "coverage": 0.4881,
841
+ "keystrokes_saved": 0.2709,
842
+ "shown": 20242
843
+ },
844
+ {
845
+ "tau": 0.54,
846
+ "min_support": 2,
847
+ "precision": 0.8072,
848
+ "coverage": 0.4873,
849
+ "keystrokes_saved": 0.2707,
850
+ "shown": 20209
851
+ },
852
+ {
853
+ "tau": 0.54,
854
+ "min_support": 5,
855
+ "precision": 0.8263,
856
+ "coverage": 0.3687,
857
+ "keystrokes_saved": 0.2062,
858
+ "shown": 15290
859
+ },
860
+ {
861
+ "tau": 0.54,
862
+ "min_support": 10,
863
+ "precision": 0.8336,
864
+ "coverage": 0.3077,
865
+ "keystrokes_saved": 0.1733,
866
+ "shown": 12763
867
+ },
868
+ {
869
+ "tau": 0.55,
870
+ "min_support": 0,
871
+ "precision": 0.813,
872
+ "coverage": 0.4822,
873
+ "keystrokes_saved": 0.2698,
874
+ "shown": 20000
875
+ },
876
+ {
877
+ "tau": 0.55,
878
+ "min_support": 2,
879
+ "precision": 0.8137,
880
+ "coverage": 0.4815,
881
+ "keystrokes_saved": 0.2696,
882
+ "shown": 19968
883
+ },
884
+ {
885
+ "tau": 0.55,
886
+ "min_support": 5,
887
+ "precision": 0.8324,
888
+ "coverage": 0.3641,
889
+ "keystrokes_saved": 0.2051,
890
+ "shown": 15101
891
+ },
892
+ {
893
+ "tau": 0.55,
894
+ "min_support": 10,
895
+ "precision": 0.8393,
896
+ "coverage": 0.3039,
897
+ "keystrokes_saved": 0.1725,
898
+ "shown": 12605
899
+ },
900
+ {
901
+ "tau": 0.56,
902
+ "min_support": 0,
903
+ "precision": 0.8183,
904
+ "coverage": 0.4766,
905
+ "keystrokes_saved": 0.2652,
906
+ "shown": 19765
907
+ },
908
+ {
909
+ "tau": 0.56,
910
+ "min_support": 2,
911
+ "precision": 0.8189,
912
+ "coverage": 0.4758,
913
+ "keystrokes_saved": 0.265,
914
+ "shown": 19734
915
+ },
916
+ {
917
+ "tau": 0.56,
918
+ "min_support": 5,
919
+ "precision": 0.8372,
920
+ "coverage": 0.3597,
921
+ "keystrokes_saved": 0.2007,
922
+ "shown": 14918
923
+ },
924
+ {
925
+ "tau": 0.56,
926
+ "min_support": 10,
927
+ "precision": 0.8438,
928
+ "coverage": 0.3003,
929
+ "keystrokes_saved": 0.1683,
930
+ "shown": 12455
931
+ },
932
+ {
933
+ "tau": 0.57,
934
+ "min_support": 0,
935
+ "precision": 0.8259,
936
+ "coverage": 0.4716,
937
+ "keystrokes_saved": 0.2665,
938
+ "shown": 19559
939
+ },
940
+ {
941
+ "tau": 0.57,
942
+ "min_support": 2,
943
+ "precision": 0.8265,
944
+ "coverage": 0.4709,
945
+ "keystrokes_saved": 0.2663,
946
+ "shown": 19530
947
+ },
948
+ {
949
+ "tau": 0.57,
950
+ "min_support": 5,
951
+ "precision": 0.8444,
952
+ "coverage": 0.356,
953
+ "keystrokes_saved": 0.2018,
954
+ "shown": 14766
955
+ },
956
+ {
957
+ "tau": 0.57,
958
+ "min_support": 10,
959
+ "precision": 0.8518,
960
+ "coverage": 0.2973,
961
+ "keystrokes_saved": 0.1697,
962
+ "shown": 12331
963
+ },
964
+ {
965
+ "tau": 0.58,
966
+ "min_support": 0,
967
+ "precision": 0.8361,
968
+ "coverage": 0.4668,
969
+ "keystrokes_saved": 0.265,
970
+ "shown": 19359
971
+ },
972
+ {
973
+ "tau": 0.58,
974
+ "min_support": 2,
975
+ "precision": 0.8366,
976
+ "coverage": 0.4661,
977
+ "keystrokes_saved": 0.2648,
978
+ "shown": 19332
979
+ },
980
+ {
981
+ "tau": 0.58,
982
+ "min_support": 5,
983
+ "precision": 0.8549,
984
+ "coverage": 0.3527,
985
+ "keystrokes_saved": 0.2004,
986
+ "shown": 14629
987
+ },
988
+ {
989
+ "tau": 0.58,
990
+ "min_support": 10,
991
+ "precision": 0.863,
992
+ "coverage": 0.2947,
993
+ "keystrokes_saved": 0.1685,
994
+ "shown": 12222
995
+ },
996
+ {
997
+ "tau": 0.59,
998
+ "min_support": 0,
999
+ "precision": 0.8403,
1000
+ "coverage": 0.4613,
1001
+ "keystrokes_saved": 0.2622,
1002
+ "shown": 19132
1003
+ },
1004
+ {
1005
+ "tau": 0.59,
1006
+ "min_support": 2,
1007
+ "precision": 0.8407,
1008
+ "coverage": 0.4607,
1009
+ "keystrokes_saved": 0.262,
1010
+ "shown": 19106
1011
+ },
1012
+ {
1013
+ "tau": 0.59,
1014
+ "min_support": 5,
1015
+ "precision": 0.8582,
1016
+ "coverage": 0.3483,
1017
+ "keystrokes_saved": 0.198,
1018
+ "shown": 14445
1019
+ },
1020
+ {
1021
+ "tau": 0.59,
1022
+ "min_support": 10,
1023
+ "precision": 0.8661,
1024
+ "coverage": 0.2909,
1025
+ "keystrokes_saved": 0.1663,
1026
+ "shown": 12065
1027
+ },
1028
+ {
1029
+ "tau": 0.6,
1030
+ "min_support": 0,
1031
+ "precision": 0.844,
1032
+ "coverage": 0.4557,
1033
+ "keystrokes_saved": 0.2575,
1034
+ "shown": 18899
1035
+ },
1036
+ {
1037
+ "tau": 0.6,
1038
+ "min_support": 2,
1039
+ "precision": 0.8444,
1040
+ "coverage": 0.4551,
1041
+ "keystrokes_saved": 0.2572,
1042
+ "shown": 18873
1043
+ },
1044
+ {
1045
+ "tau": 0.6,
1046
+ "min_support": 5,
1047
+ "precision": 0.862,
1048
+ "coverage": 0.3438,
1049
+ "keystrokes_saved": 0.1937,
1050
+ "shown": 14259
1051
+ },
1052
+ {
1053
+ "tau": 0.6,
1054
+ "min_support": 10,
1055
+ "precision": 0.8701,
1056
+ "coverage": 0.2872,
1057
+ "keystrokes_saved": 0.1626,
1058
+ "shown": 11910
1059
+ },
1060
+ {
1061
+ "tau": 0.61,
1062
+ "min_support": 0,
1063
+ "precision": 0.8482,
1064
+ "coverage": 0.4501,
1065
+ "keystrokes_saved": 0.2555,
1066
+ "shown": 18667
1067
+ },
1068
+ {
1069
+ "tau": 0.61,
1070
+ "min_support": 2,
1071
+ "precision": 0.8485,
1072
+ "coverage": 0.4495,
1073
+ "keystrokes_saved": 0.2553,
1074
+ "shown": 18643
1075
+ },
1076
+ {
1077
+ "tau": 0.61,
1078
+ "min_support": 5,
1079
+ "precision": 0.8655,
1080
+ "coverage": 0.3396,
1081
+ "keystrokes_saved": 0.1921,
1082
+ "shown": 14085
1083
+ },
1084
+ {
1085
+ "tau": 0.61,
1086
+ "min_support": 10,
1087
+ "precision": 0.8729,
1088
+ "coverage": 0.2842,
1089
+ "keystrokes_saved": 0.1613,
1090
+ "shown": 11787
1091
+ },
1092
+ {
1093
+ "tau": 0.62,
1094
+ "min_support": 0,
1095
+ "precision": 0.8551,
1096
+ "coverage": 0.4405,
1097
+ "keystrokes_saved": 0.2542,
1098
+ "shown": 18271
1099
+ },
1100
+ {
1101
+ "tau": 0.62,
1102
+ "min_support": 2,
1103
+ "precision": 0.8554,
1104
+ "coverage": 0.44,
1105
+ "keystrokes_saved": 0.254,
1106
+ "shown": 18247
1107
+ },
1108
+ {
1109
+ "tau": 0.62,
1110
+ "min_support": 5,
1111
+ "precision": 0.8729,
1112
+ "coverage": 0.3316,
1113
+ "keystrokes_saved": 0.1915,
1114
+ "shown": 13753
1115
+ },
1116
+ {
1117
+ "tau": 0.62,
1118
+ "min_support": 10,
1119
+ "precision": 0.8805,
1120
+ "coverage": 0.2772,
1121
+ "keystrokes_saved": 0.1614,
1122
+ "shown": 11497
1123
+ },
1124
+ {
1125
+ "tau": 0.63,
1126
+ "min_support": 0,
1127
+ "precision": 0.8595,
1128
+ "coverage": 0.4349,
1129
+ "keystrokes_saved": 0.2518,
1130
+ "shown": 18035
1131
+ },
1132
+ {
1133
+ "tau": 0.63,
1134
+ "min_support": 2,
1135
+ "precision": 0.8598,
1136
+ "coverage": 0.4343,
1137
+ "keystrokes_saved": 0.2516,
1138
+ "shown": 18012
1139
+ },
1140
+ {
1141
+ "tau": 0.63,
1142
+ "min_support": 5,
1143
+ "precision": 0.8767,
1144
+ "coverage": 0.3276,
1145
+ "keystrokes_saved": 0.1898,
1146
+ "shown": 13587
1147
+ },
1148
+ {
1149
+ "tau": 0.63,
1150
+ "min_support": 10,
1151
+ "precision": 0.884,
1152
+ "coverage": 0.274,
1153
+ "keystrokes_saved": 0.1601,
1154
+ "shown": 11364
1155
+ },
1156
+ {
1157
+ "tau": 0.64,
1158
+ "min_support": 0,
1159
+ "precision": 0.8637,
1160
+ "coverage": 0.4308,
1161
+ "keystrokes_saved": 0.2502,
1162
+ "shown": 17865
1163
+ },
1164
+ {
1165
+ "tau": 0.64,
1166
+ "min_support": 2,
1167
+ "precision": 0.864,
1168
+ "coverage": 0.4302,
1169
+ "keystrokes_saved": 0.2501,
1170
+ "shown": 17843
1171
+ },
1172
+ {
1173
+ "tau": 0.64,
1174
+ "min_support": 5,
1175
+ "precision": 0.8798,
1176
+ "coverage": 0.3249,
1177
+ "keystrokes_saved": 0.1886,
1178
+ "shown": 13474
1179
+ },
1180
+ {
1181
+ "tau": 0.64,
1182
+ "min_support": 10,
1183
+ "precision": 0.8871,
1184
+ "coverage": 0.2715,
1185
+ "keystrokes_saved": 0.1592,
1186
+ "shown": 11260
1187
+ },
1188
+ {
1189
+ "tau": 0.65,
1190
+ "min_support": 0,
1191
+ "precision": 0.8697,
1192
+ "coverage": 0.4247,
1193
+ "keystrokes_saved": 0.2479,
1194
+ "shown": 17616
1195
+ },
1196
+ {
1197
+ "tau": 0.65,
1198
+ "min_support": 2,
1199
+ "precision": 0.87,
1200
+ "coverage": 0.4242,
1201
+ "keystrokes_saved": 0.2477,
1202
+ "shown": 17594
1203
+ },
1204
+ {
1205
+ "tau": 0.65,
1206
+ "min_support": 5,
1207
+ "precision": 0.8849,
1208
+ "coverage": 0.3206,
1209
+ "keystrokes_saved": 0.1867,
1210
+ "shown": 13296
1211
+ },
1212
+ {
1213
+ "tau": 0.65,
1214
+ "min_support": 10,
1215
+ "precision": 0.8917,
1216
+ "coverage": 0.268,
1217
+ "keystrokes_saved": 0.1576,
1218
+ "shown": 11113
1219
+ },
1220
+ {
1221
+ "tau": 0.66,
1222
+ "min_support": 0,
1223
+ "precision": 0.8748,
1224
+ "coverage": 0.4193,
1225
+ "keystrokes_saved": 0.2456,
1226
+ "shown": 17391
1227
+ },
1228
+ {
1229
+ "tau": 0.66,
1230
+ "min_support": 2,
1231
+ "precision": 0.875,
1232
+ "coverage": 0.4188,
1233
+ "keystrokes_saved": 0.2454,
1234
+ "shown": 17369
1235
+ },
1236
+ {
1237
+ "tau": 0.66,
1238
+ "min_support": 5,
1239
+ "precision": 0.8883,
1240
+ "coverage": 0.317,
1241
+ "keystrokes_saved": 0.1852,
1242
+ "shown": 13149
1243
+ },
1244
+ {
1245
+ "tau": 0.66,
1246
+ "min_support": 10,
1247
+ "precision": 0.8943,
1248
+ "coverage": 0.2651,
1249
+ "keystrokes_saved": 0.1563,
1250
+ "shown": 10994
1251
+ },
1252
+ {
1253
+ "tau": 0.67,
1254
+ "min_support": 0,
1255
+ "precision": 0.8797,
1256
+ "coverage": 0.4133,
1257
+ "keystrokes_saved": 0.2425,
1258
+ "shown": 17141
1259
+ },
1260
+ {
1261
+ "tau": 0.67,
1262
+ "min_support": 2,
1263
+ "precision": 0.8799,
1264
+ "coverage": 0.4128,
1265
+ "keystrokes_saved": 0.2424,
1266
+ "shown": 17122
1267
+ },
1268
+ {
1269
+ "tau": 0.67,
1270
+ "min_support": 5,
1271
+ "precision": 0.8929,
1272
+ "coverage": 0.3124,
1273
+ "keystrokes_saved": 0.183,
1274
+ "shown": 12958
1275
+ },
1276
+ {
1277
+ "tau": 0.67,
1278
+ "min_support": 10,
1279
+ "precision": 0.8989,
1280
+ "coverage": 0.2614,
1281
+ "keystrokes_saved": 0.1542,
1282
+ "shown": 10840
1283
+ },
1284
+ {
1285
+ "tau": 0.68,
1286
+ "min_support": 0,
1287
+ "precision": 0.8852,
1288
+ "coverage": 0.4085,
1289
+ "keystrokes_saved": 0.2409,
1290
+ "shown": 16943
1291
+ },
1292
+ {
1293
+ "tau": 0.68,
1294
+ "min_support": 2,
1295
+ "precision": 0.8854,
1296
+ "coverage": 0.4081,
1297
+ "keystrokes_saved": 0.2407,
1298
+ "shown": 16925
1299
+ },
1300
+ {
1301
+ "tau": 0.68,
1302
+ "min_support": 5,
1303
+ "precision": 0.8966,
1304
+ "coverage": 0.3091,
1305
+ "keystrokes_saved": 0.1819,
1306
+ "shown": 12820
1307
+ },
1308
+ {
1309
+ "tau": 0.68,
1310
+ "min_support": 10,
1311
+ "precision": 0.9024,
1312
+ "coverage": 0.2587,
1313
+ "keystrokes_saved": 0.1531,
1314
+ "shown": 10728
1315
+ },
1316
+ {
1317
+ "tau": 0.69,
1318
+ "min_support": 0,
1319
+ "precision": 0.8894,
1320
+ "coverage": 0.4034,
1321
+ "keystrokes_saved": 0.2382,
1322
+ "shown": 16731
1323
+ },
1324
+ {
1325
+ "tau": 0.69,
1326
+ "min_support": 2,
1327
+ "precision": 0.8895,
1328
+ "coverage": 0.403,
1329
+ "keystrokes_saved": 0.2381,
1330
+ "shown": 16713
1331
+ },
1332
+ {
1333
+ "tau": 0.69,
1334
+ "min_support": 5,
1335
+ "precision": 0.899,
1336
+ "coverage": 0.3057,
1337
+ "keystrokes_saved": 0.1798,
1338
+ "shown": 12680
1339
+ },
1340
+ {
1341
+ "tau": 0.69,
1342
+ "min_support": 10,
1343
+ "precision": 0.9044,
1344
+ "coverage": 0.2561,
1345
+ "keystrokes_saved": 0.1512,
1346
+ "shown": 10620
1347
+ },
1348
+ {
1349
+ "tau": 0.7,
1350
+ "min_support": 0,
1351
+ "precision": 0.8942,
1352
+ "coverage": 0.3982,
1353
+ "keystrokes_saved": 0.2359,
1354
+ "shown": 16516
1355
+ },
1356
+ {
1357
+ "tau": 0.7,
1358
+ "min_support": 2,
1359
+ "precision": 0.8944,
1360
+ "coverage": 0.3978,
1361
+ "keystrokes_saved": 0.2358,
1362
+ "shown": 16498
1363
+ },
1364
+ {
1365
+ "tau": 0.7,
1366
+ "min_support": 5,
1367
+ "precision": 0.904,
1368
+ "coverage": 0.3017,
1369
+ "keystrokes_saved": 0.1779,
1370
+ "shown": 12512
1371
+ },
1372
+ {
1373
+ "tau": 0.7,
1374
+ "min_support": 10,
1375
+ "precision": 0.9094,
1376
+ "coverage": 0.2529,
1377
+ "keystrokes_saved": 0.1496,
1378
+ "shown": 10490
1379
+ },
1380
+ {
1381
+ "tau": 0.71,
1382
+ "min_support": 0,
1383
+ "precision": 0.8976,
1384
+ "coverage": 0.3929,
1385
+ "keystrokes_saved": 0.233,
1386
+ "shown": 16295
1387
+ },
1388
+ {
1389
+ "tau": 0.71,
1390
+ "min_support": 2,
1391
+ "precision": 0.8978,
1392
+ "coverage": 0.3925,
1393
+ "keystrokes_saved": 0.2329,
1394
+ "shown": 16277
1395
+ },
1396
+ {
1397
+ "tau": 0.71,
1398
+ "min_support": 5,
1399
+ "precision": 0.9062,
1400
+ "coverage": 0.2981,
1401
+ "keystrokes_saved": 0.1758,
1402
+ "shown": 12365
1403
+ },
1404
+ {
1405
+ "tau": 0.71,
1406
+ "min_support": 10,
1407
+ "precision": 0.9116,
1408
+ "coverage": 0.2499,
1409
+ "keystrokes_saved": 0.1477,
1410
+ "shown": 10363
1411
+ },
1412
+ {
1413
+ "tau": 0.72,
1414
+ "min_support": 0,
1415
+ "precision": 0.9029,
1416
+ "coverage": 0.3866,
1417
+ "keystrokes_saved": 0.2296,
1418
+ "shown": 16034
1419
+ },
1420
+ {
1421
+ "tau": 0.72,
1422
+ "min_support": 2,
1423
+ "precision": 0.9031,
1424
+ "coverage": 0.3862,
1425
+ "keystrokes_saved": 0.2295,
1426
+ "shown": 16016
1427
+ },
1428
+ {
1429
+ "tau": 0.72,
1430
+ "min_support": 5,
1431
+ "precision": 0.9113,
1432
+ "coverage": 0.2934,
1433
+ "keystrokes_saved": 0.173,
1434
+ "shown": 12167
1435
+ },
1436
+ {
1437
+ "tau": 0.72,
1438
+ "min_support": 10,
1439
+ "precision": 0.9172,
1440
+ "coverage": 0.2457,
1441
+ "keystrokes_saved": 0.1451,
1442
+ "shown": 10191
1443
+ },
1444
+ {
1445
+ "tau": 0.73,
1446
+ "min_support": 0,
1447
+ "precision": 0.9074,
1448
+ "coverage": 0.381,
1449
+ "keystrokes_saved": 0.2279,
1450
+ "shown": 15800
1451
+ },
1452
+ {
1453
+ "tau": 0.73,
1454
+ "min_support": 2,
1455
+ "precision": 0.9076,
1456
+ "coverage": 0.3805,
1457
+ "keystrokes_saved": 0.2278,
1458
+ "shown": 15782
1459
+ },
1460
+ {
1461
+ "tau": 0.73,
1462
+ "min_support": 5,
1463
+ "precision": 0.9149,
1464
+ "coverage": 0.2894,
1465
+ "keystrokes_saved": 0.1718,
1466
+ "shown": 12004
1467
+ },
1468
+ {
1469
+ "tau": 0.73,
1470
+ "min_support": 10,
1471
+ "precision": 0.9209,
1472
+ "coverage": 0.2427,
1473
+ "keystrokes_saved": 0.1443,
1474
+ "shown": 10065
1475
+ },
1476
+ {
1477
+ "tau": 0.74,
1478
+ "min_support": 0,
1479
+ "precision": 0.9114,
1480
+ "coverage": 0.3765,
1481
+ "keystrokes_saved": 0.2254,
1482
+ "shown": 15615
1483
+ },
1484
+ {
1485
+ "tau": 0.74,
1486
+ "min_support": 2,
1487
+ "precision": 0.9116,
1488
+ "coverage": 0.3761,
1489
+ "keystrokes_saved": 0.2252,
1490
+ "shown": 15597
1491
+ },
1492
+ {
1493
+ "tau": 0.74,
1494
+ "min_support": 5,
1495
+ "precision": 0.9183,
1496
+ "coverage": 0.2863,
1497
+ "keystrokes_saved": 0.17,
1498
+ "shown": 11876
1499
+ },
1500
+ {
1501
+ "tau": 0.74,
1502
+ "min_support": 10,
1503
+ "precision": 0.9242,
1504
+ "coverage": 0.2402,
1505
+ "keystrokes_saved": 0.1428,
1506
+ "shown": 9964
1507
+ },
1508
+ {
1509
+ "tau": 0.75,
1510
+ "min_support": 0,
1511
+ "precision": 0.9141,
1512
+ "coverage": 0.3718,
1513
+ "keystrokes_saved": 0.2234,
1514
+ "shown": 15420
1515
+ },
1516
+ {
1517
+ "tau": 0.75,
1518
+ "min_support": 2,
1519
+ "precision": 0.9143,
1520
+ "coverage": 0.3714,
1521
+ "keystrokes_saved": 0.2233,
1522
+ "shown": 15403
1523
+ },
1524
+ {
1525
+ "tau": 0.75,
1526
+ "min_support": 5,
1527
+ "precision": 0.9204,
1528
+ "coverage": 0.2826,
1529
+ "keystrokes_saved": 0.1686,
1530
+ "shown": 11720
1531
+ },
1532
+ {
1533
+ "tau": 0.75,
1534
+ "min_support": 10,
1535
+ "precision": 0.9261,
1536
+ "coverage": 0.2373,
1537
+ "keystrokes_saved": 0.1418,
1538
+ "shown": 9843
1539
+ },
1540
+ {
1541
+ "tau": 0.76,
1542
+ "min_support": 0,
1543
+ "precision": 0.918,
1544
+ "coverage": 0.3657,
1545
+ "keystrokes_saved": 0.2199,
1546
+ "shown": 15166
1547
+ },
1548
+ {
1549
+ "tau": 0.76,
1550
+ "min_support": 2,
1551
+ "precision": 0.9182,
1552
+ "coverage": 0.3653,
1553
+ "keystrokes_saved": 0.2197,
1554
+ "shown": 15150
1555
+ },
1556
+ {
1557
+ "tau": 0.76,
1558
+ "min_support": 5,
1559
+ "precision": 0.9246,
1560
+ "coverage": 0.2782,
1561
+ "keystrokes_saved": 0.1665,
1562
+ "shown": 11538
1563
+ },
1564
+ {
1565
+ "tau": 0.76,
1566
+ "min_support": 10,
1567
+ "precision": 0.9299,
1568
+ "coverage": 0.2337,
1569
+ "keystrokes_saved": 0.1403,
1570
+ "shown": 9694
1571
+ },
1572
+ {
1573
+ "tau": 0.77,
1574
+ "min_support": 0,
1575
+ "precision": 0.9224,
1576
+ "coverage": 0.3601,
1577
+ "keystrokes_saved": 0.2169,
1578
+ "shown": 14934
1579
+ },
1580
+ {
1581
+ "tau": 0.77,
1582
+ "min_support": 2,
1583
+ "precision": 0.9226,
1584
+ "coverage": 0.3597,
1585
+ "keystrokes_saved": 0.2168,
1586
+ "shown": 14918
1587
+ },
1588
+ {
1589
+ "tau": 0.77,
1590
+ "min_support": 5,
1591
+ "precision": 0.928,
1592
+ "coverage": 0.2741,
1593
+ "keystrokes_saved": 0.1643,
1594
+ "shown": 11369
1595
+ },
1596
+ {
1597
+ "tau": 0.77,
1598
+ "min_support": 10,
1599
+ "precision": 0.9325,
1600
+ "coverage": 0.2304,
1601
+ "keystrokes_saved": 0.1385,
1602
+ "shown": 9557
1603
+ },
1604
+ {
1605
+ "tau": 0.78,
1606
+ "min_support": 0,
1607
+ "precision": 0.9258,
1608
+ "coverage": 0.3547,
1609
+ "keystrokes_saved": 0.2143,
1610
+ "shown": 14710
1611
+ },
1612
+ {
1613
+ "tau": 0.78,
1614
+ "min_support": 2,
1615
+ "precision": 0.926,
1616
+ "coverage": 0.3543,
1617
+ "keystrokes_saved": 0.2142,
1618
+ "shown": 14694
1619
+ },
1620
+ {
1621
+ "tau": 0.78,
1622
+ "min_support": 5,
1623
+ "precision": 0.9311,
1624
+ "coverage": 0.2708,
1625
+ "keystrokes_saved": 0.1626,
1626
+ "shown": 11230
1627
+ },
1628
+ {
1629
+ "tau": 0.78,
1630
+ "min_support": 10,
1631
+ "precision": 0.9351,
1632
+ "coverage": 0.2281,
1633
+ "keystrokes_saved": 0.1373,
1634
+ "shown": 9461
1635
+ },
1636
+ {
1637
+ "tau": 0.79,
1638
+ "min_support": 0,
1639
+ "precision": 0.9307,
1640
+ "coverage": 0.3489,
1641
+ "keystrokes_saved": 0.2115,
1642
+ "shown": 14469
1643
+ },
1644
+ {
1645
+ "tau": 0.79,
1646
+ "min_support": 2,
1647
+ "precision": 0.931,
1648
+ "coverage": 0.3485,
1649
+ "keystrokes_saved": 0.2114,
1650
+ "shown": 14454
1651
+ },
1652
+ {
1653
+ "tau": 0.79,
1654
+ "min_support": 5,
1655
+ "precision": 0.9356,
1656
+ "coverage": 0.2667,
1657
+ "keystrokes_saved": 0.1606,
1658
+ "shown": 11062
1659
+ },
1660
+ {
1661
+ "tau": 0.79,
1662
+ "min_support": 10,
1663
+ "precision": 0.9391,
1664
+ "coverage": 0.2247,
1665
+ "keystrokes_saved": 0.1355,
1666
+ "shown": 9320
1667
+ },
1668
+ {
1669
+ "tau": 0.8,
1670
+ "min_support": 0,
1671
+ "precision": 0.9351,
1672
+ "coverage": 0.3412,
1673
+ "keystrokes_saved": 0.2066,
1674
+ "shown": 14153
1675
+ },
1676
+ {
1677
+ "tau": 0.8,
1678
+ "min_support": 2,
1679
+ "precision": 0.9352,
1680
+ "coverage": 0.3409,
1681
+ "keystrokes_saved": 0.2065,
1682
+ "shown": 14139
1683
+ },
1684
+ {
1685
+ "tau": 0.8,
1686
+ "min_support": 5,
1687
+ "precision": 0.9396,
1688
+ "coverage": 0.2606,
1689
+ "keystrokes_saved": 0.1567,
1690
+ "shown": 10810
1691
+ },
1692
+ {
1693
+ "tau": 0.8,
1694
+ "min_support": 10,
1695
+ "precision": 0.9424,
1696
+ "coverage": 0.2192,
1697
+ "keystrokes_saved": 0.1318,
1698
+ "shown": 9093
1699
+ },
1700
+ {
1701
+ "tau": 0.81,
1702
+ "min_support": 0,
1703
+ "precision": 0.9379,
1704
+ "coverage": 0.335,
1705
+ "keystrokes_saved": 0.2032,
1706
+ "shown": 13894
1707
+ },
1708
+ {
1709
+ "tau": 0.81,
1710
+ "min_support": 2,
1711
+ "precision": 0.938,
1712
+ "coverage": 0.3347,
1713
+ "keystrokes_saved": 0.2031,
1714
+ "shown": 13881
1715
+ },
1716
+ {
1717
+ "tau": 0.81,
1718
+ "min_support": 5,
1719
+ "precision": 0.9423,
1720
+ "coverage": 0.2559,
1721
+ "keystrokes_saved": 0.154,
1722
+ "shown": 10615
1723
+ },
1724
+ {
1725
+ "tau": 0.81,
1726
+ "min_support": 10,
1727
+ "precision": 0.945,
1728
+ "coverage": 0.2153,
1729
+ "keystrokes_saved": 0.1299,
1730
+ "shown": 8931
1731
+ },
1732
+ {
1733
+ "tau": 0.82,
1734
+ "min_support": 0,
1735
+ "precision": 0.9402,
1736
+ "coverage": 0.3296,
1737
+ "keystrokes_saved": 0.1999,
1738
+ "shown": 13671
1739
+ },
1740
+ {
1741
+ "tau": 0.82,
1742
+ "min_support": 2,
1743
+ "precision": 0.9403,
1744
+ "coverage": 0.3293,
1745
+ "keystrokes_saved": 0.1998,
1746
+ "shown": 13658
1747
+ },
1748
+ {
1749
+ "tau": 0.82,
1750
+ "min_support": 5,
1751
+ "precision": 0.9439,
1752
+ "coverage": 0.2523,
1753
+ "keystrokes_saved": 0.1517,
1754
+ "shown": 10465
1755
+ },
1756
+ {
1757
+ "tau": 0.82,
1758
+ "min_support": 10,
1759
+ "precision": 0.9465,
1760
+ "coverage": 0.2124,
1761
+ "keystrokes_saved": 0.1281,
1762
+ "shown": 8808
1763
+ },
1764
+ {
1765
+ "tau": 0.83,
1766
+ "min_support": 0,
1767
+ "precision": 0.9433,
1768
+ "coverage": 0.323,
1769
+ "keystrokes_saved": 0.1966,
1770
+ "shown": 13396
1771
+ },
1772
+ {
1773
+ "tau": 0.83,
1774
+ "min_support": 2,
1775
+ "precision": 0.9434,
1776
+ "coverage": 0.3227,
1777
+ "keystrokes_saved": 0.1965,
1778
+ "shown": 13384
1779
+ },
1780
+ {
1781
+ "tau": 0.83,
1782
+ "min_support": 5,
1783
+ "precision": 0.9473,
1784
+ "coverage": 0.2474,
1785
+ "keystrokes_saved": 0.1494,
1786
+ "shown": 10260
1787
+ },
1788
+ {
1789
+ "tau": 0.83,
1790
+ "min_support": 10,
1791
+ "precision": 0.9495,
1792
+ "coverage": 0.2085,
1793
+ "keystrokes_saved": 0.1263,
1794
+ "shown": 8646
1795
+ },
1796
+ {
1797
+ "tau": 0.84,
1798
+ "min_support": 0,
1799
+ "precision": 0.9472,
1800
+ "coverage": 0.3166,
1801
+ "keystrokes_saved": 0.1932,
1802
+ "shown": 13130
1803
+ },
1804
+ {
1805
+ "tau": 0.84,
1806
+ "min_support": 2,
1807
+ "precision": 0.9472,
1808
+ "coverage": 0.3163,
1809
+ "keystrokes_saved": 0.1931,
1810
+ "shown": 13120
1811
+ },
1812
+ {
1813
+ "tau": 0.84,
1814
+ "min_support": 5,
1815
+ "precision": 0.951,
1816
+ "coverage": 0.2423,
1817
+ "keystrokes_saved": 0.1469,
1818
+ "shown": 10051
1819
+ },
1820
+ {
1821
+ "tau": 0.84,
1822
+ "min_support": 10,
1823
+ "precision": 0.9519,
1824
+ "coverage": 0.2047,
1825
+ "keystrokes_saved": 0.1247,
1826
+ "shown": 8490
1827
+ },
1828
+ {
1829
+ "tau": 0.85,
1830
+ "min_support": 0,
1831
+ "precision": 0.9507,
1832
+ "coverage": 0.3089,
1833
+ "keystrokes_saved": 0.1886,
1834
+ "shown": 12810
1835
+ },
1836
+ {
1837
+ "tau": 0.85,
1838
+ "min_support": 2,
1839
+ "precision": 0.9507,
1840
+ "coverage": 0.3086,
1841
+ "keystrokes_saved": 0.1886,
1842
+ "shown": 12800
1843
+ },
1844
+ {
1845
+ "tau": 0.85,
1846
+ "min_support": 5,
1847
+ "precision": 0.9542,
1848
+ "coverage": 0.2364,
1849
+ "keystrokes_saved": 0.1436,
1850
+ "shown": 9806
1851
+ },
1852
+ {
1853
+ "tau": 0.85,
1854
+ "min_support": 10,
1855
+ "precision": 0.9551,
1856
+ "coverage": 0.1999,
1857
+ "keystrokes_saved": 0.1218,
1858
+ "shown": 8292
1859
+ },
1860
+ {
1861
+ "tau": 0.86,
1862
+ "min_support": 0,
1863
+ "precision": 0.9535,
1864
+ "coverage": 0.3026,
1865
+ "keystrokes_saved": 0.183,
1866
+ "shown": 12550
1867
+ },
1868
+ {
1869
+ "tau": 0.86,
1870
+ "min_support": 2,
1871
+ "precision": 0.9534,
1872
+ "coverage": 0.3024,
1873
+ "keystrokes_saved": 0.1829,
1874
+ "shown": 12540
1875
+ },
1876
+ {
1877
+ "tau": 0.86,
1878
+ "min_support": 5,
1879
+ "precision": 0.9569,
1880
+ "coverage": 0.232,
1881
+ "keystrokes_saved": 0.1391,
1882
+ "shown": 9623
1883
+ },
1884
+ {
1885
+ "tau": 0.86,
1886
+ "min_support": 10,
1887
+ "precision": 0.9574,
1888
+ "coverage": 0.1965,
1889
+ "keystrokes_saved": 0.1179,
1890
+ "shown": 8149
1891
+ },
1892
+ {
1893
+ "tau": 0.87,
1894
+ "min_support": 0,
1895
+ "precision": 0.9565,
1896
+ "coverage": 0.2951,
1897
+ "keystrokes_saved": 0.1793,
1898
+ "shown": 12241
1899
+ },
1900
+ {
1901
+ "tau": 0.87,
1902
+ "min_support": 2,
1903
+ "precision": 0.9565,
1904
+ "coverage": 0.2949,
1905
+ "keystrokes_saved": 0.1792,
1906
+ "shown": 12232
1907
+ },
1908
+ {
1909
+ "tau": 0.87,
1910
+ "min_support": 5,
1911
+ "precision": 0.9593,
1912
+ "coverage": 0.2264,
1913
+ "keystrokes_saved": 0.1366,
1914
+ "shown": 9390
1915
+ },
1916
+ {
1917
+ "tau": 0.87,
1918
+ "min_support": 10,
1919
+ "precision": 0.9594,
1920
+ "coverage": 0.1919,
1921
+ "keystrokes_saved": 0.1159,
1922
+ "shown": 7958
1923
+ },
1924
+ {
1925
+ "tau": 0.88,
1926
+ "min_support": 0,
1927
+ "precision": 0.9588,
1928
+ "coverage": 0.2883,
1929
+ "keystrokes_saved": 0.1753,
1930
+ "shown": 11958
1931
+ },
1932
+ {
1933
+ "tau": 0.88,
1934
+ "min_support": 2,
1935
+ "precision": 0.9587,
1936
+ "coverage": 0.2881,
1937
+ "keystrokes_saved": 0.1752,
1938
+ "shown": 11949
1939
+ },
1940
+ {
1941
+ "tau": 0.88,
1942
+ "min_support": 5,
1943
+ "precision": 0.9607,
1944
+ "coverage": 0.2215,
1945
+ "keystrokes_saved": 0.1336,
1946
+ "shown": 9187
1947
+ },
1948
+ {
1949
+ "tau": 0.88,
1950
+ "min_support": 10,
1951
+ "precision": 0.9609,
1952
+ "coverage": 0.188,
1953
+ "keystrokes_saved": 0.1135,
1954
+ "shown": 7797
1955
+ },
1956
+ {
1957
+ "tau": 0.89,
1958
+ "min_support": 0,
1959
+ "precision": 0.9614,
1960
+ "coverage": 0.2802,
1961
+ "keystrokes_saved": 0.1691,
1962
+ "shown": 11621
1963
+ },
1964
+ {
1965
+ "tau": 0.89,
1966
+ "min_support": 2,
1967
+ "precision": 0.9613,
1968
+ "coverage": 0.28,
1969
+ "keystrokes_saved": 0.1691,
1970
+ "shown": 11612
1971
+ },
1972
+ {
1973
+ "tau": 0.89,
1974
+ "min_support": 5,
1975
+ "precision": 0.9631,
1976
+ "coverage": 0.2154,
1977
+ "keystrokes_saved": 0.1285,
1978
+ "shown": 8932
1979
+ },
1980
+ {
1981
+ "tau": 0.89,
1982
+ "min_support": 10,
1983
+ "precision": 0.9631,
1984
+ "coverage": 0.1829,
1985
+ "keystrokes_saved": 0.1092,
1986
+ "shown": 7587
1987
+ },
1988
+ {
1989
+ "tau": 0.9,
1990
+ "min_support": 0,
1991
+ "precision": 0.9644,
1992
+ "coverage": 0.2692,
1993
+ "keystrokes_saved": 0.1642,
1994
+ "shown": 11165
1995
+ },
1996
+ {
1997
+ "tau": 0.9,
1998
+ "min_support": 2,
1999
+ "precision": 0.9643,
2000
+ "coverage": 0.269,
2001
+ "keystrokes_saved": 0.1642,
2002
+ "shown": 11156
2003
+ },
2004
+ {
2005
+ "tau": 0.9,
2006
+ "min_support": 5,
2007
+ "precision": 0.9666,
2008
+ "coverage": 0.2069,
2009
+ "keystrokes_saved": 0.1256,
2010
+ "shown": 8580
2011
+ },
2012
+ {
2013
+ "tau": 0.9,
2014
+ "min_support": 10,
2015
+ "precision": 0.9666,
2016
+ "coverage": 0.1757,
2017
+ "keystrokes_saved": 0.1072,
2018
+ "shown": 7286
2019
+ },
2020
+ {
2021
+ "tau": 0.91,
2022
+ "min_support": 0,
2023
+ "precision": 0.9673,
2024
+ "coverage": 0.2609,
2025
+ "keystrokes_saved": 0.1552,
2026
+ "shown": 10820
2027
+ },
2028
+ {
2029
+ "tau": 0.91,
2030
+ "min_support": 2,
2031
+ "precision": 0.9673,
2032
+ "coverage": 0.2607,
2033
+ "keystrokes_saved": 0.1551,
2034
+ "shown": 10811
2035
+ },
2036
+ {
2037
+ "tau": 0.91,
2038
+ "min_support": 5,
2039
+ "precision": 0.9696,
2040
+ "coverage": 0.2008,
2041
+ "keystrokes_saved": 0.1177,
2042
+ "shown": 8328
2043
+ },
2044
+ {
2045
+ "tau": 0.91,
2046
+ "min_support": 10,
2047
+ "precision": 0.9698,
2048
+ "coverage": 0.171,
2049
+ "keystrokes_saved": 0.1002,
2050
+ "shown": 7093
2051
+ },
2052
+ {
2053
+ "tau": 0.92,
2054
+ "min_support": 0,
2055
+ "precision": 0.9693,
2056
+ "coverage": 0.2474,
2057
+ "keystrokes_saved": 0.1485,
2058
+ "shown": 10260
2059
+ },
2060
+ {
2061
+ "tau": 0.92,
2062
+ "min_support": 2,
2063
+ "precision": 0.9693,
2064
+ "coverage": 0.2472,
2065
+ "keystrokes_saved": 0.1484,
2066
+ "shown": 10253
2067
+ },
2068
+ {
2069
+ "tau": 0.92,
2070
+ "min_support": 5,
2071
+ "precision": 0.9717,
2072
+ "coverage": 0.19,
2073
+ "keystrokes_saved": 0.1126,
2074
+ "shown": 7878
2075
+ },
2076
+ {
2077
+ "tau": 0.92,
2078
+ "min_support": 10,
2079
+ "precision": 0.9714,
2080
+ "coverage": 0.1611,
2081
+ "keystrokes_saved": 0.0954,
2082
+ "shown": 6680
2083
+ },
2084
+ {
2085
+ "tau": 0.93,
2086
+ "min_support": 0,
2087
+ "precision": 0.9722,
2088
+ "coverage": 0.2361,
2089
+ "keystrokes_saved": 0.1417,
2090
+ "shown": 9791
2091
+ },
2092
+ {
2093
+ "tau": 0.93,
2094
+ "min_support": 2,
2095
+ "precision": 0.9722,
2096
+ "coverage": 0.2359,
2097
+ "keystrokes_saved": 0.1417,
2098
+ "shown": 9784
2099
+ },
2100
+ {
2101
+ "tau": 0.93,
2102
+ "min_support": 5,
2103
+ "precision": 0.9746,
2104
+ "coverage": 0.1815,
2105
+ "keystrokes_saved": 0.1077,
2106
+ "shown": 7526
2107
+ },
2108
+ {
2109
+ "tau": 0.93,
2110
+ "min_support": 10,
2111
+ "precision": 0.9746,
2112
+ "coverage": 0.154,
2113
+ "keystrokes_saved": 0.0913,
2114
+ "shown": 6388
2115
+ },
2116
+ {
2117
+ "tau": 0.94,
2118
+ "min_support": 0,
2119
+ "precision": 0.9748,
2120
+ "coverage": 0.2226,
2121
+ "keystrokes_saved": 0.1317,
2122
+ "shown": 9234
2123
+ },
2124
+ {
2125
+ "tau": 0.94,
2126
+ "min_support": 2,
2127
+ "precision": 0.9748,
2128
+ "coverage": 0.2225,
2129
+ "keystrokes_saved": 0.1316,
2130
+ "shown": 9228
2131
+ },
2132
+ {
2133
+ "tau": 0.94,
2134
+ "min_support": 5,
2135
+ "precision": 0.9774,
2136
+ "coverage": 0.1719,
2137
+ "keystrokes_saved": 0.1007,
2138
+ "shown": 7130
2139
+ },
2140
+ {
2141
+ "tau": 0.94,
2142
+ "min_support": 10,
2143
+ "precision": 0.9775,
2144
+ "coverage": 0.1459,
2145
+ "keystrokes_saved": 0.0852,
2146
+ "shown": 6052
2147
+ },
2148
+ {
2149
+ "tau": 0.95,
2150
+ "min_support": 0,
2151
+ "precision": 0.9773,
2152
+ "coverage": 0.2049,
2153
+ "keystrokes_saved": 0.1176,
2154
+ "shown": 8497
2155
+ },
2156
+ {
2157
+ "tau": 0.95,
2158
+ "min_support": 2,
2159
+ "precision": 0.9773,
2160
+ "coverage": 0.2047,
2161
+ "keystrokes_saved": 0.1176,
2162
+ "shown": 8491
2163
+ },
2164
+ {
2165
+ "tau": 0.95,
2166
+ "min_support": 5,
2167
+ "precision": 0.9806,
2168
+ "coverage": 0.1582,
2169
+ "keystrokes_saved": 0.0897,
2170
+ "shown": 6560
2171
+ },
2172
+ {
2173
+ "tau": 0.95,
2174
+ "min_support": 10,
2175
+ "precision": 0.9806,
2176
+ "coverage": 0.134,
2177
+ "keystrokes_saved": 0.0756,
2178
+ "shown": 5559
2179
+ }
2180
+ ]
2181
+ }
export_web.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Write web/model.bin: the compiled model in sparse form for the browser.
2
+
3
+ The dense model.safetensors is ~93% zeros. Each MLP neuron of the compiled
4
+ transformer is fully described by its context tokens (which slots its gate
5
+ matches) and the logit changes its down-projection writes; the attention and
6
+ embedding weights are fixed by construction. This file stores exactly that, so
7
+ web/autocomplete.ts can evaluate only the neurons whose context matches: the
8
+ same computation as the transformer, without the zeros.
9
+
10
+ Layout (little-endian):
11
+ "TACB" | u32 version | u32 header bytes | header JSON | pad to 8
12
+ sections (each padded to 8), offsets relative to the first section:
13
+ base f32[V] unigram neuron (always on)
14
+ ctx{k} k=1..order u16/u32[n_k * k] context token ids, oldest first, sorted
15
+ support{k} f32[n_k] weighted count of the context
16
+ offsets{k} u32[n_k + 1] slice of this neuron's deltas
17
+ dids{k}, dvals{k} u16/u32, f32 next-token ids and logit changes
18
+
19
+ python export_web.py # uses the "web" settings and "policy" in compile_settings.json
20
+ """
21
+
22
+ import json
23
+ from dataclasses import asdict
24
+ from pathlib import Path
25
+
26
+ import numpy as np
27
+
28
+ import build_weights as bw
29
+ import suggest as sg
30
+
31
+ ROOT = Path(__file__).parent
32
+ OUT = ROOT / "web" / "model.bin"
33
+ MAGIC, VERSION = b"TACB", 1
34
+
35
+
36
+ def load_policy() -> sg.Policy:
37
+ return sg.Policy(**json.loads(bw.SETTINGS_PATH.read_text())["policy"])
38
+
39
+
40
+ def export(vocab: list[str], ngram: bw.NGram, oov: dict, policy: sg.Policy, path: Path) -> dict:
41
+ id_dtype = np.uint16 if len(vocab) < 65536 else np.uint32
42
+ sections, orders = [], []
43
+
44
+ def add(name: str, arr: np.ndarray) -> None:
45
+ sections.append((name, np.ascontiguousarray(arr).tobytes()))
46
+
47
+ add("base", ngram.base_logits.astype(np.float32))
48
+ for k in range(1, ngram.order + 1):
49
+ ctxs = sorted(ngram.tables[k])
50
+ offsets, dids, dvals = [0], [], []
51
+ for ctx in ctxs:
52
+ d = ngram.delta(ctx)
53
+ dids.extend(d.keys())
54
+ dvals.extend(d.values())
55
+ offsets.append(len(dids))
56
+ add(f"ctx{k}", np.array(ctxs, dtype=id_dtype).reshape(-1))
57
+ add(f"support{k}", np.array([ngram.support[c] for c in ctxs], dtype=np.float32))
58
+ add(f"offsets{k}", np.array(offsets, dtype=np.uint32))
59
+ add(f"dids{k}", np.array(dids, dtype=id_dtype))
60
+ add(f"dvals{k}", np.array(dvals, dtype=np.float32))
61
+ orders.append({"k": k, "contexts": len(ctxs), "pairs": len(dids)})
62
+
63
+ table, offset = [], 0
64
+ for name, data in sections:
65
+ table.append({"name": name, "offset": offset, "bytes": len(data)})
66
+ offset += -(-len(data) // 8) * 8
67
+ header = {
68
+ "n_prev": ngram.s.n_prev,
69
+ "vocab": vocab,
70
+ "eos_id": bw.EOS_ID,
71
+ "unk_id": bw.UNK_ID,
72
+ "id_bytes": np.dtype(id_dtype).itemsize,
73
+ "orders": orders,
74
+ "oov_prefix_rates": {p: round(r, 4) for p, r in oov.items()},
75
+ "policy": asdict(policy),
76
+ "stop_after": sorted(sg.STOP_AFTER),
77
+ "settings": asdict(ngram.s),
78
+ "sections": table,
79
+ }
80
+ head = json.dumps(header, separators=(",", ":")).encode()
81
+ path.parent.mkdir(exist_ok=True)
82
+ with open(path, "wb") as f:
83
+ f.write(MAGIC + np.array([VERSION, len(head)], dtype="<u4").tobytes() + head)
84
+ f.write(b"\0" * (-(12 + len(head)) % 8))
85
+ for _, data in sections:
86
+ f.write(data + b"\0" * (-len(data) % 8))
87
+ return {"bytes": path.stat().st_size, "contexts": sum(o["contexts"] for o in orders), "pairs": sum(o["pairs"] for o in orders)}
88
+
89
+
90
+ def build(settings: bw.Settings, sources: dict, policy: sg.Policy, path: Path = OUT) -> dict:
91
+ vocab, ngram, stats = bw.compile_tables(settings, sources)
92
+ oov = sg.oov_prefix_rates(settings, sources, vocab)
93
+ return {**stats, **export(vocab, ngram, oov, policy, path)}
94
+
95
+
96
+ def main() -> None:
97
+ info = build(bw.load_settings("web"), bw.load_sources(), load_policy())
98
+ print(json.dumps({**info, "mb": round(info["bytes"] / 1e6, 1), "out": str(OUT)}, indent=2))
99
+
100
+
101
+ if __name__ == "__main__":
102
+ main()
model.safetensors CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:93c23040ccf6393710b0ee1d2fff98d57dc4193a27c4911cac7374e2c28f50f6
3
  size 1521162624
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ae857db3e321c35782192c198a7778987964e9cc147d2c67428fea9bf5777100
3
  size 1521162624
model.ts CHANGED
@@ -4,6 +4,7 @@
4
 
5
  import { readFileSync } from "node:fs";
6
  import { join } from "node:path";
 
7
 
8
  export type Config = {
9
  vocab_size: number;
@@ -44,63 +45,6 @@ function loadSafetensors(path: string): Map<string, Float32Array> {
44
  return tensors;
45
  }
46
 
47
- // ---------- tokenizer (WordLevel + ByteLevel, as in tokenizer.json) ----------
48
-
49
- function bytesToUnicode(): Map<number, string> {
50
- const bs: number[] = [];
51
- for (let b = 33; b <= 126; b++) bs.push(b);
52
- for (let b = 161; b <= 172; b++) bs.push(b);
53
- for (let b = 174; b <= 255; b++) bs.push(b);
54
- const cs = [...bs];
55
- let n = 0;
56
- for (let b = 0; b < 256; b++) {
57
- if (!bs.includes(b)) {
58
- bs.push(b);
59
- cs.push(256 + n++);
60
- }
61
- }
62
- return new Map(bs.map((b, i) => [b, String.fromCodePoint(cs[i])]));
63
- }
64
-
65
- const GPT2_SPLIT = /'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+/gu;
66
-
67
- export class Tokenizer {
68
- vocab: Map<string, number>;
69
- inverse: string[];
70
- special = new Set<number>();
71
- unkId: number;
72
- bosId: number;
73
- private byteEnc = bytesToUnicode();
74
- private byteDec = new Map([...this.byteEnc].map(([b, c]) => [c, b]));
75
-
76
- constructor(path: string) {
77
- const json = JSON.parse(readFileSync(path, "utf8"));
78
- this.vocab = new Map(Object.entries<number>(json.model.vocab));
79
- this.inverse = [];
80
- for (const [tok, id] of this.vocab) this.inverse[id] = tok;
81
- for (const t of json.added_tokens ?? []) if (t.special) this.special.add(t.id);
82
- this.unkId = this.vocab.get(json.model.unk_token)!;
83
- const bos = json.post_processor?.single?.find((p: any) => p.SpecialToken)?.SpecialToken?.id;
84
- this.bosId = this.vocab.get(bos)!;
85
- }
86
-
87
- encode(text: string): number[] {
88
- let norm = text.normalize("NFKC").toLowerCase();
89
- if (!/^\s/.test(norm)) norm = " " + norm; // ByteLevel add_prefix_space
90
- const ids = [this.bosId];
91
- for (const piece of norm.match(GPT2_SPLIT) ?? []) {
92
- const mapped = [...new TextEncoder().encode(piece)].map((b) => this.byteEnc.get(b)!).join("");
93
- ids.push(this.vocab.get(mapped) ?? this.unkId);
94
- }
95
- return ids;
96
- }
97
-
98
- decode(ids: number[]): string {
99
- const chars = ids.filter((id) => !this.special.has(id)).map((id) => this.inverse[id]).join("");
100
- return new TextDecoder().decode(new Uint8Array([...chars].map((c) => this.byteDec.get(c) ?? 63)));
101
- }
102
- }
103
-
104
  // ---------- Llama forward pass ----------
105
 
106
  function matvec(w: Float32Array, x: Float64Array, rows: number, cols: number): Float64Array {
@@ -228,12 +172,12 @@ export type Completion = {
228
  };
229
 
230
  export class Autocompleter {
231
- tok: Tokenizer;
232
  model: Llama;
233
  maxNew: number;
234
 
235
  constructor(dir: string) {
236
- this.tok = new Tokenizer(join(dir, "tokenizer.json"));
237
  this.model = new Llama(dir);
238
  const gen = JSON.parse(readFileSync(join(dir, "generation_config.json"), "utf8"));
239
  this.maxNew = gen.max_new_tokens ?? 16;
 
4
 
5
  import { readFileSync } from "node:fs";
6
  import { join } from "node:path";
7
+ import { WordTokenizer } from "./tokenizer.ts";
8
 
9
  export type Config = {
10
  vocab_size: number;
 
45
  return tensors;
46
  }
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  // ---------- Llama forward pass ----------
49
 
50
  function matvec(w: Float32Array, x: Float64Array, rows: number, cols: number): Float64Array {
 
172
  };
173
 
174
  export class Autocompleter {
175
+ tok: WordTokenizer;
176
  model: Llama;
177
  maxNew: number;
178
 
179
  constructor(dir: string) {
180
+ this.tok = WordTokenizer.fromJson(JSON.parse(readFileSync(join(dir, "tokenizer.json"), "utf8")));
181
  this.model = new Llama(dir);
182
  const gen = JSON.parse(readFileSync(join(dir, "generation_config.json"), "utf8"));
183
  this.maxNew = gen.max_new_tokens ?? 16;
scripts/benchmark.py CHANGED
@@ -1,6 +1,6 @@
1
- """Measure compile cost, inference speed, exactness and held-out accuracy.
2
 
3
- Writes docs/benchmarks.json. Run from the repo root after build_weights.py:
4
  python scripts/benchmark.py
5
  """
6
 
@@ -11,17 +11,21 @@ import platform
11
  import random
12
  import resource
13
  import shutil
 
14
  import subprocess
15
  import sys
16
  import time
17
- import zlib
18
  from pathlib import Path
19
 
20
  import torch
21
 
22
  ROOT = Path(__file__).resolve().parent.parent
23
  sys.path.insert(0, str(ROOT))
 
24
  import build_weights as bw # noqa: E402
 
 
 
25
 
26
  PROMPTS = [
27
  "Once upon a", "I hope this email", "The quick brown", "Thank you for", "Looking forward to",
@@ -50,44 +54,53 @@ def hardware() -> dict:
50
  "python": platform.python_version(), "torch": torch.__version__}
51
 
52
 
53
- def compile_runs(n: int = 2) -> dict:
54
- """Full `python build_weights.py` runs: wall time, peak memory, determinism."""
55
  times, hashes = [], []
56
  for _ in range(n):
57
  t0 = time.perf_counter()
58
- subprocess.run([sys.executable, "build_weights.py"], cwd=ROOT, check=True, capture_output=True)
59
  times.append(time.perf_counter() - t0)
60
- hashes.append(sha256(ROOT / "model.safetensors"))
61
- peak = resource.getrusage(resource.RUSAGE_CHILDREN).ru_maxrss
62
- peak_gb = peak / 2**30 if sys.platform == "darwin" else peak / 2**20
63
  return {
64
- "compile_seconds": round(min(times), 1),
65
- "compile_runs": [round(t, 1) for t in times],
66
- "peak_memory_gb": round(peak_gb, 1),
67
  "byte_identical_rebuild": len(set(hashes)) == 1,
68
- "model_sha256": hashes[0],
69
- "file_gb": round((ROOT / "model.safetensors").stat().st_size / 1e9, 2),
70
  }
71
 
72
 
73
- def compile_stages() -> dict:
 
 
74
  t0 = time.perf_counter()
75
- phrases, data = bw.load_phrases(), bw.load_data()
76
  t1 = time.perf_counter()
77
- _, ngram, state, _, info = bw.build(phrases, data)
 
78
  t2 = time.perf_counter()
79
- nonzero = sum(int((t != 0).sum()) for t in state.values())
 
80
  return {
 
 
81
  "tokenize_seconds": round(t1 - t0, 2),
82
- "build_tensors_seconds": round(t2 - t1, 2),
83
- "source_lines": len(phrases) + len(data),
 
84
  "params": info["params"],
85
- "nonzero_params": nonzero,
86
- "contexts_seen": info["contexts_seen"],
87
- "neurons": info["neurons"],
88
  }
89
 
90
 
 
 
 
 
 
 
 
91
  def torch_inference() -> dict:
92
  from transformers import AutoModelForCausalLM, AutoTokenizer
93
 
@@ -95,7 +108,6 @@ def torch_inference() -> dict:
95
  tok = AutoTokenizer.from_pretrained(ROOT)
96
  model = AutoModelForCausalLM.from_pretrained(ROOT).eval()
97
  load = time.perf_counter() - t0
98
-
99
  with torch.no_grad():
100
  ids = tok("Write a python function to", return_tensors="pt")
101
  model(**ids) # warm-up
@@ -103,33 +115,27 @@ def torch_inference() -> dict:
103
  for _ in range(20):
104
  model(**ids)
105
  forward_ms = (time.perf_counter() - t0) / 20 * 1000
106
-
107
  new_tokens, t0 = 0, time.perf_counter()
108
  for p in PROMPTS:
109
  ids = tok(p, return_tensors="pt")
110
  out = model.generate(**ids, max_new_tokens=16)
111
  new_tokens += out.shape[1] - ids["input_ids"].shape[1]
112
  gen = time.perf_counter() - t0
113
- return {
114
- "load_seconds": round(load, 1),
115
- "next_word_ms": round(forward_ms, 1),
116
- "generate_tokens_per_second": round(new_tokens / gen, 1),
117
- "threads": torch.get_num_threads(),
118
- }
119
 
120
 
121
- def ts_inference() -> dict | None:
122
- bun = shutil.which("bun")
123
- if not bun:
124
  return None
125
- t0 = time.perf_counter()
126
- subprocess.run([bun, "autocomplete.ts", "--json"], cwd=ROOT, check=True, capture_output=True)
127
- load = time.perf_counter() - t0
128
- t0 = time.perf_counter()
129
- out = subprocess.run([bun, "autocomplete.ts", "--json", *PROMPTS], cwd=ROOT, check=True, capture_output=True, text=True)
130
- total = time.perf_counter() - t0 - load
131
- words = sum(len(bw.pieces(r["completion"])) + 1 for r in json.loads(out.stdout)) # +1: the final EOS step
132
- return {"runtime": "bun, plain TypeScript, no ML library", "load_seconds": round(load, 1), "seconds_per_token": round(total / words, 2)}
133
 
134
 
135
  def exactness() -> float:
@@ -146,12 +152,23 @@ def exactness() -> float:
146
  return worst
147
 
148
 
149
- def heldout(frac: float = 0.1) -> dict:
150
- """Compile without 10% of the data lines, then score next-word prediction on them."""
151
- phrases, data = bw.load_phrases(), bw.load_data()
152
- test = [t for t in data if zlib.crc32(" ".join(t).encode()) % 100 < frac * 100]
153
- train = [t for t in data if zlib.crc32(" ".join(t).encode()) % 100 >= frac * 100]
154
- vocab, ngram, _, _, _ = bw.build(phrases, train)
 
 
 
 
 
 
 
 
 
 
 
155
  index = {p: i for i, p in enumerate(vocab)}
156
  top1 = top3 = total = oov = 0
157
  for toks in test:
@@ -165,27 +182,26 @@ def heldout(frac: float = 0.1) -> dict:
165
  ranked = ngram.reference_logits(ids[: t + 1]).topk(3).indices.tolist()
166
  top1 += ranked[0] == target
167
  top3 += target in ranked
168
- return {
169
- "heldout_lines": len(test),
170
- "heldout_tokens": total,
171
- "next_token_top1": round(top1 / total, 3),
172
- "next_token_top3": round(top3 / total, 3),
173
- "oov_rate": round(oov / total, 3),
174
- }
175
 
176
 
177
  def main() -> None:
178
  results = {"hardware": hardware()}
179
- print("compiling (2 full runs)...", flush=True)
180
- results["compile"] = {**compile_runs(), **compile_stages()}
 
 
181
  print("inference...", flush=True)
182
  results["pytorch"] = torch_inference()
183
- results["typescript"] = ts_inference()
 
184
  results["max_logprob_error_vs_ngram"] = exactness()
185
- print("held-out accuracy...", flush=True)
186
- results["heldout"] = heldout()
187
- out = ROOT / "docs" / "benchmarks.json"
188
- out.write_text(json.dumps(results, indent=2) + "\n")
 
189
  print(json.dumps(results, indent=2))
190
 
191
 
 
1
+ """Measure compile cost, inference speed, exactness and held-out quality.
2
 
3
+ Writes docs/benchmarks.json. Run from the repo root after build_weights.py and export_web.py:
4
  python scripts/benchmark.py
5
  """
6
 
 
11
  import random
12
  import resource
13
  import shutil
14
+ import statistics
15
  import subprocess
16
  import sys
17
  import time
 
18
  from pathlib import Path
19
 
20
  import torch
21
 
22
  ROOT = Path(__file__).resolve().parent.parent
23
  sys.path.insert(0, str(ROOT))
24
+ sys.path.insert(0, str(ROOT / "scripts"))
25
  import build_weights as bw # noqa: E402
26
+ import export_web # noqa: E402
27
+ import suggest as sg # noqa: E402
28
+ import sweep # noqa: E402
29
 
30
  PROMPTS = [
31
  "Once upon a", "I hope this email", "The quick brown", "Thank you for", "Looking forward to",
 
54
  "python": platform.python_version(), "torch": torch.__version__}
55
 
56
 
57
+ def timed_runs(cmd: list[str], out: Path, n: int = 2) -> dict:
 
58
  times, hashes = [], []
59
  for _ in range(n):
60
  t0 = time.perf_counter()
61
+ subprocess.run(cmd, cwd=ROOT, check=True, capture_output=True)
62
  times.append(time.perf_counter() - t0)
63
+ hashes.append(sha256(out))
 
 
64
  return {
65
+ "seconds": round(min(times), 1),
66
+ "runs": [round(t, 1) for t in times],
 
67
  "byte_identical_rebuild": len(set(hashes)) == 1,
68
+ "sha256": hashes[0],
69
+ "mb": round(out.stat().st_size / 1e6, 2),
70
  }
71
 
72
 
73
+ def compile_dense() -> dict:
74
+ runs = timed_runs([sys.executable, "build_weights.py"], ROOT / "model.safetensors")
75
+ peak = resource.getrusage(resource.RUSAGE_CHILDREN).ru_maxrss
76
  t0 = time.perf_counter()
77
+ sources = bw.load_sources()
78
  t1 = time.perf_counter()
79
+ s = bw.load_settings("hf")
80
+ vocab, ngram, stats = bw.compile_tables(s, sources)
81
  t2 = time.perf_counter()
82
+ state, _, info = bw.build_dense(vocab, ngram, s)
83
+ t3 = time.perf_counter()
84
  return {
85
+ **runs,
86
+ "peak_memory_gb": round(peak / 2**30 if sys.platform == "darwin" else peak / 2**20, 1),
87
  "tokenize_seconds": round(t1 - t0, 2),
88
+ "count_seconds": round(t2 - t1, 2),
89
+ "build_tensors_seconds": round(t3 - t2, 2),
90
+ "source_lines": sum(len(v) for v in sources.values()),
91
  "params": info["params"],
92
+ "nonzero_params": sum(int((t != 0).sum()) for t in state.values()),
93
+ **stats,
 
94
  }
95
 
96
 
97
+ def compile_web() -> dict:
98
+ runs = timed_runs([sys.executable, "export_web.py"], ROOT / "web" / "model.bin")
99
+ header_len = int.from_bytes((ROOT / "web" / "model.bin").read_bytes()[8:12], "little")
100
+ header = json.loads((ROOT / "web" / "model.bin").read_bytes()[12 : 12 + header_len])
101
+ return {**runs, "contexts": sum(o["contexts"] for o in header["orders"]), "vocab": len(header["vocab"])}
102
+
103
+
104
  def torch_inference() -> dict:
105
  from transformers import AutoModelForCausalLM, AutoTokenizer
106
 
 
108
  tok = AutoTokenizer.from_pretrained(ROOT)
109
  model = AutoModelForCausalLM.from_pretrained(ROOT).eval()
110
  load = time.perf_counter() - t0
 
111
  with torch.no_grad():
112
  ids = tok("Write a python function to", return_tensors="pt")
113
  model(**ids) # warm-up
 
115
  for _ in range(20):
116
  model(**ids)
117
  forward_ms = (time.perf_counter() - t0) / 20 * 1000
 
118
  new_tokens, t0 = 0, time.perf_counter()
119
  for p in PROMPTS:
120
  ids = tok(p, return_tensors="pt")
121
  out = model.generate(**ids, max_new_tokens=16)
122
  new_tokens += out.shape[1] - ids["input_ids"].shape[1]
123
  gen = time.perf_counter() - t0
124
+ return {"load_seconds": round(load, 1), "next_word_ms": round(forward_ms, 1),
125
+ "generate_tokens_per_second": round(new_tokens / gen, 1), "threads": torch.get_num_threads()}
 
 
 
 
126
 
127
 
128
+ def web_latency(texts: list[str]) -> dict | None:
129
+ """Per-keystroke suggestion time of web/autocomplete.ts on held-out prefixes (Bun)."""
130
+ if not shutil.which("bun"):
131
  return None
132
+ rng = random.Random(0)
133
+ prefixes = [t[: rng.randint(1, len(t) - 1)] for t in rng.sample(texts, 500)]
134
+ out = json.loads(subprocess.run(["bun", "web/cli.ts", "--json", *prefixes], cwd=ROOT, check=True,
135
+ capture_output=True, text=True).stdout)
136
+ ms = sorted(r["ms"] for r in out[50:]) # skip JIT warm-up
137
+ return {"runtime": "web/autocomplete.ts on Bun", "median_ms": round(statistics.median(ms), 2),
138
+ "p95_ms": round(ms[int(0.95 * len(ms))], 2), "max_ms": round(ms[-1], 2)}
 
139
 
140
 
141
  def exactness() -> float:
 
152
  return worst
153
 
154
 
155
+ def heldout_typing(name: str, texts: list[str], src: dict) -> dict:
156
+ """Simulated typing on held-out prompts with the shipped policy, compiled without them."""
157
+ s = bw.load_settings(name)
158
+ vocab, ngram, _ = bw.compile_tables(s, src)
159
+ sugg = sg.Suggester(vocab, ngram, sg.oov_prefix_rates(s, src, vocab))
160
+ recs = sweep.record(sugg, texts)
161
+ policy = export_web.load_policy()
162
+ curve = {f"tau={t}": sweep.evaluate(recs, sg.Policy(tau=t, min_support=policy.min_support))
163
+ for t in [policy.tau, 0.8, 0.9, 0.95]}
164
+ return {"policy": policy.__dict__, "heldout_prompts": len(texts), "characters": sum(map(len, texts)), **curve}
165
+
166
+
167
+ def heldout_next_token(texts_src: dict) -> dict:
168
+ """Top-1/top-3 next-token accuracy of the dense model's tables on held-out prompts."""
169
+ s = bw.load_settings("hf")
170
+ _, test = bw.heldout_split(bw.load_sources()["prompts"])
171
+ vocab, ngram, _ = bw.compile_tables(s, texts_src)
172
  index = {p: i for i, p in enumerate(vocab)}
173
  top1 = top3 = total = oov = 0
174
  for toks in test:
 
182
  ranked = ngram.reference_logits(ids[: t + 1]).topk(3).indices.tolist()
183
  top1 += ranked[0] == target
184
  top3 += target in ranked
185
+ return {"heldout_lines": len(test), "heldout_tokens": total, "next_token_top1": round(top1 / total, 3),
186
+ "next_token_top3": round(top3 / total, 3), "oov_rate": round(oov / total, 3)}
 
 
 
 
 
187
 
188
 
189
  def main() -> None:
190
  results = {"hardware": hardware()}
191
+ print("compiling dense model (2 runs)...", flush=True)
192
+ results["compile_dense"] = compile_dense()
193
+ print("compiling web export (2 runs)...", flush=True)
194
+ results["compile_web"] = compile_web()
195
  print("inference...", flush=True)
196
  results["pytorch"] = torch_inference()
197
+ texts, src = sweep.heldout_texts(), sweep.train_sources()
198
+ results["web_keystroke_latency"] = web_latency(texts)
199
  results["max_logprob_error_vs_ngram"] = exactness()
200
+ print("held-out quality...", flush=True)
201
+ results["heldout_typing_web"] = heldout_typing("web", texts, src)
202
+ results["heldout_typing_dense_tables"] = heldout_typing("hf", texts, src)
203
+ results["heldout_next_token_dense"] = heldout_next_token(src)
204
+ (ROOT / "docs" / "benchmarks.json").write_text(json.dumps(results, indent=2) + "\n")
205
  print(json.dumps(results, indent=2))
206
 
207
 
scripts/experiment_topic.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Experiment: does long-range "topic" context help, before compiling it into attention?
2
+
3
+ Hypothesis: a head that attends to the most recent topic word anywhere earlier in the
4
+ prompt (python, sql, react, ...) gives context the 4-token n-gram window can't see.
5
+
6
+ Closed-form trigger model (no fitting): for each topic word t, count next-word
7
+ frequencies at positions where t is the most recent topic word earlier in the line.
8
+ The bias added to the n-gram logits is
9
+ lambda * log( p(w | t) / p(w) ), p(w | t) = (c(t, w) + 1) / (c(t) + V)
10
+ kept only where c(t, w) >= MIN_COUNT. In a compiled transformer this would be one
11
+ content-addressed attention head (keys = "is topic word" + code, recency via RoPE) and
12
+ one MLP neuron per topic word writing these biases.
13
+
14
+ Scored with the same held-out typing simulation as scripts/sweep.py.
15
+ """
16
+
17
+ import json
18
+ import sys
19
+ from collections import Counter, defaultdict
20
+ from pathlib import Path
21
+
22
+ import numpy as np
23
+
24
+ ROOT = Path(__file__).resolve().parent.parent
25
+ sys.path.insert(0, str(ROOT))
26
+ sys.path.insert(0, str(ROOT / "scripts"))
27
+ import build_weights as bw # noqa: E402
28
+ import suggest as sg # noqa: E402
29
+ import sweep # noqa: E402
30
+
31
+ TOPIC_WORDS = [
32
+ "python", "javascript", "js", "typescript", "java", "c", "c++", "c#", "rust", "golang", "go", "ruby", "php",
33
+ "kotlin", "swift", "sql", "mysql", "postgresql", "sqlite", "bash", "shell", "powershell", "html", "css", "react",
34
+ "vue", "angular", "node", "django", "flask", "pandas", "numpy", "pytorch", "tensorflow", "excel", "linux", "windows",
35
+ "git", "github", "docker", "kubernetes", "regex", "json", "api", "unity", "blender", "matlab", "r", "lua", "haskell",
36
+ ]
37
+ MIN_COUNT = 3
38
+
39
+
40
+ class TopicSuggester(sg.Suggester):
41
+ def __init__(self, vocab, ngram, oov, topics: set[int], bias: dict[int, tuple[np.ndarray, np.ndarray]], lam: float):
42
+ super().__init__(vocab, ngram, oov)
43
+ self.topics, self.bias, self.lam = topics, bias, lam
44
+
45
+ def topic_of(self, ids: list[int]) -> int | None:
46
+ for i in reversed(ids):
47
+ if i in self.topics:
48
+ return i
49
+ return None
50
+
51
+ def dist(self, ids):
52
+ t = self.topic_of(ids)
53
+ key = (self.ngram.window(ids), t)
54
+ if key not in self._dist:
55
+ logits, support, _ = self.ngram.predict(ids)
56
+ if t is not None and t in self.bias:
57
+ idx, vals = self.bias[t]
58
+ logits[idx] += self.lam * vals
59
+ p = np.exp(logits - logits.max())
60
+ self._dist[key] = (p / p.sum(), support)
61
+ return self._dist[key]
62
+
63
+
64
+ def topic_bias(vocab: list[str], src: dict, s: bw.Settings, topics: set[int]):
65
+ index = {p: i for i, p in enumerate(vocab)}
66
+ V = len(vocab)
67
+ uni, by_topic = Counter(), defaultdict(Counter)
68
+ for lines, w in bw.weighted_sources(s, src):
69
+ for toks in lines:
70
+ ids = [index.get(p, bw.UNK_ID) for p in toks] + [bw.EOS_ID]
71
+ topic = None
72
+ for i, nxt in enumerate(ids):
73
+ if nxt != bw.UNK_ID:
74
+ uni[nxt] += w
75
+ if topic is not None:
76
+ by_topic[topic][nxt] += w
77
+ if i < len(ids) - 1 and ids[i] in topics:
78
+ topic = ids[i]
79
+ total = sum(uni.values())
80
+ out = {}
81
+ for t, cnt in by_topic.items():
82
+ ct = sum(cnt.values())
83
+ keep = [(w, c) for w, c in cnt.items() if c >= MIN_COUNT]
84
+ if not keep:
85
+ continue
86
+ idx = np.array([w for w, _ in keep])
87
+ vals = np.array([np.log((c + 1) / (ct + V)) - np.log((uni[w] + 1) / (total + V)) for w, c in keep])
88
+ out[t] = (idx, vals)
89
+ return out
90
+
91
+
92
+ def main() -> None:
93
+ s = bw.load_settings("web")
94
+ src, texts = sweep.train_sources(), sweep.heldout_texts()
95
+ vocab, ngram, _ = bw.compile_tables(s, src)
96
+ oov = sg.oov_prefix_rates(s, src, vocab)
97
+ index = {p: i for i, p in enumerate(vocab)}
98
+ topics = {index[p] for w in TOPIC_WORDS for p in ["Ġ" + w] if p in index}
99
+ bias = topic_bias(vocab, src, s, topics)
100
+ with_topic = sum(1 for t in texts if any(("Ġ" + w) in bw.pieces(t) for w in TOPIC_WORDS))
101
+ print(f"topic words in vocab: {len(topics)}, with biases: {len(bias)}; held-out prompts mentioning one: {with_topic}/{len(texts)}")
102
+ results = []
103
+ for lam in [0.0, 0.25, 0.5, 1.0]:
104
+ sugg = TopicSuggester(vocab, ngram, oov, topics, bias, lam)
105
+ best = sweep.best_policy(sweep.record(sugg, texts))["best"]
106
+ results.append({"lambda": lam, **best})
107
+ print(f"lambda={lam}: saved={best['keystrokes_saved']} prec={best['precision']} cov={best['coverage']} tau={best['tau']}", flush=True)
108
+ (ROOT / "docs" / "experiment_topic.json").write_text(json.dumps({"topic_words": len(topics), "results": results}, indent=2) + "\n")
109
+
110
+
111
+ if __name__ == "__main__":
112
+ main()
scripts/fetch_coding_prompts.py CHANGED
@@ -1,4 +1,7 @@
1
- """Download human-written coding prompts and write data/coding_prompts.txt.
 
 
 
2
 
3
  Sources (all human-written, permissively licensed; see data/SOURCES.md):
4
  - OpenAssistant/oasst2 (Apache-2.0) English prompter and assistant messages from
@@ -18,7 +21,7 @@ from pathlib import Path
18
  import pyarrow.parquet as pq
19
  from huggingface_hub import hf_hub_download
20
 
21
- OUT = Path(__file__).resolve().parent.parent / "data" / "coding_prompts.txt"
22
  MAX_WORDS = 24
23
  MAX_PROMPT_SENTENCES = 2
24
  MAX_REPLY_SENTENCES = 5
@@ -61,7 +64,7 @@ def clean(text: str, max_sentences: int = MAX_PROMPT_SENTENCES) -> list[str]:
61
  return out
62
 
63
 
64
- def oasst() -> list[str]:
65
  path = hf_hub_download("OpenAssistant/oasst2", "2023-11-05_oasst2_ready.messages.jsonl.gz", repo_type="dataset")
66
  rows = [json.loads(line) for line in gzip.open(path, "rt")]
67
  rows = [
@@ -69,12 +72,14 @@ def oasst() -> list[str]:
69
  if r["lang"] == "en" and not r["deleted"] and r["review_result"] and not r["synthetic"]
70
  ]
71
  coding_trees = {r["message_tree_id"] for r in rows if r["parent_id"] is None and CODE_RE.search(r["text"])}
72
- out = []
73
  for r in rows:
74
  if r["message_tree_id"] in coding_trees:
75
- limit = MAX_PROMPT_SENTENCES if r["role"] == "prompter" else MAX_REPLY_SENTENCES
76
- out += clean(r["text"], limit)
77
- return out
 
 
78
 
79
 
80
  def mbpp() -> list[str]:
@@ -97,19 +102,24 @@ def conala() -> list[str]:
97
 
98
 
99
  def main() -> None:
100
- seen, lines = set(), []
101
- counts = {}
102
- for name, fetch in [("oasst2", oasst), ("mbpp", mbpp), ("conala", conala)]:
103
- got = fetch()
104
- counts[name] = len(got)
105
- for s in got:
106
- key = s.lower()
107
- if key not in seen:
108
- seen.add(key)
109
- lines.append(s)
110
- OUT.parent.mkdir(exist_ok=True)
111
- OUT.write_text("\n".join(lines) + "\n")
112
- print(json.dumps({**counts, "unique_lines": len(lines), "out": str(OUT)}, indent=2))
 
 
 
 
 
113
 
114
 
115
  if __name__ == "__main__":
 
1
+ """Download human-written coding text into data/prompts.txt and data/replies.txt.
2
+
3
+ prompts.txt: what people type to an assistant (OASST user turns, MBPP, CoNaLa).
4
+ replies.txt: OASST assistant turns (human-written replies).
5
 
6
  Sources (all human-written, permissively licensed; see data/SOURCES.md):
7
  - OpenAssistant/oasst2 (Apache-2.0) English prompter and assistant messages from
 
21
  import pyarrow.parquet as pq
22
  from huggingface_hub import hf_hub_download
23
 
24
+ DATA = Path(__file__).resolve().parent.parent / "data"
25
  MAX_WORDS = 24
26
  MAX_PROMPT_SENTENCES = 2
27
  MAX_REPLY_SENTENCES = 5
 
64
  return out
65
 
66
 
67
+ def oasst() -> tuple[list[str], list[str]]:
68
  path = hf_hub_download("OpenAssistant/oasst2", "2023-11-05_oasst2_ready.messages.jsonl.gz", repo_type="dataset")
69
  rows = [json.loads(line) for line in gzip.open(path, "rt")]
70
  rows = [
 
72
  if r["lang"] == "en" and not r["deleted"] and r["review_result"] and not r["synthetic"]
73
  ]
74
  coding_trees = {r["message_tree_id"] for r in rows if r["parent_id"] is None and CODE_RE.search(r["text"])}
75
+ prompts, replies = [], []
76
  for r in rows:
77
  if r["message_tree_id"] in coding_trees:
78
+ if r["role"] == "prompter":
79
+ prompts += clean(r["text"], MAX_PROMPT_SENTENCES)
80
+ else:
81
+ replies += clean(r["text"], MAX_REPLY_SENTENCES)
82
+ return prompts, replies
83
 
84
 
85
  def mbpp() -> list[str]:
 
102
 
103
 
104
  def main() -> None:
105
+ oasst_prompts, oasst_replies = oasst()
106
+ sources = {
107
+ "prompts": [("oasst2_user", oasst_prompts), ("mbpp", mbpp()), ("conala", conala())],
108
+ "replies": [("oasst2_assistant", oasst_replies)],
109
+ }
110
+ seen, report = set(), {}
111
+ DATA.mkdir(exist_ok=True)
112
+ for kind, parts in sources.items():
113
+ lines = []
114
+ for name, got in parts:
115
+ report[name] = len(got)
116
+ for s in got:
117
+ if s.lower() not in seen:
118
+ seen.add(s.lower())
119
+ lines.append(s)
120
+ (DATA / f"{kind}.txt").write_text("\n".join(lines) + "\n")
121
+ report[f"{kind}_unique"] = len(lines)
122
+ print(json.dumps(report, indent=2))
123
 
124
 
125
  if __name__ == "__main__":
scripts/sweep.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Choose compile settings and the confidence policy by simulated typing on held-out prompts.
2
+
3
+ The weights are always produced by the same closed-form compile; this script only
4
+ compares a handful of compile settings (context length, smoothing, source weights,
5
+ vocabulary, pruning) and policy thresholds, and reports which one saves the most
6
+ keystrokes while keeping precision >= TARGET_PRECISION.
7
+
8
+ Simulation: each held-out prompt (10% of data/prompts.txt, never compiled in) is
9
+ "typed" one character at a time. After every keystroke the policy shows ghost text
10
+ or nothing. A shown suggestion is correct only if the user's next characters start
11
+ with the whole ghost text. Keystrokes saved replays the prompt accepting every
12
+ correct suggestion with one Tab press.
13
+
14
+ python scripts/sweep.py # writes docs/sweep.json
15
+ """
16
+
17
+ import json
18
+ import sys
19
+ import time
20
+ import zlib
21
+ from dataclasses import asdict, replace
22
+ from pathlib import Path
23
+
24
+ ROOT = Path(__file__).resolve().parent.parent
25
+ sys.path.insert(0, str(ROOT))
26
+ import build_weights as bw # noqa: E402
27
+ import suggest as sg # noqa: E402
28
+
29
+ TARGET_PRECISION = 0.90
30
+ MAX_EXPORT_MB = 50
31
+ TAUS = [round(0.5 + 0.01 * i, 2) for i in range(46)] # 0.50 .. 0.95
32
+ SUPPORTS = [0, 2, 5, 10]
33
+ BASE = bw.Settings(n_prev=3, smoothing="wb", phrase_weight=5, prompt_weight=1, reply_weight=1, max_vocab=8000, min_count=2)
34
+
35
+
36
+ def heldout_texts() -> list[str]:
37
+ seen, out = set(), []
38
+ for line in (bw.DATA_DIR / "prompts.txt").read_text().splitlines():
39
+ toks = bw.pieces(line)
40
+ if toks and zlib.crc32(" ".join(toks).encode()) % 100 < 10:
41
+ text = bw.NORMALIZER.normalize_str(line.strip())
42
+ if text not in seen:
43
+ seen.add(text)
44
+ out.append(text)
45
+ return out
46
+
47
+
48
+ def train_sources() -> dict:
49
+ src = bw.load_sources()
50
+ src["prompts"], _ = bw.heldout_split(src["prompts"])
51
+ return src
52
+
53
+
54
+ def record(sugg: sg.Suggester, texts: list[str]) -> list[tuple[str, list]]:
55
+ """For every prompt and every keystroke position, the unthresholded suggestion chain."""
56
+ return [(t, [None] + [sugg.steps(t[:i]) for i in range(1, len(t))]) for t in texts]
57
+
58
+
59
+ def evaluate(records, policy: sg.Policy) -> dict:
60
+ shown = correct = positions = chars = strokes = 0
61
+ for text, steps in records:
62
+ ghosts = [None] + [sg.Suggester.apply(st, policy) for st in steps[1:]]
63
+ for i in range(1, len(text)):
64
+ positions += 1
65
+ if ghosts[i]:
66
+ shown += 1
67
+ correct += text.startswith(ghosts[i], i)
68
+ pos, n = 0, 0
69
+ while pos < len(text):
70
+ pos, n = pos + 1, n + 1
71
+ g = ghosts[pos] if pos < len(text) else ""
72
+ if g and text.startswith(g, pos):
73
+ pos, n = pos + len(g), n + 1
74
+ chars, strokes = chars + len(text), strokes + n
75
+ return {
76
+ "precision": round(correct / shown, 4) if shown else None,
77
+ "coverage": round(shown / positions, 4),
78
+ "keystrokes_saved": round(1 - strokes / chars, 4),
79
+ "shown": shown,
80
+ }
81
+
82
+
83
+ def best_policy(records) -> dict:
84
+ results = []
85
+ for tau in TAUS:
86
+ for sup in SUPPORTS:
87
+ m = evaluate(records, sg.Policy(tau=tau, min_support=sup))
88
+ results.append({"tau": tau, "min_support": sup, **m})
89
+ ok = [r for r in results if r["precision"] is not None and r["precision"] >= TARGET_PRECISION]
90
+ best = max(ok, key=lambda r: r["keystrokes_saved"]) if ok else None
91
+ return {"best": best, "grid": results}
92
+
93
+
94
+ def export_mb(ngram: bw.NGram, vocab: list[str], oov: dict) -> float:
95
+ """Size of web/model.bin for these tables (see export_web.py)."""
96
+ size = sum(len(p.encode()) + 1 for p in vocab) + 8 * len(vocab) + sum(len(p.encode()) + 5 for p in oov)
97
+ for k in range(1, ngram.order + 1):
98
+ for table in ngram.tables[k].values():
99
+ size += 2 * k + 8 + 6 * len(table)
100
+ return round(size / 1e6, 1)
101
+
102
+
103
+ def run(s: bw.Settings, src: dict, texts: list[str]) -> dict:
104
+ t0 = time.time()
105
+ vocab, ngram, stats = bw.compile_tables(s, src)
106
+ oov = sg.oov_prefix_rates(s, src, vocab)
107
+ recs = record(sg.Suggester(vocab, ngram, oov), texts)
108
+ pol = best_policy(recs)
109
+ out = {
110
+ "settings": asdict(s),
111
+ **stats,
112
+ "export_mb": export_mb(ngram, vocab, oov),
113
+ "best": pol["best"],
114
+ "seconds": round(time.time() - t0, 1),
115
+ "grid": pol["grid"],
116
+ }
117
+ b = out["best"] or {}
118
+ print(
119
+ f"n_prev={s.n_prev} {s.smoothing} w=({s.phrase_weight},{s.prompt_weight},{s.reply_weight}) "
120
+ f"V={s.max_vocab} min={s.min_count} | {out['export_mb']} MB | "
121
+ f"saved={b.get('keystrokes_saved')} prec={b.get('precision')} cov={b.get('coverage')} "
122
+ f"tau={b.get('tau')} sup={b.get('min_support')} | {out['seconds']}s",
123
+ flush=True,
124
+ )
125
+ return out
126
+
127
+
128
+ def score(r: dict) -> float:
129
+ if r["best"] is None or r["export_mb"] > MAX_EXPORT_MB:
130
+ return -1
131
+ return r["best"]["keystrokes_saved"]
132
+
133
+
134
+ def main() -> None:
135
+ src, texts = train_sources(), heldout_texts()
136
+ print(f"held-out prompts: {len(texts)}, characters: {sum(map(len, texts))}", flush=True)
137
+ runs = []
138
+
139
+ print("== stage A: context length x smoothing", flush=True)
140
+ stage = [run(replace(BASE, n_prev=n, smoothing=sm), src, texts) for sm in ["wb", "kn"] for n in [3, 4, 5, 7, 9]]
141
+ runs += stage
142
+ best = max(stage, key=score)
143
+
144
+ print("== stage B: source weights", flush=True)
145
+ base = bw.Settings(**best["settings"])
146
+ stage = [
147
+ run(replace(base, phrase_weight=pw, reply_weight=rw), src, texts)
148
+ for pw in [1.0, 5.0]
149
+ for rw in [0.0, 0.3, 1.0]
150
+ if (pw, rw) != (base.phrase_weight, base.reply_weight)
151
+ ]
152
+ runs += stage
153
+ best = max(runs, key=score)
154
+
155
+ print("== stage C: vocabulary x pruning", flush=True)
156
+ base = bw.Settings(**best["settings"])
157
+ stage = [
158
+ run(replace(base, max_vocab=v, min_count=mc), src, texts)
159
+ for v in [3000, 8000, 16000]
160
+ for mc in [2, 3]
161
+ if (v, mc) != (base.max_vocab, base.min_count)
162
+ ]
163
+ runs += stage
164
+ best = max(runs, key=score)
165
+
166
+ print("== stage D: context length again, with the final settings", flush=True)
167
+ base = bw.Settings(**best["settings"])
168
+ stage = [run(replace(base, n_prev=n), src, texts) for n in [3, 4, 5, 7, 9] if n != base.n_prev]
169
+ runs += stage
170
+ best = max(runs, key=score)
171
+
172
+ out = ROOT / "docs" / "sweep.json"
173
+ out.write_text(json.dumps({
174
+ "target_precision": TARGET_PRECISION,
175
+ "max_export_mb": MAX_EXPORT_MB,
176
+ "heldout_prompts": len(texts),
177
+ "best": {k: v for k, v in best.items() if k != "grid"},
178
+ "runs": [{k: v for k, v in r.items() if k != "grid"} for r in runs],
179
+ "best_grid": best["grid"],
180
+ }, indent=2) + "\n")
181
+ print("BEST", json.dumps({k: v for k, v in best.items() if k != "grid"}, indent=2))
182
+
183
+
184
+ if __name__ == "__main__":
185
+ main()
suggest.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Confidence-gated ghost-text suggestions (reference implementation).
2
+
3
+ web/autocomplete.ts implements the same policy for the browser; the tests check
4
+ they agree. Nothing here is learned: the policy reads the compiled model's
5
+ next-token probabilities and only suggests when they clear fixed thresholds.
6
+
7
+ Given the text typed so far:
8
+ 1. The last pre-token is the word being typed (just "Ġ" after a space). Only
9
+ vocabulary tokens that start with it are candidates ("fun" -> "function").
10
+ 2. Candidate probabilities are renormalized over the candidates and scaled by
11
+ the chance that a word with this prefix is in the vocabulary at all
12
+ (counted from the source text), so rare out-of-vocabulary words don't
13
+ produce confident wrong completions.
14
+ 3. The suggestion is shown only if that probability >= tau and the longest
15
+ matched context was seen >= min_support times. It then extends greedily,
16
+ word by word, while the product of probabilities stays >= tau, stopping
17
+ at end-of-text or after . ? ! :
18
+ """
19
+
20
+ from collections import Counter
21
+ from dataclasses import dataclass
22
+
23
+ import numpy as np
24
+
25
+ from build_weights import EOS_ID, UNK_ID, NGram, Settings, pieces, weighted_sources
26
+
27
+ STOP_AFTER = {".", "?", "!", ":"}
28
+ MAX_WORDS = 8
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Policy:
33
+ tau: float = 0.6 # min probability of the whole suggestion
34
+ min_support: float = 2 # min (weighted) count of the longest matched context
35
+ max_words: int = MAX_WORDS
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Step:
40
+ text: str # characters this step adds to the ghost text
41
+ prob: float # probability of the suggestion up to and including this step
42
+ support: float # count of the longest context matched for this step
43
+
44
+
45
+ def _byte_decoder() -> dict[str, int]:
46
+ bs = list(range(33, 127)) + list(range(161, 173)) + list(range(174, 256))
47
+ cs, n = bs[:], 0
48
+ for b in range(256):
49
+ if b not in bs:
50
+ bs.append(b)
51
+ cs.append(256 + n)
52
+ n += 1
53
+ return {chr(c): b for b, c in zip(bs, cs)}
54
+
55
+
56
+ BYTE_DECODER = _byte_decoder()
57
+
58
+
59
+ def decode_piece(p: str) -> str:
60
+ return bytes(BYTE_DECODER[c] for c in p).decode("utf-8", errors="replace")
61
+
62
+
63
+ def oov_prefix_rates(s: Settings, sources: dict, vocab: list[str]) -> dict[str, float]:
64
+ """P(word is out of vocabulary | word starts with prefix), from weighted source counts.
65
+
66
+ Only prefixes with a non-zero rate are stored; prefixes of in-vocabulary words that
67
+ never start an OOV word are omitted (rate 0).
68
+ """
69
+ in_vocab = set(vocab)
70
+ total, oov = Counter(), Counter()
71
+ for lines, w in weighted_sources(s, sources):
72
+ for toks in lines:
73
+ for p in toks:
74
+ for i in range(1, len(p) + 1):
75
+ total[p[:i]] += w
76
+ if p not in in_vocab:
77
+ oov[p[:i]] += w
78
+ return {pre: oov[pre] / total[pre] for pre in oov}
79
+
80
+
81
+ class Suggester:
82
+ def __init__(self, vocab: list[str], ngram: NGram, oov_rates: dict[str, float]):
83
+ self.vocab, self.ngram, self.oov = vocab, ngram, oov_rates
84
+ self.index = {p: i for i, p in enumerate(vocab)}
85
+ self.text = [decode_piece(p) for p in vocab]
86
+ self._dist, self._cands = {}, {}
87
+
88
+ def dist(self, ids: list[int]) -> tuple[np.ndarray, float]:
89
+ key = self.ngram.window(ids)
90
+ if key not in self._dist:
91
+ logits, support, _ = self.ngram.predict(ids)
92
+ p = np.exp(logits - logits.max())
93
+ self._dist[key] = (p / p.sum(), support)
94
+ return self._dist[key]
95
+
96
+ def candidates(self, partial: str) -> np.ndarray:
97
+ if partial not in self._cands:
98
+ self._cands[partial] = np.array(
99
+ [i for i, p in enumerate(self.vocab) if i > UNK_ID and p.startswith(partial)], dtype=np.int64
100
+ )
101
+ return self._cands[partial]
102
+
103
+ def steps(self, text: str, max_words: int = MAX_WORDS) -> list[Step]:
104
+ """Greedy suggestion chain with cumulative probabilities (before any thresholding)."""
105
+ pcs = pieces(text)
106
+ if not pcs:
107
+ return []
108
+ partial = pcs[-1]
109
+ cands = self.candidates(partial)
110
+ if not len(cands):
111
+ return []
112
+ ids = [EOS_ID] + [self.index.get(p, UNK_ID) for p in pcs[:-1]]
113
+ probs, support = self.dist(ids)
114
+ pc = probs[cands]
115
+ best = int(cands[pc.argmax()])
116
+ prob = (1.0 - self.oov.get(partial, 0.0)) * pc.max() / pc.sum()
117
+ out = [Step(self.text[best][len(decode_piece(partial)) :], prob, support)]
118
+ ids.append(best)
119
+ while self.vocab[ids[-1]] not in STOP_AFTER and len(out) < max_words:
120
+ probs, support = self.dist(ids)
121
+ nxt = int(probs.argmax())
122
+ if nxt == EOS_ID:
123
+ break
124
+ prob *= probs[nxt]
125
+ out.append(Step(self.text[nxt], prob, support))
126
+ ids.append(nxt)
127
+ return out
128
+
129
+ @staticmethod
130
+ def apply(steps: list[Step], policy: Policy) -> str:
131
+ ghost = ""
132
+ for i, st in enumerate(steps[: policy.max_words]):
133
+ if st.prob < policy.tau or st.support < policy.min_support:
134
+ break
135
+ ghost += st.text
136
+ return ghost
137
+
138
+ def suggest(self, text: str, policy: Policy = Policy()) -> str:
139
+ return self.apply(self.steps(text, policy.max_words), policy)
test_model.py CHANGED
@@ -24,15 +24,15 @@ CASES = {
24
  "Thank you for your": "help.",
25
  "I hope this email": "finds you well.",
26
  "Can I have the": "menu, please?",
27
- "Looking forward to": "hearing from you.",
28
  "Actions speak": "louder than words.",
29
  "Where is the": "bathroom?",
30
  "Happy birthday": ".",
31
  "I'm so proud": "of you.",
32
- # coding prompts (data/coding_prompts.txt)
33
  "In Python,": "you can use the following code:",
34
- "How to convert a": "string to a list of tuples",
35
- "Here is an example of": "a basic particle class.",
36
  }
37
 
38
 
@@ -103,5 +103,66 @@ class TestModel(unittest.TestCase):
103
  self.assertEqual(res["completion"].strip(), self.complete(prompt))
104
 
105
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  if __name__ == "__main__":
107
  unittest.main()
 
24
  "Thank you for your": "help.",
25
  "I hope this email": "finds you well.",
26
  "Can I have the": "menu, please?",
27
+ "Looking forward to": "working with you.",
28
  "Actions speak": "louder than words.",
29
  "Where is the": "bathroom?",
30
  "Happy birthday": ".",
31
  "I'm so proud": "of you.",
32
+ # coding prompts and replies (data/*.txt)
33
  "In Python,": "you can use the following code:",
34
+ "You can use the": "following code:",
35
+ "How to convert a": "string to a file in python",
36
  }
37
 
38
 
 
103
  self.assertEqual(res["completion"].strip(), self.complete(prompt))
104
 
105
 
106
+ @unittest.skipUnless(shutil.which("bun"), "bun not installed")
107
+ class TestWebExport(unittest.TestCase):
108
+ """web/model.bin + web/autocomplete.ts (run `python export_web.py` first)."""
109
+
110
+ @classmethod
111
+ def setUpClass(cls):
112
+ import export_web
113
+ import suggest
114
+
115
+ cls.export_web, cls.suggest = export_web, suggest
116
+ cls.settings, cls.sources = build_weights.load_settings("web"), build_weights.load_sources()
117
+ cls.policy = export_web.load_policy()
118
+ vocab, ngram, _ = build_weights.compile_tables(cls.settings, cls.sources)
119
+ oov = suggest.oov_prefix_rates(cls.settings, cls.sources, vocab)
120
+ cls.suggester = suggest.Suggester(vocab, ngram, oov)
121
+
122
+ def cli(self, *args: str) -> list:
123
+ out = subprocess.run(["bun", "web/cli.ts", *args], cwd=MODEL_DIR, capture_output=True, text=True, check=True)
124
+ return json.loads(out.stdout)
125
+
126
+ def test_size(self):
127
+ self.assertLessEqual((MODEL_DIR / "web" / "model.bin").stat().st_size, 50_000_000)
128
+
129
+ def test_matches_python_policy(self):
130
+ """Same suggestion chains (text, probability, support) as suggest.py on random prefixes."""
131
+ rng = random.Random(1)
132
+ lines = [ln for ln in (MODEL_DIR / "data" / "prompts.txt").read_text().splitlines() if len(ln) > 2]
133
+ prefixes = [t[: rng.randint(1, len(t) - 1)] for t in rng.sample(lines, 300)]
134
+ for p, res in zip(prefixes, self.cli("--json", *prefixes)):
135
+ with self.subTest(prefix=p):
136
+ py = self.suggester.steps(p, self.policy.max_words)
137
+ self.assertEqual([s.text for s in py], [s["text"] for s in res["steps"]])
138
+ for a, b in zip(py, res["steps"]):
139
+ self.assertAlmostEqual(a.prob, b["prob"], places=3)
140
+ self.assertAlmostEqual(a.support, b["support"], places=3)
141
+ if all(abs(s.prob - self.policy.tau) > 1e-3 for s in py): # skip float32 ties at the threshold
142
+ self.assertEqual(self.suggest.Suggester.apply(py, self.policy), res["ghost"])
143
+
144
+ def test_runtime_matches_dense_transformer(self):
145
+ """The sparse runtime, run on the dense model's own tables, reproduces PyTorch."""
146
+ import tempfile
147
+
148
+ s = build_weights.load_settings("hf")
149
+ vocab, ngram, _ = build_weights.compile_tables(s, self.sources)
150
+ oov = self.suggest.oov_prefix_rates(s, self.sources, vocab)
151
+ texts = ["Once upon a", "write a python function to", "zebra", "how do i", "thank you for your"]
152
+ tok = AutoTokenizer.from_pretrained(MODEL_DIR)
153
+ model = AutoModelForCausalLM.from_pretrained(MODEL_DIR).eval()
154
+ with tempfile.TemporaryDirectory() as tmp:
155
+ path = Path(tmp) / "model.bin"
156
+ self.export_web.export(vocab, ngram, oov, self.policy, path)
157
+ results = self.cli("--model", str(path), "--dist", *texts)
158
+ with torch.no_grad():
159
+ for t, res in zip(texts, results):
160
+ with self.subTest(text=t):
161
+ lp = model(**tok(t, return_tensors="pt")).logits[0, -1].double().log_softmax(-1)
162
+ self.assertEqual([i for i, _ in res["top"]], lp.topk(5).indices.tolist())
163
+ for i, v in res["top"]:
164
+ self.assertAlmostEqual(v, lp[i].item(), places=3)
165
+
166
+
167
  if __name__ == "__main__":
168
  unittest.main()
tokenizer.json CHANGED
The diff for this file is too large to render. See raw diff
 
tokenizer.ts ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Word-level tokenizer with GPT-2 byte-level pre-tokenization, matching tokenizer.json
2
+ // (NFKC + lowercase, add_prefix_space, GPT-2 split regex). No Node or DOM APIs, so it
3
+ // is shared by model.ts (Node/Bun) and web/autocomplete.ts (browser).
4
+
5
+ function bytesToUnicode(): Map<number, string> {
6
+ const bs: number[] = [];
7
+ for (let b = 33; b <= 126; b++) bs.push(b);
8
+ for (let b = 161; b <= 172; b++) bs.push(b);
9
+ for (let b = 174; b <= 255; b++) bs.push(b);
10
+ const cs = [...bs];
11
+ let n = 0;
12
+ for (let b = 0; b < 256; b++) {
13
+ if (!bs.includes(b)) {
14
+ bs.push(b);
15
+ cs.push(256 + n++);
16
+ }
17
+ }
18
+ return new Map(bs.map((b, i) => [b, String.fromCodePoint(cs[i])]));
19
+ }
20
+
21
+ const BYTE_ENC = bytesToUnicode();
22
+ const BYTE_DEC = new Map([...BYTE_ENC].map(([b, c]) => [c, b]));
23
+ const GPT2_SPLIT = /'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+/gu;
24
+ const encoder = new TextEncoder();
25
+ const decoder = new TextDecoder();
26
+
27
+ /** Byte-level pre-tokens ("Ġword" style), exactly as the Python tokenizer splits text. */
28
+ export function preTokenize(text: string): string[] {
29
+ let norm = text.normalize("NFKC").toLowerCase();
30
+ if (norm && !/^\s/.test(norm)) norm = " " + norm; // ByteLevel add_prefix_space
31
+ return (norm.match(GPT2_SPLIT) ?? []).map((piece) => [...encoder.encode(piece)].map((b) => BYTE_ENC.get(b)!).join(""));
32
+ }
33
+
34
+ /** Byte-level piece(s) back to text. */
35
+ export function decodePieces(pieces: string): string {
36
+ return decoder.decode(new Uint8Array([...pieces].map((c) => BYTE_DEC.get(c) ?? 63)));
37
+ }
38
+
39
+ export class WordTokenizer {
40
+ vocab: Map<string, number>;
41
+ inverse: string[];
42
+ special: Set<number>;
43
+ unkId: number;
44
+ bosId: number;
45
+
46
+ constructor(pieces: string[], unkId: number, bosId: number, special: Iterable<number>) {
47
+ this.unkId = unkId;
48
+ this.bosId = bosId;
49
+ this.inverse = pieces;
50
+ this.vocab = new Map(pieces.map((p, i) => [p, i]));
51
+ this.special = new Set(special);
52
+ }
53
+
54
+ /** tokenizer.json (WordLevel model, BOS template post-processor) -> tokenizer. */
55
+ static fromJson(json: any): WordTokenizer {
56
+ const pieces: string[] = [];
57
+ for (const [tok, id] of Object.entries<number>(json.model.vocab)) pieces[id] = tok;
58
+ const bos = json.post_processor?.single?.find((p: any) => p.SpecialToken)?.SpecialToken?.id;
59
+ const special = (json.added_tokens ?? []).filter((t: any) => t.special).map((t: any) => t.id);
60
+ return new WordTokenizer(pieces, json.model.vocab[json.model.unk_token], json.model.vocab[bos], special);
61
+ }
62
+
63
+ idOf(piece: string): number {
64
+ return this.vocab.get(piece) ?? this.unkId;
65
+ }
66
+
67
+ encode(text: string): number[] {
68
+ return [this.bosId, ...preTokenize(text).map((p) => this.idOf(p))];
69
+ }
70
+
71
+ decode(ids: number[]): string {
72
+ return decodePieces(ids.filter((id) => !this.special.has(id)).map((id) => this.inverse[id]).join(""));
73
+ }
74
+ }
web/autocomplete.ts ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Browser runtime for the compiled autocomplete model (zero dependencies, no DOM;
2
+ // works on the main thread, in a Web Worker, or in Bun/Node).
3
+ //
4
+ // web/model.bin holds the compiled transformer in sparse form (see export_web.py):
5
+ // the always-on unigram neuron plus, for every MLP neuron, its context tokens and
6
+ // the logit changes it writes. Evaluating only the neurons whose context matches
7
+ // gives the same next-token distribution as the dense model, without the zeros.
8
+ // The confidence policy mirrors suggest.py.
9
+ //
10
+ // const ac = await CompiledAutocomplete.load("/model.bin");
11
+ // ac.suggest("write a python fun").ghost // "ction to"
12
+
13
+ import { decodePieces, preTokenize } from "../tokenizer.ts";
14
+
15
+ export type Policy = { tau: number; min_support: number; max_words: number };
16
+ export type Step = { text: string; prob: number; support: number };
17
+ export type Suggestion = { ghost: string; prob: number; steps: Step[] };
18
+
19
+ type IdArray = Uint16Array | Uint32Array;
20
+ type Order = {
21
+ k: number;
22
+ ctx: IdArray;
23
+ support: Float32Array;
24
+ offsets: Uint32Array;
25
+ dids: IdArray;
26
+ dvals: Float32Array;
27
+ index: Map<string, number>;
28
+ };
29
+
30
+ const MAGIC = "TACB";
31
+
32
+ export class CompiledAutocomplete {
33
+ readonly vocab: string[];
34
+ readonly nPrev: number;
35
+ readonly order: number;
36
+ readonly eosId: number;
37
+ readonly unkId: number;
38
+ readonly policy: Policy;
39
+ readonly bytes: number;
40
+ readonly contexts: number;
41
+ private text: string[];
42
+ private pieceIndex: Map<string, number>;
43
+ private base: Float32Array;
44
+ private orders: Order[];
45
+ private oov: Record<string, number>;
46
+ private stopAfter: Set<string>;
47
+ private candCache = new Map<string, Int32Array>();
48
+ private distCache = new Map<string, { probs: Float64Array; support: number }>();
49
+
50
+ /** Fetch (with optional progress callback) or wrap an already-loaded buffer. */
51
+ static async load(source: string | ArrayBuffer, onProgress?: (fraction: number) => void): Promise<CompiledAutocomplete> {
52
+ if (source instanceof ArrayBuffer) return new CompiledAutocomplete(source);
53
+ const resp = await fetch(source);
54
+ if (!resp.ok || !resp.body) throw new Error(`failed to fetch ${source}: ${resp.status}`);
55
+ const total = Number(resp.headers.get("content-length")) || 0;
56
+ const reader = resp.body.getReader();
57
+ const chunks: Uint8Array[] = [];
58
+ let received = 0;
59
+ for (;;) {
60
+ const { done, value } = await reader.read();
61
+ if (done) break;
62
+ chunks.push(value);
63
+ received += value.length;
64
+ if (total) onProgress?.(received / total);
65
+ }
66
+ const bytes = new Uint8Array(received);
67
+ let off = 0;
68
+ for (const c of chunks) {
69
+ bytes.set(c, off);
70
+ off += c.length;
71
+ }
72
+ onProgress?.(1);
73
+ return new CompiledAutocomplete(bytes.buffer);
74
+ }
75
+
76
+ constructor(buf: ArrayBuffer) {
77
+ const view = new DataView(buf);
78
+ const magic = new TextDecoder().decode(new Uint8Array(buf, 0, 4));
79
+ if (magic !== MAGIC) throw new Error("not a tiny-autocomplete model.bin");
80
+ const headerLen = view.getUint32(8, true);
81
+ const header = JSON.parse(new TextDecoder().decode(new Uint8Array(buf, 12, headerLen)));
82
+ const dataStart = 12 + headerLen + ((8 - ((12 + headerLen) % 8)) % 8);
83
+ const sections = new Map<string, { offset: number; bytes: number }>();
84
+ for (const s of header.sections) sections.set(s.name, s);
85
+ const Ids = header.id_bytes === 2 ? Uint16Array : Uint32Array;
86
+ const f32 = (name: string) => {
87
+ const s = sections.get(name)!;
88
+ return new Float32Array(buf, dataStart + s.offset, s.bytes / 4);
89
+ };
90
+ const u32 = (name: string) => {
91
+ const s = sections.get(name)!;
92
+ return new Uint32Array(buf, dataStart + s.offset, s.bytes / 4);
93
+ };
94
+ const ids = (name: string) => {
95
+ const s = sections.get(name)!;
96
+ return new Ids(buf, dataStart + s.offset, s.bytes / header.id_bytes);
97
+ };
98
+
99
+ this.vocab = header.vocab;
100
+ this.text = this.vocab.map((p) => decodePieces(p));
101
+ this.pieceIndex = new Map(this.vocab.map((p, i) => [p, i]));
102
+ this.nPrev = header.n_prev;
103
+ this.order = this.nPrev + 1;
104
+ this.eosId = header.eos_id;
105
+ this.unkId = header.unk_id;
106
+ this.oov = header.oov_prefix_rates;
107
+ this.policy = header.policy;
108
+ this.stopAfter = new Set(header.stop_after);
109
+ this.bytes = buf.byteLength;
110
+ this.base = f32("base");
111
+ this.orders = header.orders.map(({ k, contexts }: { k: number; contexts: number }) => {
112
+ const ctx = ids(`ctx${k}`);
113
+ const index = new Map<string, number>();
114
+ for (let i = 0; i < contexts; i++) index.set(ctx.subarray(i * k, (i + 1) * k).join(","), i);
115
+ return { k, ctx, support: f32(`support${k}`), offsets: u32(`offsets${k}`), dids: ids(`dids${k}`), dvals: f32(`dvals${k}`), index };
116
+ });
117
+ this.contexts = this.orders.reduce((n, o) => n + o.index.size, 0);
118
+ }
119
+
120
+ /** Last `order` token ids, oldest first; positions before 0 read BOS like the attention heads. */
121
+ private window(ids: number[]): number[] {
122
+ const t = ids.length - 1;
123
+ const w: number[] = [];
124
+ for (let a = this.nPrev; a >= 0; a--) w.push(ids[Math.max(t - a, 0)]);
125
+ return w;
126
+ }
127
+
128
+ /** Next-token distribution and support of the longest matched context. */
129
+ dist(ids: number[]): { probs: Float64Array; support: number } {
130
+ const window = this.window(ids);
131
+ const key = window.join(",");
132
+ const hit = this.distCache.get(key);
133
+ if (hit) return hit;
134
+ const logits = Float64Array.from(this.base);
135
+ let support = 0;
136
+ for (const o of this.orders) {
137
+ const i = o.index.get(window.slice(-o.k).join(","));
138
+ if (i === undefined) continue;
139
+ for (let j = o.offsets[i]; j < o.offsets[i + 1]; j++) logits[o.dids[j]] += o.dvals[j];
140
+ support = o.support[i];
141
+ }
142
+ let max = -Infinity;
143
+ for (const v of logits) if (v > max) max = v;
144
+ let z = 0;
145
+ const probs = new Float64Array(logits.length);
146
+ for (let i = 0; i < logits.length; i++) z += probs[i] = Math.exp(logits[i] - max);
147
+ for (let i = 0; i < probs.length; i++) probs[i] /= z;
148
+ if (this.distCache.size > 2000) this.distCache.clear();
149
+ const out = { probs, support };
150
+ this.distCache.set(key, out);
151
+ return out;
152
+ }
153
+
154
+ private candidates(partial: string): Int32Array {
155
+ let c = this.candCache.get(partial);
156
+ if (!c) {
157
+ const out: number[] = [];
158
+ for (let i = this.unkId + 1; i < this.vocab.length; i++) if (this.vocab[i].startsWith(partial)) out.push(i);
159
+ c = Int32Array.from(out);
160
+ if (this.candCache.size > 5000) this.candCache.clear();
161
+ this.candCache.set(partial, c);
162
+ }
163
+ return c;
164
+ }
165
+
166
+ /** Greedy suggestion chain with cumulative probabilities, before thresholding. */
167
+ steps(text: string, maxWords = this.policy.max_words): Step[] {
168
+ const pcs = preTokenize(text);
169
+ if (!pcs.length) return [];
170
+ const partial = pcs[pcs.length - 1];
171
+ const cands = this.candidates(partial);
172
+ if (!cands.length) return [];
173
+ const ids = [this.eosId, ...pcs.slice(0, -1).map((p) => this.pieceIndex.get(p) ?? this.unkId)];
174
+ let { probs, support } = this.dist(ids);
175
+ let best = cands[0];
176
+ let sum = 0;
177
+ for (const c of cands) {
178
+ sum += probs[c];
179
+ if (probs[c] > probs[best]) best = c;
180
+ }
181
+ let prob = (1 - (this.oov[partial] ?? 0)) * (probs[best] / sum);
182
+ const out: Step[] = [{ text: this.text[best].slice(decodePieces(partial).length), prob, support }];
183
+ ids.push(best);
184
+ while (!this.stopAfter.has(this.vocab[ids[ids.length - 1]]) && out.length < maxWords) {
185
+ ({ probs, support } = this.dist(ids));
186
+ let nxt = 0;
187
+ for (let i = 1; i < probs.length; i++) if (probs[i] > probs[nxt]) nxt = i;
188
+ if (nxt === this.eosId) break;
189
+ prob *= probs[nxt];
190
+ out.push({ text: this.text[nxt], prob, support });
191
+ ids.push(nxt);
192
+ }
193
+ return out;
194
+ }
195
+
196
+ static apply(steps: Step[], policy: Policy): { ghost: string; prob: number } {
197
+ let ghost = "";
198
+ let prob = 0;
199
+ for (const st of steps.slice(0, policy.max_words)) {
200
+ if (st.prob < policy.tau || st.support < policy.min_support) break;
201
+ ghost += st.text;
202
+ prob = st.prob;
203
+ }
204
+ return { ghost, prob };
205
+ }
206
+
207
+ /** Ghost text to show after `text`, or "" when the model isn't confident. */
208
+ suggest(text: string, policy: Policy = this.policy): Suggestion {
209
+ const steps = this.steps(text, policy.max_words);
210
+ return { ...CompiledAutocomplete.apply(steps, policy), steps };
211
+ }
212
+ }
web/cli.ts ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Run the browser runtime from the command line (Bun or Node >= 23.6):
2
+ // bun web/cli.ts "write a python fun" "how do i "
3
+ // bun web/cli.ts --json ... full suggestion chains (used by test_model.py)
4
+ // bun web/cli.ts --dist ... top-5 next-token log-probs after each text
5
+ // bun web/cli.ts --model path/to/model.bin use another export (default web/model.bin)
6
+
7
+ import { readFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { preTokenize } from "../tokenizer.ts";
10
+ import { CompiledAutocomplete } from "./autocomplete.ts";
11
+
12
+ const dir = import.meta.dirname ?? new URL(".", import.meta.url).pathname;
13
+ let args = process.argv.slice(2);
14
+ let modelPath = join(dir, "model.bin");
15
+ const mi = args.indexOf("--model");
16
+ if (mi >= 0) {
17
+ modelPath = args[mi + 1];
18
+ args = [...args.slice(0, mi), ...args.slice(mi + 2)];
19
+ }
20
+ const mode = args[0] === "--json" || args[0] === "--dist" ? args.shift() : "";
21
+
22
+ const buf = readFileSync(modelPath);
23
+ const ac = new CompiledAutocomplete(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength));
24
+
25
+ if (mode === "--dist") {
26
+ const out = args.map((t) => {
27
+ const ids = [ac.eosId, ...preTokenize(t).map((p) => ac.vocab.indexOf(p)).map((i) => (i < 0 ? ac.unkId : i))];
28
+ const { probs } = ac.dist(ids);
29
+ const top = [...probs.keys()].sort((a, b) => probs[b] - probs[a]).slice(0, 5);
30
+ return { text: t, top: top.map((i) => [i, Math.log(probs[i])]) };
31
+ });
32
+ console.log(JSON.stringify(out));
33
+ } else {
34
+ const results = args.map((t) => {
35
+ const start = performance.now();
36
+ const s = ac.suggest(t);
37
+ return { text: t, ...s, ms: performance.now() - start };
38
+ });
39
+ if (mode === "--json") console.log(JSON.stringify(results));
40
+ else for (const r of results) console.log(`${r.text}\x1b[90m${r.ghost}\x1b[0m ${r.ghost ? Math.round(r.prob * 100) + "%" : "(no confident suggestion)"} ${r.ms.toFixed(2)} ms`);
41
+ }
web/demo.js ADDED
@@ -0,0 +1 @@
 
 
1
+ function T(){let H=[];for(let q=33;q<=126;q++)H.push(q);for(let q=161;q<=172;q++)H.push(q);for(let q=174;q<=255;q++)H.push(q);let J=[...H],D=0;for(let q=0;q<256;q++)if(!H.includes(q))H.push(q),J.push(256+D++);return new Map(H.map((q,K)=>[q,String.fromCodePoint(J[K])]))}var $=T(),w=new Map([...$].map(([H,J])=>[J,H])),C=/'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+/gu,j=new TextEncoder,y=new TextDecoder;function v(H){let J=H.normalize("NFKC").toLowerCase();if(J&&!/^\s/.test(J))J=" "+J;return(J.match(C)??[]).map((D)=>[...j.encode(D)].map((q)=>$.get(q)).join(""))}function W(H){return y.decode(new Uint8Array([...H].map((J)=>w.get(J)??63)))}var g="TACB";class Y{vocab;nPrev;order;eosId;unkId;policy;bytes;contexts;text;pieceIndex;base;orders;oov;stopAfter;candCache=new Map;distCache=new Map;static async load(H,J){if(H instanceof ArrayBuffer)return new Y(H);let D=await fetch(H);if(!D.ok||!D.body)throw Error(`failed to fetch ${H}: ${D.status}`);let q=Number(D.headers.get("content-length"))||0,K=D.body.getReader(),O=[],R=0;for(;;){let{done:X,value:Z}=await K.read();if(X)break;if(O.push(Z),R+=Z.length,q)J?.(R/q)}let U=new Uint8Array(R),F=0;for(let X of O)U.set(X,F),F+=X.length;return J?.(1),new Y(U.buffer)}constructor(H){let J=new DataView(H);if(new TextDecoder().decode(new Uint8Array(H,0,4))!==g)throw Error("not a tiny-autocomplete model.bin");let q=J.getUint32(8,!0),K=JSON.parse(new TextDecoder().decode(new Uint8Array(H,12,q))),O=12+q+(8-(12+q)%8)%8,R=new Map;for(let V of K.sections)R.set(V.name,V);let U=K.id_bytes===2?Uint16Array:Uint32Array,F=(V)=>{let Q=R.get(V);return new Float32Array(H,O+Q.offset,Q.bytes/4)},X=(V)=>{let Q=R.get(V);return new Uint32Array(H,O+Q.offset,Q.bytes/4)},Z=(V)=>{let Q=R.get(V);return new U(H,O+Q.offset,Q.bytes/K.id_bytes)};this.vocab=K.vocab,this.text=this.vocab.map((V)=>W(V)),this.pieceIndex=new Map(this.vocab.map((V,Q)=>[V,Q])),this.nPrev=K.n_prev,this.order=this.nPrev+1,this.eosId=K.eos_id,this.unkId=K.unk_id,this.oov=K.oov_prefix_rates,this.policy=K.policy,this.stopAfter=new Set(K.stop_after),this.bytes=H.byteLength,this.base=F("base"),this.orders=K.orders.map(({k:V,contexts:Q})=>{let M=Z(`ctx${V}`),L=new Map;for(let B=0;B<Q;B++)L.set(M.subarray(B*V,(B+1)*V).join(","),B);return{k:V,ctx:M,support:F(`support${V}`),offsets:X(`offsets${V}`),dids:Z(`dids${V}`),dvals:F(`dvals${V}`),index:L}}),this.contexts=this.orders.reduce((V,Q)=>V+Q.index.size,0)}window(H){let J=H.length-1,D=[];for(let q=this.nPrev;q>=0;q--)D.push(H[Math.max(J-q,0)]);return D}dist(H){let J=this.window(H),D=J.join(","),q=this.distCache.get(D);if(q)return q;let K=Float64Array.from(this.base),O=0;for(let Z of this.orders){let V=Z.index.get(J.slice(-Z.k).join(","));if(V===void 0)continue;for(let Q=Z.offsets[V];Q<Z.offsets[V+1];Q++)K[Z.dids[Q]]+=Z.dvals[Q];O=Z.support[V]}let R=-1/0;for(let Z of K)if(Z>R)R=Z;let U=0,F=new Float64Array(K.length);for(let Z=0;Z<K.length;Z++)U+=F[Z]=Math.exp(K[Z]-R);for(let Z=0;Z<F.length;Z++)F[Z]/=U;if(this.distCache.size>2000)this.distCache.clear();let X={probs:F,support:O};return this.distCache.set(D,X),X}candidates(H){let J=this.candCache.get(H);if(!J){let D=[];for(let q=this.unkId+1;q<this.vocab.length;q++)if(this.vocab[q].startsWith(H))D.push(q);if(J=Int32Array.from(D),this.candCache.size>5000)this.candCache.clear();this.candCache.set(H,J)}return J}steps(H,J=this.policy.max_words){let D=v(H);if(!D.length)return[];let q=D[D.length-1],K=this.candidates(q);if(!K.length)return[];let O=[this.eosId,...D.slice(0,-1).map((Q)=>this.pieceIndex.get(Q)??this.unkId)],{probs:R,support:U}=this.dist(O),F=K[0],X=0;for(let Q of K)if(X+=R[Q],R[Q]>R[F])F=Q;let Z=(1-(this.oov[q]??0))*(R[F]/X),V=[{text:this.text[F].slice(W(q).length),prob:Z,support:U}];O.push(F);while(!this.stopAfter.has(this.vocab[O[O.length-1]])&&V.length<J){({probs:R,support:U}=this.dist(O));let Q=0;for(let M=1;M<R.length;M++)if(R[M]>R[Q])Q=M;if(Q===this.eosId)break;Z*=R[Q],V.push({text:this.text[Q],prob:Z,support:U}),O.push(Q)}return V}static apply(H,J){let D="",q=0;for(let K of H.slice(0,J.max_words)){if(K.prob<J.tau||K.support<J.min_support)break;D+=K.text,q=K.prob}return{ghost:D,prob:q}}suggest(H,J=this.policy){let D=this.steps(H,J.max_words);return{...Y.apply(D,J),steps:D}}}var G=(H)=>document.getElementById(H),N=G("prompt"),z=G("ghost"),f=G("status"),_=G("guess"),S=G("tau"),A=G("tau-label"),I=G("load"),k=["write a python fun","how do i ","Write a function to find the ","how to convert a ","can you explain ","I hope this email ","Once upon a ","what is the difference between "];function P(H){return H.replace(/[&<>]/g,(J)=>({"&":"&amp;","<":"&lt;",">":"&gt;"})[J])}async function E(){let H=performance.now(),J=await Y.load("model.bin",(R)=>I.textContent=`downloading model... ${Math.round(R*100)}%`),D=performance.now()-H,q={...J.policy};S.value=String(q.tau),A.textContent=`${Math.round(q.tau*100)}%`,I.textContent=`${(J.bytes/1e6).toFixed(1)} MB · ${J.contexts.toLocaleString()} compiled contexts · `+`${J.order}-token context · ${J.vocab.length.toLocaleString()} words · loaded in ${Math.round(D)} ms`,N.disabled=!1,N.focus();let K="",O=()=>{let R=N.value,U=performance.now(),F=J.suggest(R,q),X=performance.now()-U;K=F.ghost,z.innerHTML=P(R)+"<span>"+P(K)+"</span>",N.style.height="auto",N.style.height=Math.max(N.scrollHeight,z.scrollHeight)+"px",f.textContent=R?`${X.toFixed(2)} ms`:"";let Z=F.steps.findIndex((Q)=>Q.text),V=Z<0?"":F.steps.slice(0,Z+1).map((Q)=>Q.text).join("");if(!R)_.textContent="";else if(K)_.textContent=`suggesting with ${Math.round(F.prob*100)}% confidence`;else if(V)_.textContent=`staying quiet: best guess "${V}" is only ${Math.round(F.steps[Z].prob*100)}% likely`;else _.textContent="staying quiet: nothing known for this prefix"};N.addEventListener("input",O),N.addEventListener("keydown",(R)=>{if(R.key==="Tab"&&K)R.preventDefault(),N.value+=K,O()}),S.addEventListener("input",()=>{q.tau=Number(S.value),A.textContent=`${Math.round(q.tau*100)}%`,O()});for(let R of k){let U=document.createElement("button");U.className="ex",U.textContent=R.trim(),U.onclick=()=>{N.value=R,O(),N.focus()},G("examples").appendChild(U)}O()}E().catch((H)=>I.textContent=String(H));
web/demo.ts ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Static demo for web/autocomplete.ts. Bundle with:
2
+ // bun build web/demo.ts --outfile web/demo.js --target browser --minify
3
+
4
+ import { CompiledAutocomplete, type Policy } from "./autocomplete.ts";
5
+
6
+ const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
7
+ const promptEl = $<HTMLTextAreaElement>("prompt");
8
+ const ghostEl = $("ghost");
9
+ const statusEl = $("status");
10
+ const guessEl = $("guess");
11
+ const tauEl = $<HTMLInputElement>("tau");
12
+ const tauLabel = $("tau-label");
13
+ const loadEl = $("load");
14
+
15
+ const EXAMPLES = [
16
+ "write a python fun", "how do i ", "Write a function to find the ", "how to convert a ",
17
+ "can you explain ", "I hope this email ", "Once upon a ", "what is the difference between ",
18
+ ];
19
+
20
+ function esc(s: string): string {
21
+ return s.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]!);
22
+ }
23
+
24
+ async function main(): Promise<void> {
25
+ const t0 = performance.now();
26
+ const ac = await CompiledAutocomplete.load("model.bin", (f) => (loadEl.textContent = `downloading model... ${Math.round(f * 100)}%`));
27
+ const loadMs = performance.now() - t0;
28
+ const policy: Policy = { ...ac.policy };
29
+ tauEl.value = String(policy.tau);
30
+ tauLabel.textContent = `${Math.round(policy.tau * 100)}%`;
31
+ loadEl.textContent =
32
+ `${(ac.bytes / 1e6).toFixed(1)} MB · ${ac.contexts.toLocaleString()} compiled contexts · ` +
33
+ `${ac.order}-token context · ${ac.vocab.length.toLocaleString()} words · loaded in ${Math.round(loadMs)} ms`;
34
+ promptEl.disabled = false;
35
+ promptEl.focus();
36
+
37
+ let ghost = "";
38
+ const render = () => {
39
+ const text = promptEl.value;
40
+ const start = performance.now();
41
+ const s = ac.suggest(text, policy);
42
+ const ms = performance.now() - start;
43
+ ghost = s.ghost;
44
+ ghostEl.innerHTML = esc(text) + "<span>" + esc(ghost) + "</span>";
45
+ promptEl.style.height = "auto";
46
+ promptEl.style.height = Math.max(promptEl.scrollHeight, ghostEl.scrollHeight) + "px";
47
+ statusEl.textContent = text ? `${ms.toFixed(2)} ms` : "";
48
+ // First step that adds text (the first step is empty when the typed word is already complete).
49
+ const i = s.steps.findIndex((st) => st.text);
50
+ const guess = i < 0 ? "" : s.steps.slice(0, i + 1).map((st) => st.text).join("");
51
+ if (!text) guessEl.textContent = "";
52
+ else if (ghost) guessEl.textContent = `suggesting with ${Math.round(s.prob * 100)}% confidence`;
53
+ else if (guess) guessEl.textContent = `staying quiet: best guess "${guess}" is only ${Math.round(s.steps[i].prob * 100)}% likely`;
54
+ else guessEl.textContent = "staying quiet: nothing known for this prefix";
55
+ };
56
+
57
+ promptEl.addEventListener("input", render);
58
+ promptEl.addEventListener("keydown", (e) => {
59
+ if (e.key === "Tab" && ghost) {
60
+ e.preventDefault();
61
+ promptEl.value += ghost;
62
+ render();
63
+ }
64
+ });
65
+ tauEl.addEventListener("input", () => {
66
+ policy.tau = Number(tauEl.value);
67
+ tauLabel.textContent = `${Math.round(policy.tau * 100)}%`;
68
+ render();
69
+ });
70
+ for (const ex of EXAMPLES) {
71
+ const b = document.createElement("button");
72
+ b.className = "ex";
73
+ b.textContent = ex.trim();
74
+ b.onclick = () => {
75
+ promptEl.value = ex;
76
+ render();
77
+ promptEl.focus();
78
+ };
79
+ $("examples").appendChild(b);
80
+ }
81
+ render();
82
+ }
83
+
84
+ main().catch((e) => (loadEl.textContent = String(e)));
web/index.html ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>tiny-autocomplete · in-browser</title>
7
+ <style>
8
+ :root { --bg: #fafaf9; --fg: #1c1917; --muted: #78716c; --line: #e7e5e4; --accent: #2563eb; --ghost: #a8a29e; --card: #fff; }
9
+ @media (prefers-color-scheme: dark) {
10
+ :root { --bg: #0c0a09; --fg: #f5f5f4; --muted: #a8a29e; --line: #292524; --accent: #60a5fa; --ghost: #57534e; --card: #1c1917; }
11
+ }
12
+ * { box-sizing: border-box; }
13
+ body { margin: 0; font: 16px/1.5 system-ui, -apple-system, sans-serif; background: var(--bg); color: var(--fg); }
14
+ main { max-width: 720px; margin: 0 auto; padding: 48px 20px; }
15
+ h1 { font-size: 24px; margin: 0 0 4px; }
16
+ .sub { color: var(--muted); margin: 0 0 24px; font-size: 14px; }
17
+ .editor { position: relative; background: var(--card); border: 1px solid var(--line); border-radius: 12px; }
18
+ .editor:focus-within { border-color: var(--accent); }
19
+ .layer { font: 20px/1.5 system-ui, -apple-system, sans-serif; padding: 16px 18px; margin: 0; white-space: pre-wrap; word-wrap: break-word; }
20
+ #ghost { position: absolute; inset: 0; pointer-events: none; color: transparent; }
21
+ #ghost span { color: var(--ghost); }
22
+ #prompt { position: relative; width: 100%; min-height: 64px; border: 0; outline: 0; resize: none; background: transparent; color: var(--fg); overflow: hidden; }
23
+ .row { display: flex; justify-content: space-between; gap: 16px; color: var(--muted); font-size: 13px; margin: 8px 2px; }
24
+ #guess { min-height: 20px; }
25
+ kbd { border: 1px solid var(--line); border-radius: 4px; padding: 0 5px; font-size: 12px; background: var(--card); }
26
+ label { display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: 14px; margin: 20px 2px 28px; }
27
+ input[type=range] { flex: 1; accent-color: var(--accent); }
28
+ h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin: 0 0 10px; font-weight: 600; }
29
+ .examples { display: flex; flex-wrap: wrap; gap: 8px; }
30
+ .ex { border: 1px solid var(--line); background: var(--card); color: inherit; border-radius: 999px; padding: 4px 12px; font: inherit; font-size: 14px; cursor: pointer; }
31
+ .ex:hover { border-color: var(--accent); }
32
+ </style>
33
+ </head>
34
+ <body>
35
+ <main>
36
+ <h1>tiny-autocomplete</h1>
37
+ <p class="sub" id="load">loading...</p>
38
+
39
+ <div class="editor">
40
+ <pre class="layer" id="ghost" aria-hidden="true"></pre>
41
+ <textarea class="layer" id="prompt" rows="1" placeholder="Type a coding prompt, e.g. write a python fun" spellcheck="false" disabled></textarea>
42
+ </div>
43
+ <div class="row"><span><kbd>Tab</kbd> accept suggestion</span><span id="status"></span></div>
44
+ <div class="row"><span id="guess"></span></div>
45
+
46
+ <label>Confidence threshold <input type="range" id="tau" min="0.3" max="0.99" step="0.01"> <span id="tau-label"></span></label>
47
+
48
+ <h2>Try</h2>
49
+ <div class="examples" id="examples"></div>
50
+ </main>
51
+ <script type="module" src="demo.js"></script>
52
+ </body>
53
+ </html>
web/model.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d770bf4ec6644ccab5646e2b1244b58b185378b371c52b1c263b5a0ebf3f8499
3
+ size 4494512