goldenfox commited on
Commit
685e018
·
verified ·
1 Parent(s): 0f4ae5e

Marimo Diffusion 0.6B: checkpoint, sampler, OpenAI server, ledger-needle bench

Browse files
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ tokenizer-qwen3-adaptive.json filter=lfs diff=lfs merge=lfs -text
README.md ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ base_model: Qwen/Qwen3-0.6B
4
+ pipeline_tag: text-generation
5
+ language:
6
+ - en
7
+ tags:
8
+ - diffusion
9
+ - block-diffusion
10
+ - masked-diffusion
11
+ - chat
12
+ - memory
13
+ - qwen3
14
+ ---
15
+
16
+ # Marimo Diffusion 0.6B
17
+
18
+ A 0.6B chat model that **thinks in denoised blocks and remembers through its own notes**
19
+ instead of re-reading the conversation. It is a retrofit of
20
+ [Qwen/Qwen3-0.6B](https://huggingface.co/Qwen/Qwen3-0.6B): continued pretraining converts the
21
+ autoregressive base into a hybrid block-diffusion model, and a supervised fine-tune teaches it
22
+ a note-taking chat format.
23
+
24
+ Three things make it different from a standard chat model of this size:
25
+
26
+ - **Adaptive thinking blocks.** Before answering, the model may open thinking blocks of 32/64/128
27
+ tokens, chosen per thought via control tokens (`<szN>`), each denoised bidirectionally in 16
28
+ steps. Trivial turns skip thinking entirely — that decision is trained, not prompted.
29
+ - **Ledger memory.** Only the last 4 messages are kept verbatim in the prefix. Everything older
30
+ survives only as the model's own notes (`key: value`), merged so the latest value wins. A
31
+ 100-turn conversation fits in a ~500-token prefix that never grows.
32
+ - **Constant cost per turn.** ~2 s/turn on an RTX 3090 regardless of conversation length,
33
+ because the prefix is constant by construction.
34
+
35
+ ## ⚠️ This model does NOT run under standard runtimes
36
+
37
+ The weights are Qwen3 architecture, but generation requires the block-denoising sampler included
38
+ in this repository. **transformers `generate()`, llama.cpp, GGUF, Ollama and LM Studio will not
39
+ produce correct output** — their autoregressive decoding never matches the training objective.
40
+ Use the bundled code.
41
+
42
+ ## Quickstart
43
+
44
+ ```bash
45
+ git clone https://huggingface.co/goldenfox/marimo-diffusion
46
+ cd marimo-diffusion
47
+ pip install torch tokenizers numpy
48
+
49
+ # OpenAI-compatible server (any OpenAI-API chat client can connect)
50
+ PYTHONPATH=src python -m diffusion_lm.chat_server \
51
+ --checkpoint marimo-diffusion-0.6b.pt \
52
+ --tokenizer tokenizer-qwen3-adaptive.json \
53
+ --port 7998
54
+ ```
55
+
56
+ Then point any OpenAI-compatible client (Chatbox, Open WebUI, curl) at
57
+ `http://127.0.0.1:7998/v1` with model id `marimo-diffusion-0.6b`:
58
+
59
+ ```bash
60
+ curl http://127.0.0.1:7998/v1/chat/completions -H 'Content-Type: application/json' -d '{
61
+ "model": "marimo-diffusion-0.6b",
62
+ "messages": [{"role": "user", "content": "my sister lands friday 6pm, flight AR1420"}]
63
+ }'
64
+ ```
65
+
66
+ The response carries the model's notes in `reasoning_content` (same field DeepSeek uses), so
67
+ clients that render reasoning show them automatically. The server caches each turn's notes and
68
+ rebuilds the ledger across stateless requests.
69
+
70
+ For the interactive playground (streaming denoise view, per-turn data log):
71
+
72
+ ```bash
73
+ pip install gradio
74
+ PYTHONPATH=src python -m diffusion_lm.reasoning_playground \
75
+ --outputs-dir . --prefix marimo \
76
+ --tokenizer tokenizer-qwen3-adaptive.json --port 7999
77
+ ```
78
+
79
+ A CUDA GPU is recommended (any 6 GB+ card fits the bf16 weights). GPU memory: ~2.5 GB.
80
+
81
+ ## Benchmark: ledger needle (memory across 100 turns)
82
+
83
+ The long-context needle test, adapted to what this architecture claims: a scripted 100-turn
84
+ conversation plants 15 facts, corrects 5 of them, and probes recall at distances of 3–96 turns.
85
+ Baselines get every reasonable advantage: full history in context, greedy decoding, an explicit
86
+ memory instruction, and native thinking mode where it exists. Identical scoring for all systems.
87
+ Full per-turn data and the interactive viewers are in [`bench/`](./bench).
88
+
89
+ | system | params | recall | test total (s) | s/turn | max prefix (tok) |
90
+ |---|---|---|---|---|---|
91
+ | Qwen2.5-1.5B-Instruct · full history | 1.5B | **9/10** | 80 | 0.8 | 2,881 |
92
+ | Qwen3-0.6B + thinking · full history | 0.6B | 7/10 | 1,320 | 13.2 | 4,450 |
93
+ | **Marimo Diffusion (ledger)** | **0.6B** | **6/10** | **220** | **2.2** | **625** |
94
+ | Qwen2.5-0.5B-Instruct · full history | 0.5B | 4/10 | 110 | 1.1 | 3,908 |
95
+ | SmolLM2-360M-Instruct · full history | 0.36B | 4/10 | 60 | 0.6 | 2,712 |
96
+ | TinyLlama-1.1B-Chat · full history | 1.1B | 3/10 | 120 | 1.2 | 5,126 |
97
+ | Qwen3-0.6B no thinking · full history | 0.6B | 1/10 | 130 | 1.3 | 3,722 |
98
+ | Qwen3-0.6B no thinking · 512-token budget | 0.6B | 1/10 | 160 | 1.6 | 512 |
99
+
100
+ Reading this honestly:
101
+
102
+ - **In its size class it leads**: every ≤0.6B baseline with the full transcript in context
103
+ scores 4/10 or less; the ledger reaches 6/10 from a 7× smaller prefix.
104
+ - **Beating it costs something**: 2.5× the parameters (Qwen2.5-1.5B), or the same base model's
105
+ thinking mode at **6× the latency** with an unbounded prefix — and 7 vs 6 on ten probes is
106
+ within noise.
107
+ - **Failure profiles are complementary.** The thinking baseline re-reads verbatim, so it never
108
+ suffers a corrupted note; the ledger never suffers long-context attention loss (it recalled
109
+ facts at distance 59–60 that the thinking baseline missed with the text in front of it).
110
+ - **Caveats**: one seed, ten probes; and the comparison measures the mechanism *and* its
111
+ training together — this model was trained on this conversational register, the baselines
112
+ were not.
113
+
114
+ ## Training
115
+
116
+ - Base: Qwen3-0.6B. Continued pretraining converts AR → hybrid block diffusion (answer region
117
+ stays autoregressive; thinking blocks are masked-denoised bidirectionally).
118
+ - SFT: 174k examples from ~38k conversations — synthetic memory-task dialogues (recap,
119
+ correction, distant-combination, each ending in a consolidating close), passage-grounded QA
120
+ with the source dataset's reference answer as an exact quality gate, an abstention slice, and
121
+ persona-grounded dialogues. Sequence length 512 (median example: 194 tokens); the checkpoint
122
+ is served at 2,048 (RoPE, no learned positions).
123
+ - `steps_per_block 16` is the measured optimum for this checkpoint: best numeric fidelity at
124
+ half the latency of 32; below 8 both prose and numbers degrade.
125
+
126
+ ### Training data provenance
127
+
128
+ | source | role | license |
129
+ |---|---|---|
130
+ | synthetic dialogues (DeepSeek v4-flash generated, machine-audited) | chat + memory tasks | — |
131
+ | [stanfordnlp/coqa](https://huggingface.co/datasets/stanfordnlp/coqa) | multi-turn grounded QA | other (mixed provenance) |
132
+ | [rajpurkar/squad_v2](https://huggingface.co/datasets/rajpurkar/squad_v2) | abstention | cc-by-sa-4.0 |
133
+ | [dgslibisey/MuSiQue](https://huggingface.co/datasets/dgslibisey/MuSiQue) | multi-hop reasoning | undeclared on mirror |
134
+ | [ucinlp/drop](https://huggingface.co/datasets/ucinlp/drop) | arithmetic over passages | cc-by-sa-4.0 |
135
+ | [nayohan/multi_session_chat](https://huggingface.co/datasets/nayohan/multi_session_chat) | human-written persona facts | undeclared on mirror |
136
+
137
+ ## Limitations
138
+
139
+ - **0.6B knowledge ceiling.** It confabulates on open-domain facts like any model this size;
140
+ the training includes an abstention slice ("the passage doesn't say") but it is not a fix.
141
+ - **Note-taking can corrupt compound values** (an alphanumeric like `harbor858` was once noted
142
+ as `8858` and then faithfully recalled wrong). What enters the ledger wrong stays wrong.
143
+ - **Ledger interference**: with 50+ accumulated entries, similar-typed values (several money
144
+ amounts) can cross-contaminate. Training saw ~24 entries max.
145
+ - **No code in training data.** Reasoning about pasted code runs on the base model's residual
146
+ ability.
147
+ - SFT never saw examples past 512 tokens; behaviour between 512 and 2,048 rides on the
148
+ continued pretraining.
149
+ - English only.
150
+
151
+ ## License
152
+
153
+ Apache 2.0, inheriting the Qwen3-0.6B base license. Training data licenses are listed above;
154
+ CoQA carries mixed-provenance terms and two mirrors declare no license — review them if you
155
+ redistribute derived data.
bench/bench_ledger_needle.py ADDED
@@ -0,0 +1,602 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ledger needle bench: fact recall across a conversation far longer than the context.
2
+
3
+ The long-context analogue for this architecture. A needle-in-a-haystack test measures whether a
4
+ fact survives distance inside a huge prompt; here the prompt never exceeds the training window
5
+ (last ``keep`` messages) and facts survive only through the ledger of the model's own notes, so
6
+ the same question — does distance kill recall? — is asked of the memory mechanism instead of the
7
+ attention span.
8
+
9
+ ``run`` drives a deterministic scripted conversation through the engine on the GPU box and
10
+ records everything per turn. ``render`` turns that record into a self-contained HTML viewer:
11
+ timeline, per-turn notes, the exact ledger state, and every probe scored.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import html as html_lib
18
+ import json
19
+ import random
20
+ import re
21
+ import time
22
+ from pathlib import Path
23
+
24
+ FACT_POOL = [
25
+ ('sister flight', 'flight {code}', 'my sister lands on {value}, write that down'),
26
+ ('hotel room', 'room {num3}', 'we got {value} at the hotel'),
27
+ ('locker code', 'code {num4}', 'the gym locker is {value}'),
28
+ ('dentist slot', '{clock}', 'dentist moved me to {value}'),
29
+ ('plumber quote', '${money}', 'the plumber quoted {value} for the bathroom'),
30
+ ('car plate', '{plate}', 'the rental has plate {value}'),
31
+ ('wifi password', '{word}{num3}', 'cabin wifi password is {value}'),
32
+ ('train platform', 'platform {num2}', 'our train leaves from {value}'),
33
+ ('order number', 'order {code}', 'the couch is {value}, keep it handy'),
34
+ ('rent due', 'day {num2}', 'landlord wants rent by {value} each month'),
35
+ ('kid teacher', 'ms. {name}', "tomas' new teacher is {value}"),
36
+ ('parking spot', 'spot {letter}{num2}', 'we always park at {value}'),
37
+ ('meds dose', '{num2}mg', 'doctor changed the dose to {value}'),
38
+ ('deposit', '${money}', 'the deposit for the venue was {value}'),
39
+ ('boarding gate', 'gate {letter}{num2}', 'boarding is at {value}'),
40
+ ]
41
+ DISTRACTORS = [
42
+ 'what a week, honestly', 'did you catch the game last night?', 'i love this weather lately',
43
+ 'work was chaos today', 'thinking of making pasta tonight', 'my back hurts from the gym',
44
+ 'the neighbor is renovating again, so loud', 'saw a great documentary yesterday',
45
+ 'coffee here is getting expensive', 'might go for a walk later', 'the cat knocked over a plant',
46
+ 'traffic was unreal this morning', 'finally finished that book', 'craving something sweet',
47
+ 'they repaved our street', 'my phone battery dies so fast now',
48
+ ]
49
+ PROBES = ['wait, what was the {key} again?', 'remind me of the {key}?',
50
+ 'i forgot the {key}, what was it?', 'quick — the {key}?']
51
+ WORDS = ('maple', 'harbor', 'cactus', 'violet', 'ember', 'quartz')
52
+ NAMES = ('ferro', 'silva', 'duarte', 'campos', 'rojas', 'ibanez')
53
+
54
+
55
+ def _value(template: str, rng: random.Random) -> str:
56
+ return (template
57
+ .replace('{code}', f'{rng.choice("ABKQZ")}{rng.choice("RLMT")}{rng.randint(1000, 9999)}')
58
+ .replace('{num2}', str(rng.randint(10, 99)))
59
+ .replace('{num3}', str(rng.randint(100, 999)))
60
+ .replace('{num4}', str(rng.randint(1000, 9999)))
61
+ .replace('{clock}', f'{rng.randint(1, 12)}:{rng.choice(("15", "30", "45"))}pm')
62
+ .replace('{money}', f'{rng.randint(2, 90) * 100:,}')
63
+ .replace('{plate}', f'{"".join(rng.choice("BCDFGHJK") for _ in range(3))}-{rng.randint(100, 999)}')
64
+ .replace('{word}', rng.choice(WORDS))
65
+ .replace('{letter}', rng.choice('ABCDE'))
66
+ .replace('{name}', rng.choice(NAMES)))
67
+
68
+
69
+ def build_script(turns: int, seed: int) -> list[dict]:
70
+ """Deterministic conversation plan: facts early and throughout, probes at all distances."""
71
+
72
+ rng = random.Random(seed)
73
+ facts = []
74
+ for key, template, phrasing in rng.sample(FACT_POOL, len(FACT_POOL)):
75
+ facts.append({'key': key, 'value': _value(template, rng), 'phrasing': phrasing})
76
+
77
+ plan: list[dict] = []
78
+ stated: dict[str, dict] = {}
79
+ fact_iter = iter(facts)
80
+ for index in range(turns):
81
+ remaining = turns - index
82
+ can_probe = [f for f in stated.values() if index - f['turn'] >= 3]
83
+ if index >= turns - 3 and can_probe:
84
+ kind = 'probe'
85
+ elif index % 7 in (0, 3) and (fact := next(fact_iter, None)) is not None:
86
+ plan.append({'kind': 'fact', **fact})
87
+ stated[fact['key']] = {**fact, 'turn': index}
88
+ continue
89
+ elif index % 11 == 5 and can_probe:
90
+ kind = 'probe'
91
+ elif index % 13 == 8 and stated and remaining > 5:
92
+ kind = 'correction'
93
+ else:
94
+ kind = 'distractor'
95
+
96
+ if kind == 'probe':
97
+ target = rng.choice(can_probe)
98
+ plan.append({'kind': 'probe', 'key': target['key'], 'value': target['value'],
99
+ 'distance': index - target['turn'],
100
+ 'text': rng.choice(PROBES).format(key=target['key'])})
101
+ elif kind == 'correction':
102
+ target = rng.choice(list(stated.values()))
103
+ new = _value(next(t for k, t, _ in FACT_POOL if k == target['key']), rng)
104
+ plan.append({'kind': 'correction', 'key': target['key'], 'old': target['value'],
105
+ 'value': new,
106
+ 'text': f'actually scratch that, the {target["key"]} is {new} now'})
107
+ stated[target['key']] = {**target, 'value': new, 'turn': index}
108
+ else:
109
+ plan.append({'kind': 'distractor', 'text': rng.choice(DISTRACTORS)})
110
+ return plan
111
+
112
+
113
+ def _hit(value: str, answer: str) -> bool:
114
+ """Whether the answer states the value, matched on its distinctive core.
115
+
116
+ Demanding the full phrase penalised correct answers on both systems ("the locker code is
117
+ 4805" failed against 'code 4805'), so the core — the last token, which carries the
118
+ identifier — is what must appear. Values are generated with 2+ digit cores, so incidental
119
+ collisions stay unlikely.
120
+ """
121
+
122
+ canon = lambda t: re.sub(r'[^a-z0-9]', '', t.lower()) # noqa: E731
123
+ core = value.split()[-1]
124
+ return canon(core) in canon(answer)
125
+
126
+
127
+ def run(args: argparse.Namespace) -> None:
128
+ from diffusion_lm.claims import SYSTEM, chat_prefix, ledger_line, ledger_notes, merge_notes
129
+ from diffusion_lm.reasoning_playground import ReasoningEngine
130
+ from diffusion_lm.train import resolve_device
131
+
132
+ engine = ReasoningEngine(args.checkpoint, args.tokenizer, resolve_device('auto'))
133
+ plan = build_script(args.turns, args.seed)
134
+ messages: list[dict] = []
135
+ records = []
136
+ # Appended per turn and flushed, so a tail -f (or a crash) sees every completed turn.
137
+ sink = args.output.open('w', encoding='utf-8')
138
+ for index, turn in enumerate(plan):
139
+ user_text = turn.get('text') or turn['phrasing'].format(value=turn['value'])
140
+ messages.append({'role': 'user', 'content': user_text})
141
+ older = merge_notes(ledger_notes(messages, args.keep))
142
+ window = messages[max(0, len(messages) - args.keep):]
143
+ prefix = chat_prefix(
144
+ [{'role': m['role'], 'content': m['content']} for m in window],
145
+ system=SYSTEM, extra=ledger_line(older),
146
+ )
147
+ blocks: list[tuple[int, str]] = []
148
+ answer = ''
149
+ begin = time.perf_counter()
150
+ for _, answer, _ in engine.stream_chat(
151
+ prefix, temperature=0.7, steps_per_block=args.steps,
152
+ max_answer_tokens=120, seed=args.seed * 1000 + index, blocks_out=blocks,
153
+ ):
154
+ pass
155
+ note = '; '.join(text for _, text in blocks if text)
156
+ messages.append({'role': 'assistant', 'content': answer.strip(), 'note': note})
157
+ record = {
158
+ 'turn': index, 'kind': turn['kind'], 'user': user_text,
159
+ 'notes': [text for _, text in blocks if text], 'ledger': older,
160
+ 'prefix_tokens': len(engine.tokenizer.encode(prefix, add_special_tokens=False).ids),
161
+ 'answer': answer.strip(), 'seconds': round(time.perf_counter() - begin, 2),
162
+ }
163
+ if turn['kind'] == 'probe':
164
+ record.update({'key': turn['key'], 'expected': turn['value'],
165
+ 'distance': turn['distance'],
166
+ 'hit': _hit(turn['value'], answer)})
167
+ if turn['kind'] in ('fact', 'correction'):
168
+ record.update({'key': turn['key'], 'value': turn['value']})
169
+ records.append(record)
170
+ sink.write(json.dumps(record, ensure_ascii=False) + '\n')
171
+ sink.flush()
172
+ flag = '' if turn['kind'] != 'probe' else (' HIT' if record['hit'] else ' MISS')
173
+ print(f'[{index + 1}/{len(plan)}] {turn["kind"]}{flag} {record["seconds"]}s '
174
+ f'ledger={len(older)}', flush=True)
175
+ print(f' U: {user_text}', flush=True)
176
+ if note:
177
+ print(f' T: {note}', flush=True)
178
+ print(f' A: {answer.strip()}', flush=True)
179
+ if turn['kind'] == 'probe':
180
+ print(f' >> esperado {turn["value"]} (distancia {turn["distance"]}) -> '
181
+ f'{"HIT" if record["hit"] else "MISS"}', flush=True)
182
+
183
+ sink.close()
184
+ probes = [r for r in records if r['kind'] == 'probe']
185
+ hits = sum(r['hit'] for r in probes)
186
+ print(f'\nrecall: {hits}/{len(probes)} ({hits / max(1, len(probes)):.0%})')
187
+
188
+
189
+ def run_ar(args: argparse.Namespace) -> None:
190
+ """Same scripted conversation through a plain AR baseline of the same size.
191
+
192
+ ``--context full`` gives the baseline the whole history (its native memory);
193
+ ``--context budget`` truncates the rendered prompt to the newest messages that fit the
194
+ same token budget the ledger system runs under, so both systems answer from equally many
195
+ prefix tokens and only the memory MECHANISM differs.
196
+ """
197
+
198
+ import torch
199
+ from transformers import AutoModelForCausalLM, AutoTokenizer
200
+
201
+ tokenizer = AutoTokenizer.from_pretrained(args.model_path)
202
+ model = AutoModelForCausalLM.from_pretrained(
203
+ args.model_path, torch_dtype=torch.bfloat16, device_map='cuda',
204
+ ).eval()
205
+ plan = build_script(args.turns, args.seed)
206
+ # Deliberately generous: the baselines get an explicit memory instruction, greedy decoding,
207
+ # and (with --thinking) their native reasoning mode. The comparison should show the ledger
208
+ # beating the baselines at their best, or it shows nothing.
209
+ system = ('You are a meticulous assistant. Remember every concrete detail the user '
210
+ 'mentions — codes, amounts, times, names, numbers. When asked to recall one, '
211
+ 'answer with the exact value.')
212
+ messages: list[dict] = []
213
+ records = []
214
+ try:
215
+ tokenizer.apply_chat_template(
216
+ [{'role': 'system', 'content': 'x'}, {'role': 'user', 'content': 'y'}],
217
+ tokenize=True, add_generation_prompt=True)
218
+ args._no_system = False
219
+ except Exception:
220
+ args._no_system = True
221
+ sink = args.output.open('w', encoding='utf-8')
222
+ for index, turn in enumerate(plan):
223
+ user_text = turn.get('text') or turn['phrasing'].format(value=turn['value'])
224
+ messages.append({'role': 'user', 'content': user_text})
225
+
226
+ def _assemble(tail: list[dict]) -> list[dict]:
227
+ if getattr(args, '_no_system', False):
228
+ head = dict(tail[0]) if tail else {'role': 'user', 'content': ''}
229
+ head['content'] = f'{system}\n\n{head["content"]}'
230
+ return [head] + [dict(m) for m in tail[1:]]
231
+ return [{'role': 'system', 'content': system}] + [dict(m) for m in tail]
232
+
233
+ chat = _assemble(messages)
234
+ if args.context == 'budget':
235
+ # Newest-first packing under the same prefix budget the ledger system uses.
236
+ kept: list[dict] = []
237
+ for message in reversed(messages):
238
+ candidate = _assemble([message, *kept])
239
+ rendered = tokenizer.apply_chat_template(
240
+ candidate, tokenize=True, add_generation_prompt=True,
241
+ enable_thinking=False,
242
+ )
243
+ # BatchEncoding in newer transformers: len() counts KEYS, which silently
244
+ # disabled the cap; count tokens explicitly.
245
+ ids = rendered if isinstance(rendered, list) else rendered['input_ids']
246
+ if len(ids) > args.budget:
247
+ break
248
+ kept = [message, *kept]
249
+ chat = _assemble(kept)
250
+ encoded = tokenizer.apply_chat_template(
251
+ chat, tokenize=True, add_generation_prompt=True, return_tensors='pt',
252
+ enable_thinking=args.thinking,
253
+ )
254
+ # Newer transformers return a BatchEncoding here rather than the bare tensor.
255
+ prompt_ids = (encoded if torch.is_tensor(encoded) else encoded['input_ids']).to(
256
+ model.device)
257
+ torch.manual_seed(args.seed * 1000 + index)
258
+ begin = time.perf_counter()
259
+ with torch.no_grad():
260
+ out = model.generate(
261
+ prompt_ids, max_new_tokens=640 if args.thinking else 120, do_sample=False,
262
+ pad_token_id=tokenizer.eos_token_id or tokenizer.pad_token_id,
263
+ )
264
+ answer = tokenizer.decode(out[0][prompt_ids.shape[1]:], skip_special_tokens=True)
265
+ answer = re.sub(r'(?s)<think>.*?(</think>|$)', '', answer).strip()
266
+ messages.append({'role': 'assistant', 'content': answer})
267
+ record = {
268
+ 'turn': index, 'kind': turn['kind'], 'user': user_text, 'notes': [], 'ledger': [],
269
+ 'prefix_tokens': int(prompt_ids.shape[1]), 'answer': answer,
270
+ 'seconds': round(time.perf_counter() - begin, 2),
271
+ }
272
+ if turn['kind'] == 'probe':
273
+ record.update({'key': turn['key'], 'expected': turn['value'],
274
+ 'distance': turn['distance'], 'hit': _hit(turn['value'], answer)})
275
+ if turn['kind'] in ('fact', 'correction'):
276
+ record.update({'key': turn['key'], 'value': turn['value']})
277
+ records.append(record)
278
+ sink.write(json.dumps(record, ensure_ascii=False) + '\n')
279
+ sink.flush()
280
+ flag = '' if turn['kind'] != 'probe' else (' HIT' if record['hit'] else ' MISS')
281
+ print(f'[{index + 1}/{len(plan)}] {turn["kind"]}{flag} {record["seconds"]}s '
282
+ f'prefix={record["prefix_tokens"]}', flush=True)
283
+ sink.close()
284
+ probes = [r for r in records if r['kind'] == 'probe']
285
+ hits = sum(r['hit'] for r in probes)
286
+ print(f'\nrecall: {hits}/{len(probes)} ({hits / max(1, len(probes)):.0%})')
287
+
288
+
289
+ TEMPLATE = '''<!doctype html><html><head><meta charset="utf-8">
290
+ <title>Ledger needle — __MODEL__</title><style>
291
+ :root { --bg:#11141a; --panel:#1a1f29; --text:#dde3ee; --dim:#8a93a6; --hit:#3fb96b;
292
+ --miss:#e05555; --fact:#4d8fd1; --corr:#d9a23c; --line:#2a3140; }
293
+ * { box-sizing:border-box; margin:0 } body { background:var(--bg); color:var(--text);
294
+ font:14px/1.5 ui-monospace,Menlo,monospace; padding:24px; }
295
+ h1 { font-size:18px; margin-bottom:4px } .sub { color:var(--dim); margin-bottom:18px }
296
+ .metrics { display:flex; gap:14px; flex-wrap:wrap; margin-bottom:18px }
297
+ .metric { background:var(--panel); border:1px solid var(--line); border-radius:8px;
298
+ padding:10px 16px } .metric b { font-size:20px; display:block }
299
+ .dist { margin-bottom:18px } .dist .row { display:flex; align-items:center; gap:8px;
300
+ margin:3px 0 } .dist .bar { height:14px; background:var(--hit); border-radius:3px }
301
+ .dist .bar.m { background:var(--miss) } .dist span { color:var(--dim); font-size:12px;
302
+ min-width:110px }
303
+ .wrap { display:grid; grid-template-columns:340px 1fr; gap:16px; align-items:start }
304
+ .timeline { max-height:75vh; overflow-y:auto; background:var(--panel);
305
+ border:1px solid var(--line); border-radius:8px }
306
+ .t { padding:7px 10px; border-bottom:1px solid var(--line); cursor:pointer;
307
+ display:flex; gap:8px; align-items:center } .t:hover,.t.sel { background:#242b38 }
308
+ .t .n { color:var(--dim); min-width:30px } .dot { width:9px; height:9px; border-radius:50%;
309
+ flex:none } .dot.distractor { background:var(--dim) } .dot.fact { background:var(--fact) }
310
+ .dot.correction { background:var(--corr) } .dot.probe.hit { background:var(--hit) }
311
+ .dot.probe.miss { background:var(--miss) } .t .txt { white-space:nowrap; overflow:hidden;
312
+ text-overflow:ellipsis; font-size:12px }
313
+ .detail { background:var(--panel); border:1px solid var(--line); border-radius:8px;
314
+ padding:16px; max-height:75vh; overflow-y:auto }
315
+ .detail h3 { font-size:13px; color:var(--dim); margin:14px 0 6px; text-transform:uppercase }
316
+ .detail h3:first-child { margin-top:0 }
317
+ .bubble { background:#242b38; border-radius:8px; padding:10px 12px; margin:4px 0 }
318
+ .note { color:#9fc4ea } .ledger-entry { display:inline-block; background:#242b38;
319
+ border:1px solid var(--line); border-radius:5px; padding:2px 8px; margin:2px;
320
+ font-size:12px } .verdict { padding:10px 12px; border-radius:8px; margin-top:6px }
321
+ .verdict.hit { background:#173523; border:1px solid var(--hit) }
322
+ .verdict.miss { background:#3a1d1d; border:1px solid var(--miss) }
323
+ .meta { color:var(--dim); font-size:12px; margin-top:12px }
324
+ </style></head><body>
325
+ <h1>Ledger needle</h1>
326
+ <div class="sub">__MODEL__ · __TURNS__ turnos · ventana de __KEEP__ mensajes ·
327
+ steps_per_block __STEPS__ · el prefijo nunca excede __MAXPREFIX__ tokens</div>
328
+ <div class="metrics" id="metrics"></div>
329
+ <div class="dist" id="dist"></div>
330
+ <div class="wrap"><div class="timeline" id="timeline"></div>
331
+ <div class="detail" id="detail">elegí un turno</div></div>
332
+ <script>
333
+ const DATA = __DATA__;
334
+ const probes = DATA.filter(r => r.kind === 'probe');
335
+ const hits = probes.filter(r => r.hit).length;
336
+ const corr = DATA.filter(r => r.kind === 'correction').length;
337
+ const maxTok = Math.max(...DATA.map(r => r.prefix_tokens));
338
+ const secs = DATA.reduce((a, r) => a + r.seconds, 0) / DATA.length;
339
+ document.getElementById('metrics').innerHTML = [
340
+ ['recall', hits + '/' + probes.length + ' (' + Math.round(100 * hits / probes.length) + '%)'],
341
+ ['hechos plantados', DATA.filter(r => r.kind === 'fact').length],
342
+ ['correcciones', corr], ['prefijo máx', maxTok + ' tok'],
343
+ ['media', secs.toFixed(1) + ' s/turno'],
344
+ ].map(([k, v]) => '<div class="metric"><b>' + v + '</b>' + k + '</div>').join('');
345
+ const buckets = [[3, 10], [11, 25], [26, 50], [51, 999]];
346
+ document.getElementById('dist').innerHTML = '<span style="color:var(--dim)">recall por distancia (turnos desde que el hecho se dijo):</span>' +
347
+ buckets.map(([lo, hi]) => {
348
+ const set = probes.filter(p => p.distance >= lo && p.distance <= hi);
349
+ if (!set.length) return '';
350
+ const h = set.filter(p => p.hit).length;
351
+ return '<div class="row"><span>' + lo + '–' + (hi > 100 ? '∞' : hi) + ' (' + h + '/' + set.length +
352
+ ')</span><div class="bar" style="width:' + (300 * h / set.length) + 'px"></div>' +
353
+ '<div class="bar m" style="width:' + (300 * (set.length - h) / set.length) + 'px"></div></div>';
354
+ }).join('');
355
+ const tl = document.getElementById('timeline');
356
+ DATA.forEach(r => {
357
+ const div = document.createElement('div');
358
+ div.className = 't'; div.dataset.turn = r.turn;
359
+ const cls = r.kind + (r.kind === 'probe' ? (r.hit ? ' hit' : ' miss') : '');
360
+ div.innerHTML = '<span class="n">' + (r.turn + 1) + '</span><span class="dot ' + cls +
361
+ '"></span><span class="txt">' + r.user.replace(/</g, '&lt;') + '</span>';
362
+ div.onclick = () => show(r, div); tl.appendChild(div);
363
+ });
364
+ function esc(t) { return String(t).replace(/</g, '&lt;'); }
365
+ function show(r, el) {
366
+ document.querySelectorAll('.t.sel').forEach(x => x.classList.remove('sel'));
367
+ el.classList.add('sel');
368
+ let h = '<h3>usuario (turno ' + (r.turn + 1) + ' · ' + r.kind + ')</h3><div class="bubble">' +
369
+ esc(r.user) + '</div>';
370
+ h += '<h3>ledger que vio el modelo (' + r.ledger.length + ' entradas, historia fuera de ventana)</h3>' +
371
+ (r.ledger.length ? r.ledger.map(e => '<span class="ledger-entry">' + esc(e) + '</span>').join('')
372
+ : '<span style="color:var(--dim)">vacío</span>');
373
+ h += '<h3>notas de este turno</h3>' + (r.notes.length
374
+ ? r.notes.map(n => '<div class="bubble note">' + esc(n) + '</div>').join('')
375
+ : '<span style="color:var(--dim)">sin pensamiento</span>');
376
+ h += '<h3>respuesta</h3><div class="bubble">' + esc(r.answer) + '</div>';
377
+ if (r.kind === 'probe') h += '<div class="verdict ' + (r.hit ? 'hit' : 'miss') + '">esperado: <b>' +
378
+ esc(r.expected) + '</b> · distancia ' + r.distance + ' turnos · ' + (r.hit ? 'RECUPERADO' : 'PERDIDO') + '</div>';
379
+ if (r.kind === 'correction') h += '<div class="verdict" style="border:1px solid var(--corr)">corrige ' +
380
+ esc(r.key) + ': ahora <b>' + esc(r.value) + '</b></div>';
381
+ h += '<div class="meta">prefijo ' + r.prefix_tokens + ' tokens · ' + r.seconds + ' s</div>';
382
+ document.getElementById('detail').innerHTML = h;
383
+ }
384
+ </script></body></html>'''
385
+
386
+
387
+ def _rescore(records: list[dict]) -> list[dict]:
388
+ """Verdicts are re-derived from the CURRENT rule, never trusted from the run.
389
+
390
+ The stored ``hit`` reflects whatever rule was live at run time; a scoring fix must reach
391
+ old runs, or the viewers keep showing correct answers marked as losses.
392
+ """
393
+
394
+ for record in records:
395
+ if 'expected' in record:
396
+ record['hit'] = _hit(record['expected'], record['answer'])
397
+ return records
398
+
399
+
400
+ def render(args: argparse.Namespace) -> None:
401
+ records = _rescore(
402
+ [json.loads(line) for line in args.input.open(encoding='utf-8') if line.strip()])
403
+ page = (TEMPLATE
404
+ .replace('__DATA__', json.dumps(records, ensure_ascii=False))
405
+ .replace('__MODEL__', html_lib.escape(args.model_name))
406
+ .replace('__TURNS__', str(len(records)))
407
+ .replace('__KEEP__', str(args.keep))
408
+ .replace('__STEPS__', str(args.steps))
409
+ .replace('__MAXPREFIX__', str(max(r['prefix_tokens'] for r in records))))
410
+ args.output.write_text(page, encoding='utf-8')
411
+ print(f'viewer -> {args.output}')
412
+
413
+
414
+ COMPARE_TEMPLATE = '''<!doctype html><html><head><meta charset="utf-8">
415
+ <title>Ledger needle — comparación</title><style>
416
+ :root { --bg:#11141a; --panel:#1a1f29; --text:#dde3ee; --dim:#8a93a6; --hit:#3fb96b;
417
+ --miss:#e05555; --line:#2a3140; --us:#16233a; }
418
+ * { box-sizing:border-box; margin:0 } body { background:var(--bg); color:var(--text);
419
+ font:14px/1.5 ui-monospace,Menlo,monospace; padding:24px; max-width:1240px; margin:0 auto }
420
+ h1 { font-size:18px } .sub { color:var(--dim); margin:4px 0 20px }
421
+ table { border-collapse:collapse; width:100%; margin-bottom:26px }
422
+ th, td { border:1px solid var(--line); padding:7px 10px; text-align:left; font-size:13px }
423
+ th { background:var(--panel); color:var(--dim); font-weight:normal; cursor:pointer;
424
+ user-select:none; white-space:nowrap }
425
+ th.sorted { color:var(--text) } th.sorted::after { content:' ↓' }
426
+ th.sorted.asc::after { content:' ↑' }
427
+ tr.us td { background:var(--us) }
428
+ td.hitcell { background:#173523; color:var(--hit); text-align:center; font-weight:bold }
429
+ td.misscell { background:#3a1d1d; color:var(--miss); text-align:center }
430
+ #matrix { width:auto } #matrix th, #matrix td { padding:5px 8px }
431
+ #matrix td.hitcell, #matrix td.misscell { width:36px; min-width:36px; padding:5px 0 }
432
+ #matrix th.sys { writing-mode:vertical-rl; transform:rotate(180deg); text-align:left;
433
+ vertical-align:bottom; max-height:190px; font-size:12px; cursor:default; padding:8px 4px }
434
+ #matrix th.sys.usc { color:#9fc4ea }
435
+ #matrix td.exp { white-space:nowrap }
436
+ .bar { display:inline-block; height:11px; background:var(--hit); border-radius:2px;
437
+ vertical-align:middle } .bar.m { background:var(--miss) }
438
+ h2 { font-size:14px; color:var(--dim); margin:22px 0 10px; text-transform:uppercase }
439
+ .caveats { background:var(--panel); border:1px solid var(--line); border-radius:8px;
440
+ padding:14px 18px; color:var(--dim); font-size:13px } .caveats li { margin:6px 0 0 18px }
441
+ .hint { color:var(--dim); font-size:12px; margin:-18px 0 10px }
442
+ </style></head><body>
443
+ <h1>Ledger needle — comparación hasta 1.5B</h1>
444
+ <div class="sub">100 turnos guionados (seed 21) · 15 hechos, 5 correcciones, 10 sondas a
445
+ distancia 3–96 · scoring por núcleo del valor, idéntico para todos · baselines con historia
446
+ completa, decoding greedy e instrucción explícita de memoria</div>
447
+ <table id="summary"><thead><tr>
448
+ <th data-k="label" data-t="s">sistema</th>
449
+ <th data-k="params" data-t="n">tamaño (B)</th>
450
+ <th data-k="hits" data-t="n">recall</th>
451
+ <th data-k="d1" data-t="n">d 3–10</th>
452
+ <th data-k="d2" data-t="n">d 11–50</th>
453
+ <th data-k="d3" data-t="n">d 51+</th>
454
+ <th data-k="total" data-t="n">test total (s)</th>
455
+ <th data-k="per" data-t="n">s/turno</th>
456
+ <th data-k="prefix" data-t="n">prefijo máx</th>
457
+ </tr></thead><tbody></tbody></table>
458
+ <div class="hint">click en una cabecera reordena; por defecto: más aciertos primero</div>
459
+ <h2>Sonda por sonda</h2>
460
+ __MATRIX__
461
+ <h2>Advertencias de lectura</h2>
462
+ <div class="caveats"><ul>
463
+ <li>Un solo seed y 10 sondas por sistema: las diferencias chicas son ruido; las grandes
464
+ sobreviven a ese margen.</li>
465
+ <li>La comparación mide el paquete mecanismo+entrenamiento: el sistema de ledger fue entrenado
466
+ en este registro conversacional y los baselines no. Muestra que el paquete funciona, no que el
467
+ mecanismo solo cause la diferencia.</li>
468
+ <li>Los baselines reciben la mayor ventaja razonable: historia completa en contexto, greedy,
469
+ instrucción de memoria, y el modo thinking nativo donde existe. El sistema de ledger corre con
470
+ sampling a temperatura 0.7 y un prefijo constante de ~500 tokens.</li>
471
+ <li>El guion es sintético y sus valores son arbitrarios por diseño; un valor sin sentido
472
+ ("day 97") puede penalizar a un modelo que se resiste a repetir datos absurdos.</li>
473
+ </ul></div>
474
+ <script>
475
+ const ROWS = __ROWS__;
476
+ const tbody = document.querySelector('#summary tbody');
477
+ let sortKey = 'hits', asc = false;
478
+ function fmt(r) {
479
+ return '<td><b>' + r.label + '</b></td><td>' + r.params.toFixed(2) + '</td>' +
480
+ '<td><b>' + r.hits + '/' + r.probes + '</b> <span class="bar" style="width:' + 6 * r.hits +
481
+ 'px"></span><span class="bar m" style="width:' + 6 * (r.probes - r.hits) + 'px"></span></td>' +
482
+ ['d1', 'd2', 'd3'].map(k => '<td>' + r[k] + '/' + r[k + 'n'] + '</td>').join('') +
483
+ '<td>' + r.total.toFixed(0) + '</td><td>' + r.per.toFixed(1) + '</td><td>' + r.prefix + '</td>';
484
+ }
485
+ function draw() {
486
+ const rows = [...ROWS].sort((a, b) => {
487
+ const va = a[sortKey], vb = b[sortKey];
488
+ const c = typeof va === 'string' ? va.localeCompare(vb) : va - vb;
489
+ return asc ? c : -c;
490
+ });
491
+ tbody.innerHTML = rows.map(r =>
492
+ '<tr' + (r.us ? ' class="us"' : '') + '>' + fmt(r) + '</tr>').join('');
493
+ document.querySelectorAll('#summary th').forEach(th => {
494
+ th.classList.toggle('sorted', th.dataset.k === sortKey);
495
+ th.classList.toggle('asc', th.dataset.k === sortKey && asc);
496
+ });
497
+ }
498
+ document.querySelectorAll('#summary th').forEach(th => th.onclick = () => {
499
+ if (sortKey === th.dataset.k) asc = !asc;
500
+ else { sortKey = th.dataset.k; asc = th.dataset.t === 's'; }
501
+ draw();
502
+ });
503
+ draw();
504
+ </script></body></html>'''
505
+
506
+
507
+ def compare(args: argparse.Namespace) -> None:
508
+ systems = []
509
+ for spec in args.inputs:
510
+ label, _, rest = spec.partition('=')
511
+ path, _, params = rest.partition('=')
512
+ records = _rescore(
513
+ [json.loads(line) for line in Path(path).open(encoding='utf-8') if line.strip()])
514
+ probes = [r for r in records if r['kind'] == 'probe']
515
+ row = {'label': html_lib.escape(label), 'params': float(params or 0),
516
+ 'probes': len(probes), 'hits': sum(r['hit'] for r in probes),
517
+ 'total': sum(r['seconds'] for r in records),
518
+ 'per': sum(r['seconds'] for r in records) / max(1, len(records)),
519
+ 'prefix': max(r['prefix_tokens'] for r in records),
520
+ 'us': bool(args.ours and label == args.ours)}
521
+ for name, (lo, hi) in (('d1', (3, 10)), ('d2', (11, 50)), ('d3', (51, 999))):
522
+ subset = [r for r in probes if lo <= r['distance'] <= hi]
523
+ row[name] = sum(r['hit'] for r in subset)
524
+ row[name + 'n'] = len(subset)
525
+ systems.append((label, records, row))
526
+
527
+ first_probes = [r for r in systems[0][1] if r['kind'] == 'probe']
528
+ head = ['<tr><th>turno</th><th>dist</th><th>esperado</th>']
529
+ head += [
530
+ f'<th class="sys{" usc" if args.ours and label == args.ours else ""}">'
531
+ f'{html_lib.escape(label)}</th>'
532
+ for label, _, _ in systems
533
+ ]
534
+ rows_html = []
535
+ for probe in first_probes:
536
+ row = [f'<td>{probe["turn"] + 1}</td><td>{probe["distance"]}</td>'
537
+ f'<td class="exp">{html_lib.escape(probe["expected"])}</td>']
538
+ for _, records, _ in systems:
539
+ match = next((r for r in records if r['kind'] == 'probe'
540
+ and r['turn'] == probe['turn']), None)
541
+ if match is None:
542
+ row.append('<td>—</td>')
543
+ else:
544
+ row.append('<td class="hitcell">✓</td>' if match['hit']
545
+ else '<td class="misscell">✗</td>')
546
+ rows_html.append(f'<tr>{"".join(row)}</tr>')
547
+ matrix = f'<table id="matrix">{"".join(head)}</tr>{"".join(rows_html)}</table>'
548
+
549
+ page = (COMPARE_TEMPLATE
550
+ .replace('__ROWS__', json.dumps([row for _, _, row in systems], ensure_ascii=False))
551
+ .replace('__MATRIX__', matrix))
552
+ args.output.write_text(page, encoding='utf-8')
553
+ print(f'comparison -> {args.output}')
554
+
555
+
556
+ def main() -> None:
557
+ parser = argparse.ArgumentParser(description=__doc__)
558
+ sub = parser.add_subparsers(dest='command', required=True)
559
+ runner = sub.add_parser('run', help='drive the scripted conversation through the engine')
560
+ runner.add_argument('--checkpoint', type=Path,
561
+ default=Path('outputs/qwen06b-genchat-sft/inference-latest.pt'))
562
+ runner.add_argument('--tokenizer', type=Path,
563
+ default=Path('artifacts/tokenizer-qwen3-adaptive.json'))
564
+ runner.add_argument('--turns', type=int, default=100)
565
+ runner.add_argument('--keep', type=int, default=4)
566
+ runner.add_argument('--steps', type=int, default=16)
567
+ runner.add_argument('--seed', type=int, default=7)
568
+ runner.add_argument('--output', type=Path, default=Path('bench-ledger-needle.jsonl'))
569
+ ar = sub.add_parser('run-ar', help='same script through a plain AR baseline')
570
+ ar.add_argument('--model-path', type=Path, default=Path('/root/models/Qwen3-0.6B'))
571
+ ar.add_argument('--turns', type=int, default=100)
572
+ ar.add_argument('--seed', type=int, default=7)
573
+ ar.add_argument('--context', choices=('full', 'budget'), default='full')
574
+ ar.add_argument('--budget', type=int, default=512)
575
+ ar.add_argument('--thinking', action='store_true',
576
+ help='enable the native reasoning mode where the chat template supports it')
577
+ ar.add_argument('--output', type=Path, default=Path('bench-needle-ar.jsonl'))
578
+
579
+ cmp_ = sub.add_parser('compare', help='comparison table across systems as HTML')
580
+ cmp_.add_argument('--inputs', nargs='+', required=True, metavar='LABEL=PATH[=PARAMS_B]')
581
+ cmp_.add_argument('--ours', default=None, help='label to highlight as our system')
582
+ cmp_.add_argument('--output', type=Path, default=Path('docs/ledger-needle-compare.html'))
583
+
584
+ view = sub.add_parser('render', help='write the self-contained HTML viewer')
585
+ view.add_argument('--input', type=Path, default=Path('bench-ledger-needle.jsonl'))
586
+ view.add_argument('--output', type=Path, default=Path('docs/ledger-needle.html'))
587
+ view.add_argument('--model-name', default='qwen06b-genchat-sft')
588
+ view.add_argument('--keep', type=int, default=4)
589
+ view.add_argument('--steps', type=int, default=16)
590
+ args = parser.parse_args()
591
+ if args.command == 'run':
592
+ run(args)
593
+ elif args.command == 'run-ar':
594
+ run_ar(args)
595
+ elif args.command == 'compare':
596
+ compare(args)
597
+ else:
598
+ render(args)
599
+
600
+
601
+ if __name__ == '__main__':
602
+ main()
bench/ledger-needle-compare.html ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html><html><head><meta charset="utf-8">
2
+ <title>Ledger needle — comparación</title><style>
3
+ :root { --bg:#11141a; --panel:#1a1f29; --text:#dde3ee; --dim:#8a93a6; --hit:#3fb96b;
4
+ --miss:#e05555; --line:#2a3140; --us:#16233a; }
5
+ * { box-sizing:border-box; margin:0 } body { background:var(--bg); color:var(--text);
6
+ font:14px/1.5 ui-monospace,Menlo,monospace; padding:24px; max-width:1240px; margin:0 auto }
7
+ h1 { font-size:18px } .sub { color:var(--dim); margin:4px 0 20px }
8
+ table { border-collapse:collapse; width:100%; margin-bottom:26px }
9
+ th, td { border:1px solid var(--line); padding:7px 10px; text-align:left; font-size:13px }
10
+ th { background:var(--panel); color:var(--dim); font-weight:normal; cursor:pointer;
11
+ user-select:none; white-space:nowrap }
12
+ th.sorted { color:var(--text) } th.sorted::after { content:' ↓' }
13
+ th.sorted.asc::after { content:' ↑' }
14
+ tr.us td { background:var(--us) }
15
+ td.hitcell { background:#173523; color:var(--hit); text-align:center; font-weight:bold }
16
+ td.misscell { background:#3a1d1d; color:var(--miss); text-align:center }
17
+ #matrix { width:auto } #matrix th, #matrix td { padding:5px 8px }
18
+ #matrix td.hitcell, #matrix td.misscell { width:36px; min-width:36px; padding:5px 0 }
19
+ #matrix th.sys { writing-mode:vertical-rl; transform:rotate(180deg); text-align:left;
20
+ vertical-align:bottom; max-height:190px; font-size:12px; cursor:default; padding:8px 4px }
21
+ #matrix th.sys.usc { color:#9fc4ea }
22
+ #matrix td.exp { white-space:nowrap }
23
+ .bar { display:inline-block; height:11px; background:var(--hit); border-radius:2px;
24
+ vertical-align:middle } .bar.m { background:var(--miss) }
25
+ h2 { font-size:14px; color:var(--dim); margin:22px 0 10px; text-transform:uppercase }
26
+ .caveats { background:var(--panel); border:1px solid var(--line); border-radius:8px;
27
+ padding:14px 18px; color:var(--dim); font-size:13px } .caveats li { margin:6px 0 0 18px }
28
+ .hint { color:var(--dim); font-size:12px; margin:-18px 0 10px }
29
+ </style></head><body>
30
+ <h1>Ledger needle — comparación hasta 1.5B</h1>
31
+ <div class="sub">100 turnos guionados (seed 21) · 15 hechos, 5 correcciones, 10 sondas a
32
+ distancia 3–96 · scoring por núcleo del valor, idéntico para todos · baselines con historia
33
+ completa, decoding greedy e instrucción explícita de memoria</div>
34
+ <table id="summary"><thead><tr>
35
+ <th data-k="label" data-t="s">sistema</th>
36
+ <th data-k="params" data-t="n">tamaño (B)</th>
37
+ <th data-k="hits" data-t="n">recall</th>
38
+ <th data-k="d1" data-t="n">d 3–10</th>
39
+ <th data-k="d2" data-t="n">d 11–50</th>
40
+ <th data-k="d3" data-t="n">d 51+</th>
41
+ <th data-k="total" data-t="n">test total (s)</th>
42
+ <th data-k="per" data-t="n">s/turno</th>
43
+ <th data-k="prefix" data-t="n">prefijo máx</th>
44
+ </tr></thead><tbody></tbody></table>
45
+ <div class="hint">click en una cabecera reordena; por defecto: más aciertos primero</div>
46
+ <h2>Sonda por sonda</h2>
47
+ <table id="matrix"><tr><th>turno</th><th>dist</th><th>esperado</th><th class="sys">Qwen2.5-1.5B-Instruct · full</th><th class="sys">Qwen3-0.6B + thinking · full</th><th class="sys usc">ledger qwen06b-genchat (nuestro)</th><th class="sys">Qwen2.5-0.5B-Instruct · full</th><th class="sys">SmolLM2-360M-Instruct · full</th><th class="sys">TinyLlama-1.1B-Chat · full</th><th class="sys">Qwen3-0.6B sin thinking · full</th><th class="sys">Qwen3-0.6B sin thinking · budget 512</th></tr><tr><td>6</td><td>5</td><td class="exp">code 4805</td><td class="hitcell">✓</td><td class="hitcell">✓</td><td class="hitcell">✓</td><td class="hitcell">✓</td><td class="hitcell">✓</td><td class="hitcell">✓</td><td class="hitcell">✓</td><td class="hitcell">✓</td></tr><tr><td>17</td><td>6</td><td class="exp">$6,400</td><td class="hitcell">✓</td><td class="hitcell">✓</td><td class="hitcell">✓</td><td class="hitcell">✓</td><td class="hitcell">✓</td><td class="hitcell">✓</td><td class="misscell">✗</td><td class="misscell">✗</td></tr><tr><td>28</td><td>3</td><td class="exp">7:15pm</td><td class="hitcell">✓</td><td class="hitcell">✓</td><td class="hitcell">✓</td><td class="misscell">✗</td><td class="hitcell">✓</td><td class="hitcell">✓</td><td class="misscell">✗</td><td class="misscell">✗</td></tr><tr><td>61</td><td>57</td><td class="exp">harbor858</td><td class="hitcell">✓</td><td class="hitcell">✓</td><td class="misscell">✗</td><td class="hitcell">✓</td><td class="misscell">✗</td><td class="misscell">✗</td><td class="misscell">✗</td><td class="misscell">✗</td></tr><tr><td>72</td><td>37</td><td class="exp">$4,000</td><td class="hitcell">✓</td><td class="hitcell">✓</td><td class="misscell">✗</td><td class="hitcell">✓</td><td class="hitcell">✓</td><td class="misscell">✗</td><td class="misscell">✗</td><td class="misscell">✗</td></tr><tr><td>83</td><td>47</td><td class="exp">day 97</td><td class="misscell">✗</td><td class="misscell">✗</td><td class="hitcell">✓</td><td class="misscell">✗</td><td class="misscell">✗</td><td class="misscell">✗</td><td class="misscell">✗</td><td class="misscell">✗</td></tr><tr><td>94</td><td>83</td><td class="exp">$6,400</td><td class="hitcell">✓</td><td class="hitcell">✓</td><td class="misscell">✗</td><td class="misscell">✗</td><td class="misscell">✗</td><td class="misscell">✗</td><td class="misscell">✗</td><td class="misscell">✗</td></tr><tr><td>98</td><td>59</td><td class="exp">room 411</td><td class="hitcell">✓</td><td class="misscell">✗</td><td class="hitcell">✓</td><td class="misscell">✗</td><td class="misscell">✗</td><td class="misscell">✗</td><td class="misscell">✗</td><td class="misscell">✗</td></tr><tr><td>99</td><td>60</td><td class="exp">room 411</td><td class="hitcell">✓</td><td class="misscell">✗</td><td class="hitcell">✓</td><td class="misscell">✗</td><td class="misscell">✗</td><td class="misscell">✗</td><td class="misscell">✗</td><td class="misscell">✗</td></tr><tr><td>100</td><td>96</td><td class="exp">harbor858</td><td class="hitcell">✓</td><td class="hitcell">✓</td><td class="misscell">✗</td><td class="misscell">✗</td><td class="misscell">✗</td><td class="misscell">✗</td><td class="misscell">✗</td><td class="misscell">✗</td></tr></table>
48
+ <h2>Advertencias de lectura</h2>
49
+ <div class="caveats"><ul>
50
+ <li>Un solo seed y 10 sondas por sistema: las diferencias chicas son ruido; las grandes
51
+ sobreviven a ese margen.</li>
52
+ <li>La comparación mide el paquete mecanismo+entrenamiento: el sistema de ledger fue entrenado
53
+ en este registro conversacional y los baselines no. Muestra que el paquete funciona, no que el
54
+ mecanismo solo cause la diferencia.</li>
55
+ <li>Los baselines reciben la mayor ventaja razonable: historia completa en contexto, greedy,
56
+ instrucción de memoria, y el modo thinking nativo donde existe. El sistema de ledger corre con
57
+ sampling a temperatura 0.7 y un prefijo constante de ~500 tokens.</li>
58
+ <li>El guion es sintético y sus valores son arbitrarios por diseño; un valor sin sentido
59
+ ("day 97") puede penalizar a un modelo que se resiste a repetir datos absurdos.</li>
60
+ </ul></div>
61
+ <script>
62
+ const ROWS = [{"label": "Qwen2.5-1.5B-Instruct · full", "params": 1.54, "probes": 10, "hits": 9, "total": 76.74, "per": 0.7674, "prefix": 2881, "us": false, "d1": 3, "d1n": 3, "d2": 1, "d2n": 2, "d3": 5, "d3n": 5}, {"label": "Qwen3-0.6B + thinking · full", "params": 0.6, "probes": 10, "hits": 7, "total": 1318.3, "per": 13.183, "prefix": 4450, "us": false, "d1": 3, "d1n": 3, "d2": 1, "d2n": 2, "d3": 3, "d3n": 5}, {"label": "ledger qwen06b-genchat (nuestro)", "params": 0.6, "probes": 10, "hits": 6, "total": 218.07, "per": 2.1807, "prefix": 625, "us": true, "d1": 3, "d1n": 3, "d2": 1, "d2n": 2, "d3": 2, "d3n": 5}, {"label": "Qwen2.5-0.5B-Instruct · full", "params": 0.49, "probes": 10, "hits": 4, "total": 110.34, "per": 1.1034, "prefix": 3908, "us": false, "d1": 2, "d1n": 3, "d2": 1, "d2n": 2, "d3": 1, "d3n": 5}, {"label": "SmolLM2-360M-Instruct · full", "params": 0.36, "probes": 10, "hits": 4, "total": 56.93, "per": 0.5693, "prefix": 2712, "us": false, "d1": 3, "d1n": 3, "d2": 1, "d2n": 2, "d3": 0, "d3n": 5}, {"label": "TinyLlama-1.1B-Chat · full", "params": 1.1, "probes": 10, "hits": 3, "total": 120.77, "per": 1.2077, "prefix": 5126, "us": false, "d1": 3, "d1n": 3, "d2": 0, "d2n": 2, "d3": 0, "d3n": 5}, {"label": "Qwen3-0.6B sin thinking · full", "params": 0.6, "probes": 10, "hits": 1, "total": 129.6, "per": 1.296, "prefix": 3722, "us": false, "d1": 1, "d1n": 3, "d2": 0, "d2n": 2, "d3": 0, "d3n": 5}, {"label": "Qwen3-0.6B sin thinking · budget 512", "params": 0.6, "probes": 10, "hits": 1, "total": 161.75, "per": 1.6175, "prefix": 512, "us": false, "d1": 1, "d1n": 3, "d2": 0, "d2n": 2, "d3": 0, "d3n": 5}];
63
+ const tbody = document.querySelector('#summary tbody');
64
+ let sortKey = 'hits', asc = false;
65
+ function fmt(r) {
66
+ return '<td><b>' + r.label + '</b></td><td>' + r.params.toFixed(2) + '</td>' +
67
+ '<td><b>' + r.hits + '/' + r.probes + '</b> <span class="bar" style="width:' + 6 * r.hits +
68
+ 'px"></span><span class="bar m" style="width:' + 6 * (r.probes - r.hits) + 'px"></span></td>' +
69
+ ['d1', 'd2', 'd3'].map(k => '<td>' + r[k] + '/' + r[k + 'n'] + '</td>').join('') +
70
+ '<td>' + r.total.toFixed(0) + '</td><td>' + r.per.toFixed(1) + '</td><td>' + r.prefix + '</td>';
71
+ }
72
+ function draw() {
73
+ const rows = [...ROWS].sort((a, b) => {
74
+ const va = a[sortKey], vb = b[sortKey];
75
+ const c = typeof va === 'string' ? va.localeCompare(vb) : va - vb;
76
+ return asc ? c : -c;
77
+ });
78
+ tbody.innerHTML = rows.map(r =>
79
+ '<tr' + (r.us ? ' class="us"' : '') + '>' + fmt(r) + '</tr>').join('');
80
+ document.querySelectorAll('#summary th').forEach(th => {
81
+ th.classList.toggle('sorted', th.dataset.k === sortKey);
82
+ th.classList.toggle('asc', th.dataset.k === sortKey && asc);
83
+ });
84
+ }
85
+ document.querySelectorAll('#summary th').forEach(th => th.onclick = () => {
86
+ if (sortKey === th.dataset.k) asc = !asc;
87
+ else { sortKey = th.dataset.k; asc = th.dataset.t === 's'; }
88
+ draw();
89
+ });
90
+ draw();
91
+ </script></body></html>
bench/ledger-needle-ours.html ADDED
The diff for this file is too large to render. See raw diff
 
bench/results-ledger-ours.jsonl ADDED
The diff for this file is too large to render. See raw diff
 
bench/results-qwen25-05b-full.jsonl ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {"turn": 0, "kind": "fact", "user": "the gym locker is code 4805", "notes": [], "ledger": [], "prefix_tokens": 59, "answer": "The code for the gym locker is 4805.", "seconds": 2.56, "key": "locker code", "value": "code 4805"}
2
+ {"turn": 1, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 87, "answer": "The street has been repaved.", "seconds": 0.26}
3
+ {"turn": 2, "kind": "distractor", "user": "what a week, honestly", "notes": [], "ledger": [], "prefix_tokens": 109, "answer": "A week can vary greatly depending on many factors such as your location, personal habits, and current circumstances. It's important to remember that while a week may seem like a long time in some contexts, it can also be a short period of time in others.", "seconds": 2.21}
4
+ {"turn": 3, "kind": "fact", "user": "cabin wifi password is harbor858", "notes": [], "ledger": [], "prefix_tokens": 180, "answer": "The password for the cabin Wi-Fi is \"harbor858\".", "seconds": 0.58, "key": "wifi password", "value": "harbor858"}
5
+ {"turn": 4, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 210, "answer": "The street has been repaved.", "seconds": 0.36}
6
+ {"turn": 5, "kind": "probe", "user": "quick — the locker code?", "notes": [], "ledger": [], "prefix_tokens": 233, "answer": "The locker code is 4805.", "seconds": 0.43, "key": "locker code", "expected": "code 4805", "distance": 5, "hit": true}
7
+ {"turn": 6, "kind": "distractor", "user": "finally finished that book", "notes": [], "ledger": [], "prefix_tokens": 257, "answer": "I'm sorry, but I don't have enough context to provide an accurate response about what you're asking. Could you please provide more information or clarify your question?", "seconds": 3.84}
8
+ {"turn": 7, "kind": "fact", "user": "we always park at spot B19", "notes": [], "ledger": [], "prefix_tokens": 308, "answer": "Spot B19 has always been reserved.", "seconds": 0.38, "key": "parking spot", "value": "spot B19"}
9
+ {"turn": 8, "kind": "correction", "user": "actually scratch that, the locker code is code 1688 now", "notes": [], "ledger": [], "prefix_tokens": 342, "answer": "The new locker code for the gym locker is 1688.", "seconds": 0.67, "key": "locker code", "value": "code 1688"}
10
+ {"turn": 9, "kind": "distractor", "user": "the cat knocked over a plant", "notes": [], "ledger": [], "prefix_tokens": 373, "answer": "The cat knocked over a plant.", "seconds": 0.33}
11
+ {"turn": 10, "kind": "fact", "user": "the deposit for the venue was $6,400", "notes": [], "ledger": [], "prefix_tokens": 402, "answer": "The deposit for the venue was $6,400.", "seconds": 0.54, "key": "deposit", "value": "$6,400"}
12
+ {"turn": 11, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 432, "answer": "If your phone battery is dying quickly, it could be due to several reasons including low charge level, overuse, or damage from extreme temperatures. To troubleshoot this issue, try charging your phone fully before using it again, consider swapping out batteries if possible, and ensure your phone is not exposed to extreme temperatures. If the problem persists, it might be best to consult a professional technician who can diagnose and repair any issues with your device.", "seconds": 3.77}
13
+ {"turn": 12, "kind": "distractor", "user": "saw a great documentary yesterday", "notes": [], "ledger": [], "prefix_tokens": 536, "answer": "Yesterday, I saw a great documentary.", "seconds": 0.49}
14
+ {"turn": 13, "kind": "distractor", "user": "i love this weather lately", "notes": [], "ledger": [], "prefix_tokens": 559, "answer": "I see! The recent weather conditions have certainly made things interesting. How can I assist further today?", "seconds": 1.09}
15
+ {"turn": 14, "kind": "fact", "user": "tomas' new teacher is ms. duarte", "notes": [], "ledger": [], "prefix_tokens": 599, "answer": "Ms. Duarte is Tomás' new teacher.", "seconds": 0.59, "key": "kid teacher", "value": "ms. duarte"}
16
+ {"turn": 15, "kind": "distractor", "user": "did you catch the game last night?", "notes": [], "ledger": [], "prefix_tokens": 628, "answer": "Yes, I did catch the game last night.", "seconds": 0.57}
17
+ {"turn": 16, "kind": "probe", "user": "remind me of the deposit?", "notes": [], "ledger": [], "prefix_tokens": 655, "answer": "The deposit for the venue was $6,400.", "seconds": 0.69, "key": "deposit", "expected": "$6,400", "distance": 6, "hit": true}
18
+ {"turn": 17, "kind": "fact", "user": "the plumber quoted $4,900 for the bathroom", "notes": [], "ledger": [], "prefix_tokens": 690, "answer": "The plumber estimated the cost for the bathroom would be $4,900.", "seconds": 0.82, "key": "plumber quote", "value": "$4,900"}
19
+ {"turn": 18, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 724, "answer": "If your phone battery is dying quickly, it could be due to several reasons including low charge level, overuse, or damage from extreme temperatures. To troubleshoot this issue, try charging your phone fully before using it again, consider swapping out batteries if possible, and ensure your phone is not exposed to extreme temperatures. If the problem persists, it might be best to consult a professional technician who can diagnose and repair any issues with your device.", "seconds": 4.16}
20
+ {"turn": 19, "kind": "distractor", "user": "traffic was unreal this morning", "notes": [], "ledger": [], "prefix_tokens": 827, "answer": "Traffic this morning was very heavy.", "seconds": 0.35}
21
+ {"turn": 20, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 853, "answer": "The neighbor is renovating again, which means there will be increased noise levels.", "seconds": 0.77}
22
+ {"turn": 21, "kind": "fact", "user": "our train leaves from platform 58", "notes": [], "ledger": [], "prefix_tokens": 887, "answer": "Our train departs from Platform 58.", "seconds": 0.51, "key": "train platform", "value": "platform 58"}
23
+ {"turn": 22, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 912, "answer": "The street has been repaved.", "seconds": 0.44}
24
+ {"turn": 23, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 934, "answer": "Coffee prices here are becoming increasingly expensive.", "seconds": 0.48}
25
+ {"turn": 24, "kind": "fact", "user": "dentist moved me to 7:15pm", "notes": [], "ledger": [], "prefix_tokens": 963, "answer": "The dentist moved you to 7:15 PM.", "seconds": 0.56, "key": "dentist slot", "value": "7:15pm"}
26
+ {"turn": 25, "kind": "distractor", "user": "saw a great documentary yesterday", "notes": [], "ledger": [], "prefix_tokens": 991, "answer": "Yesterday, I saw a great documentary.", "seconds": 0.53}
27
+ {"turn": 26, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 1016, "answer": "If your phone battery is dying quickly, it could be due to several reasons including low charge level, overuse, or damage from extreme temperatures. To troubleshoot this issue, try charging your phone fully before using it again, consider swapping out batteries if possible, and ensure your phone is not exposed to extreme temperatures. If the problem persists, it might be best to consult a professional technician who can diagnose and repair any issues with your device.", "seconds": 4.53}
28
+ {"turn": 27, "kind": "probe", "user": "quick — the dentist slot?", "notes": [], "ledger": [], "prefix_tokens": 1120, "answer": "The dentist slot is located on the right side of the door.", "seconds": 0.61, "key": "dentist slot", "expected": "7:15pm", "distance": 3, "hit": false}
29
+ {"turn": 28, "kind": "fact", "user": "doctor changed the dose to 27mg", "notes": [], "ledger": [], "prefix_tokens": 1152, "answer": "The doctor has changed the dose to 27 mg.", "seconds": 0.67, "key": "meds dose", "value": "27mg"}
30
+ {"turn": 29, "kind": "distractor", "user": "might go for a walk later", "notes": [], "ledger": [], "prefix_tokens": 1180, "answer": "It sounds like you might decide to go for a walk later.", "seconds": 0.76}
31
+ {"turn": 30, "kind": "distractor", "user": "finally finished that book", "notes": [], "ledger": [], "prefix_tokens": 1207, "answer": "I finally finished that book.", "seconds": 0.34}
32
+ {"turn": 31, "kind": "fact", "user": "the couch is order ZT6045, keep it handy", "notes": [], "ledger": [], "prefix_tokens": 1237, "answer": "The couch is ordered as ZT6045. Keep it handy.", "seconds": 0.97, "key": "order number", "value": "order ZT6045"}
33
+ {"turn": 32, "kind": "distractor", "user": "what a week, honestly", "notes": [], "ledger": [], "prefix_tokens": 1268, "answer": "A week can vary greatly depending on many factors such as your location, personal habits, and current circumstances. It's important to remember that while a week may seem like a long time in some contexts, it can also be a short period of time in others.", "seconds": 2.52}
34
+ {"turn": 33, "kind": "distractor", "user": "my back hurts from the gym", "notes": [], "ledger": [], "prefix_tokens": 1336, "answer": "I'm sorry to hear that you're experiencing back pain from your gym workout. It's important to take care of your body after physical activity, especially when it comes to injuries like back pain. You might want to consider taking a break from exercise and focusing on stretching and strengthening exercises instead. Additionally, making sure to get plenty of rest and hydration can help alleviate any discomfort. If the pain persists or worsens, it's recommended to consult with a healthcare provider for proper evaluation and treatment.", "seconds": 4.93}
35
+ {"turn": 34, "kind": "correction", "user": "actually scratch that, the plumber quote is $4,000 now", "notes": [], "ledger": [], "prefix_tokens": 1459, "answer": "The plumber estimate for the bathroom is $4,000.", "seconds": 0.74, "key": "plumber quote", "value": "$4,000"}
36
+ {"turn": 35, "kind": "fact", "user": "landlord wants rent by day 97 each month", "notes": [], "ledger": [], "prefix_tokens": 1494, "answer": "The landlord wants the rent to be paid monthly starting from Day 97.", "seconds": 0.88, "key": "rent due", "value": "day 97"}
37
+ {"turn": 36, "kind": "distractor", "user": "the cat knocked over a plant", "notes": [], "ledger": [], "prefix_tokens": 1526, "answer": "The cat knocked over a plant.", "seconds": 0.42}
38
+ {"turn": 37, "kind": "distractor", "user": "traffic was unreal this morning", "notes": [], "ledger": [], "prefix_tokens": 1548, "answer": "Traffic this morning was very heavy.", "seconds": 0.38}
39
+ {"turn": 38, "kind": "fact", "user": "we got room 411 at the hotel", "notes": [], "ledger": [], "prefix_tokens": 1575, "answer": "We got room 411 at the hotel.", "seconds": 0.58, "key": "hotel room", "value": "room 411"}
40
+ {"turn": 39, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 1605, "answer": "The neighbor is renovating again, which means there will be increased noise levels.", "seconds": 0.8}
41
+ {"turn": 40, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 1636, "answer": "Coffee prices here are becoming increasingly expensive.", "seconds": 0.4}
42
+ {"turn": 41, "kind": "distractor", "user": "i love this weather lately", "notes": [], "ledger": [], "prefix_tokens": 1659, "answer": "I see! The recent weather conditions have certainly made things interesting. How can I assist further today?", "seconds": 0.91}
43
+ {"turn": 42, "kind": "fact", "user": "boarding is at gate E17", "notes": [], "ledger": [], "prefix_tokens": 1696, "answer": "The boarding is at Gate 17.", "seconds": 0.44, "key": "boarding gate", "value": "gate E17"}
44
+ {"turn": 43, "kind": "distractor", "user": "what a week, honestly", "notes": [], "ledger": [], "prefix_tokens": 1720, "answer": "A week can vary greatly depending on many factors such as your location, personal habits, and current circumstances. It's important to remember that while a week may seem like a long time in some contexts, it can also be a short period of time in others.", "seconds": 2.35}
45
+ {"turn": 44, "kind": "distractor", "user": "the cat knocked over a plant", "notes": [], "ledger": [], "prefix_tokens": 1788, "answer": "The cat knocked over a plant.", "seconds": 0.41}
46
+ {"turn": 45, "kind": "fact", "user": "my sister lands on flight AL7371, write that down", "notes": [], "ledger": [], "prefix_tokens": 1819, "answer": "My sister landed on Flight AL7371.", "seconds": 0.51, "key": "sister flight", "value": "flight AL7371"}
47
+ {"turn": 46, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 1845, "answer": "Coffee prices here are becoming increasingly expensive.", "seconds": 0.49}
48
+ {"turn": 47, "kind": "correction", "user": "actually scratch that, the sister flight is flight BL4905 now", "notes": [], "ledger": [], "prefix_tokens": 1878, "answer": "The sister flight is now flight BL4905.", "seconds": 0.65, "key": "sister flight", "value": "flight BL4905"}
49
+ {"turn": 48, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 1905, "answer": "Coffee prices here are becoming increasingly expensive.", "seconds": 0.43}
50
+ {"turn": 49, "kind": "fact", "user": "the rental has plate HJC-988", "notes": [], "ledger": [], "prefix_tokens": 1933, "answer": "The rental has a plate number HJC-988.", "seconds": 0.68, "key": "car plate", "value": "HJC-988"}
51
+ {"turn": 50, "kind": "distractor", "user": "my back hurts from the gym", "notes": [], "ledger": [], "prefix_tokens": 1962, "answer": "I'm sorry to hear that you're experiencing back pain from your gym workout. It's important to take care of your body after physical activity, especially when it comes to injuries like back pain. You might want to consider taking a break from exercise and focusing on stretching and strengthening exercises instead. Additionally, making sure to get plenty of rest and hydration can help alleviate any discomfort. If the pain persists or worsens, it's recommended to consult with a healthcare provider for proper evaluation and treatment.", "seconds": 4.17}
52
+ {"turn": 51, "kind": "distractor", "user": "finally finished that book", "notes": [], "ledger": [], "prefix_tokens": 2074, "answer": "I finally finished that book.", "seconds": 0.26}
53
+ {"turn": 52, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 2095, "answer": "Coffee prices here are becoming increasingly expensive.", "seconds": 0.44}
54
+ {"turn": 53, "kind": "distractor", "user": "work was chaos today", "notes": [], "ledger": [], "prefix_tokens": 2117, "answer": "Today's work was chaotic.", "seconds": 0.29}
55
+ {"turn": 54, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 2142, "answer": "The neighbor is renovating again, which means there will be increased noise levels.", "seconds": 0.71}
56
+ {"turn": 55, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 2177, "answer": "The neighbor is renovating again, which means there will be increased noise levels.", "seconds": 0.72}
57
+ {"turn": 56, "kind": "distractor", "user": "did you catch the game last night?", "notes": [], "ledger": [], "prefix_tokens": 2211, "answer": "Yes, I did catch the game last night.", "seconds": 0.43}
58
+ {"turn": 57, "kind": "distractor", "user": "traffic was unreal this morning", "notes": [], "ledger": [], "prefix_tokens": 2236, "answer": "Traffic this morning was very heavy.", "seconds": 0.36}
59
+ {"turn": 58, "kind": "distractor", "user": "traffic was unreal this morning", "notes": [], "ledger": [], "prefix_tokens": 2258, "answer": "Traffic this morning was very heavy.", "seconds": 0.39}
60
+ {"turn": 59, "kind": "distractor", "user": "what a week, honestly", "notes": [], "ledger": [], "prefix_tokens": 2280, "answer": "A week can vary greatly depending on many factors such as your location, personal habits, and current circumstances. It's important to remember that while a week may seem like a long time in some contexts, it can also be a short period of time in others.", "seconds": 2.4}
61
+ {"turn": 60, "kind": "probe", "user": "wait, what was the wifi password again?", "notes": [], "ledger": [], "prefix_tokens": 2351, "answer": "The password for the Wi-Fi network was \"harbor858\".", "seconds": 0.71, "key": "wifi password", "expected": "harbor858", "distance": 57, "hit": true}
62
+ {"turn": 61, "kind": "distractor", "user": "finally finished that book", "notes": [], "ledger": [], "prefix_tokens": 2380, "answer": "I finally finished that book.", "seconds": 0.35}
63
+ {"turn": 62, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 2401, "answer": "The street has been repaved.", "seconds": 0.33}
64
+ {"turn": 63, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 2423, "answer": "The street has been repaved.", "seconds": 0.4}
65
+ {"turn": 64, "kind": "distractor", "user": "saw a great documentary yesterday", "notes": [], "ledger": [], "prefix_tokens": 2446, "answer": "Yesterday, I saw a great documentary.", "seconds": 0.36}
66
+ {"turn": 65, "kind": "distractor", "user": "what a week, honestly", "notes": [], "ledger": [], "prefix_tokens": 2469, "answer": "A week can vary greatly depending on many factors such as your location, personal habits, and current circumstances. It's important to remember that while a week may seem like a long time in some contexts, it can also be a short period of time in others.", "seconds": 2.23}
67
+ {"turn": 66, "kind": "distractor", "user": "thinking of making pasta tonight", "notes": [], "ledger": [], "prefix_tokens": 2536, "answer": "Thinking of making pasta tonight sounds delicious! What type of pasta do you prefer?", "seconds": 0.83}
68
+ {"turn": 67, "kind": "distractor", "user": "work was chaos today", "notes": [], "ledger": [], "prefix_tokens": 2566, "answer": "Today's work was chaotic.", "seconds": 0.35}
69
+ {"turn": 68, "kind": "distractor", "user": "might go for a walk later", "notes": [], "ledger": [], "prefix_tokens": 2588, "answer": "It sounds like you might decide to go for a walk later.", "seconds": 0.64}
70
+ {"turn": 69, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 2620, "answer": "The neighbor is renovating again, which means there will be increased noise levels.", "seconds": 0.73}
71
+ {"turn": 70, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 2651, "answer": "The street has been repaved.", "seconds": 0.4}
72
+ {"turn": 71, "kind": "probe", "user": "wait, what was the plumber quote again?", "notes": [], "ledger": [], "prefix_tokens": 2677, "answer": "The plumber quote for the bathroom is $4,000.", "seconds": 0.78, "key": "plumber quote", "expected": "$4,000", "distance": 37, "hit": true}
73
+ {"turn": 72, "kind": "distractor", "user": "traffic was unreal this morning", "notes": [], "ledger": [], "prefix_tokens": 2706, "answer": "Traffic this morning was very heavy.", "seconds": 0.41}
74
+ {"turn": 73, "kind": "correction", "user": "actually scratch that, the parking spot is spot E16 now", "notes": [], "ledger": [], "prefix_tokens": 2736, "answer": "The parking spot is now spot E16.", "seconds": 0.62, "key": "parking spot", "value": "spot E16"}
75
+ {"turn": 74, "kind": "distractor", "user": "i love this weather lately", "notes": [], "ledger": [], "prefix_tokens": 2761, "answer": "I see! The recent weather conditions have certainly made things interesting. How can I assist further today?", "seconds": 1.26}
76
+ {"turn": 75, "kind": "distractor", "user": "craving something sweet", "notes": [], "ledger": [], "prefix_tokens": 2795, "answer": "You might enjoy something sweet, such as chocolate cake or cookies.", "seconds": 0.79}
77
+ {"turn": 76, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 2827, "answer": "The neighbor is renovating again, which means there will be increased noise levels.", "seconds": 1.0}
78
+ {"turn": 77, "kind": "distractor", "user": "thinking of making pasta tonight", "notes": [], "ledger": [], "prefix_tokens": 2858, "answer": "Thinking of making pasta tonight sounds delicious! What type of pasta do you prefer?", "seconds": 0.9}
79
+ {"turn": 78, "kind": "distractor", "user": "did you catch the game last night?", "notes": [], "ledger": [], "prefix_tokens": 2892, "answer": "Yes, I did catch the game last night.", "seconds": 0.55}
80
+ {"turn": 79, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 2919, "answer": "If your phone battery is dying quickly, it could be due to several reasons including low charge level, overuse, or damage from extreme temperatures. To troubleshoot this issue, try charging your phone fully before using it again, consider swapping out batteries if possible, and ensure your phone is not exposed to extreme temperatures. If the problem persists, it might be best to consult a professional technician who can diagnose and repair any issues with your device.", "seconds": 4.05}
81
+ {"turn": 80, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 3024, "answer": "If your phone battery is dying quickly, it could be due to several reasons including low charge level, overuse, or damage from extreme temperatures. To troubleshoot this issue, try charging your phone fully before using it again, consider swapping out batteries if possible, and ensure your phone is not exposed to extreme temperatures. If the problem persists, it might be best to consult a professional technician who can diagnose and repair any issues with your device.", "seconds": 4.06}
82
+ {"turn": 81, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 3129, "answer": "If your phone battery is dying quickly, it could be due to several reasons including low charge level, overuse, or damage from extreme temperatures. To troubleshoot this issue, try charging your phone fully before using it again, consider swapping out batteries if possible, and ensure your phone is not exposed to extreme temperatures. If the problem persists, it might be best to consult a professional technician who can diagnose and repair any issues with your device.", "seconds": 3.73}
83
+ {"turn": 82, "kind": "probe", "user": "i forgot the rent due, what was it?", "notes": [], "ledger": [], "prefix_tokens": 3237, "answer": "I'm sorry to hear that you forgot the rent due. Please let me know how much you need to pay so we can proceed with the payment process.", "seconds": 1.27, "key": "rent due", "expected": "day 97", "distance": 47, "hit": false}
84
+ {"turn": 83, "kind": "distractor", "user": "thinking of making pasta tonight", "notes": [], "ledger": [], "prefix_tokens": 3283, "answer": "Thinking of making pasta tonight sounds delicious! What type of pasta do you prefer?", "seconds": 0.64}
85
+ {"turn": 84, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 3314, "answer": "Coffee prices here are becoming increasingly expensive.", "seconds": 0.43}
86
+ {"turn": 85, "kind": "distractor", "user": "thinking of making pasta tonight", "notes": [], "ledger": [], "prefix_tokens": 3337, "answer": "Thinking of making pasta tonight sounds delicious! What type of pasta do you prefer?", "seconds": 0.93}
87
+ {"turn": 86, "kind": "correction", "user": "actually scratch that, the plumber quote is $1,400 now", "notes": [], "ledger": [], "prefix_tokens": 3378, "answer": "The plumber estimate for the bathroom is $1,400.", "seconds": 0.62, "key": "plumber quote", "value": "$1,400"}
88
+ {"turn": 87, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 3409, "answer": "If your phone battery is dying quickly, it could be due to several reasons including low charge level, overuse, or damage from extreme temperatures. To troubleshoot this issue, try charging your phone fully before using it again, consider swapping out batteries if possible, and ensure your phone is not exposed to extreme temperatures. If the problem persists, it might be best to consult a professional technician who can diagnose and repair any issues with your device.", "seconds": 4.0}
89
+ {"turn": 88, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 3512, "answer": "Coffee prices here are becoming increasingly expensive.", "seconds": 0.61}
90
+ {"turn": 89, "kind": "distractor", "user": "finally finished that book", "notes": [], "ledger": [], "prefix_tokens": 3534, "answer": "I finally finished that book.", "seconds": 0.38}
91
+ {"turn": 90, "kind": "distractor", "user": "my back hurts from the gym", "notes": [], "ledger": [], "prefix_tokens": 3556, "answer": "I'm sorry to hear that you're experiencing back pain from your gym workout. It's important to take care of your body after physical activity, especially when it comes to injuries like back pain. You might want to consider taking a break from exercise and focusing on stretching and strengthening exercises instead. Additionally, making sure to get plenty of rest and hydration can help alleviate any discomfort. If the pain persists or worsens, it's recommended to consult with a healthcare provider for proper evaluation and treatment.", "seconds": 4.39}
92
+ {"turn": 91, "kind": "distractor", "user": "did you catch the game last night?", "notes": [], "ledger": [], "prefix_tokens": 3672, "answer": "Yes, I did catch the game last night.", "seconds": 0.61}
93
+ {"turn": 92, "kind": "distractor", "user": "work was chaos today", "notes": [], "ledger": [], "prefix_tokens": 3696, "answer": "Today's work was chaotic.", "seconds": 0.44}
94
+ {"turn": 93, "kind": "probe", "user": "i forgot the deposit, what was it?", "notes": [], "ledger": [], "prefix_tokens": 3721, "answer": "I'm sorry to hear that you forgot the deposit. Please let me know how much you need to pay so we can proceed with the payment process.", "seconds": 1.65, "key": "deposit", "expected": "$6,400", "distance": 83, "hit": false}
95
+ {"turn": 94, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 3766, "answer": "Coffee prices here are becoming increasingly expensive.", "seconds": 0.4}
96
+ {"turn": 95, "kind": "distractor", "user": "craving something sweet", "notes": [], "ledger": [], "prefix_tokens": 3788, "answer": "You might enjoy something sweet, such as chocolate cake or cookies.", "seconds": 0.82}
97
+ {"turn": 96, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 3820, "answer": "The neighbor is renovating again, which means there will be increased noise levels.", "seconds": 0.99}
98
+ {"turn": 97, "kind": "probe", "user": "remind me of the hotel room?", "notes": [], "ledger": [], "prefix_tokens": 3854, "answer": "The hotel room is room 58.", "seconds": 0.48, "key": "hotel room", "expected": "room 411", "distance": 59, "hit": false}
99
+ {"turn": 98, "kind": "probe", "user": "quick — the hotel room?", "notes": [], "ledger": [], "prefix_tokens": 3879, "answer": "The hotel room is room 58.", "seconds": 0.47, "key": "hotel room", "expected": "room 411", "distance": 60, "hit": false}
100
+ {"turn": 99, "kind": "probe", "user": "i forgot the wifi password, what was it?", "notes": [], "ledger": [], "prefix_tokens": 3908, "answer": "I'm sorry to hear that you forgot the wifi password. Please let me know how much you need to pay so we can proceed with the payment process.", "seconds": 1.18, "key": "wifi password", "expected": "harbor858", "distance": 96, "hit": false}
bench/results-qwen25-15b-full.jsonl ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {"turn": 0, "kind": "fact", "user": "the gym locker is code 4805", "notes": [], "ledger": [], "prefix_tokens": 59, "answer": "The gym locker code you provided is 4805.", "seconds": 6.46, "key": "locker code", "value": "code 4805"}
2
+ {"turn": 1, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 87, "answer": "They repaved your street.", "seconds": 0.46}
3
+ {"turn": 2, "kind": "distractor", "user": "what a week, honestly", "notes": [], "ledger": [], "prefix_tokens": 108, "answer": "It sounds like you had an eventful or challenging week.", "seconds": 0.75}
4
+ {"turn": 3, "kind": "fact", "user": "cabin wifi password is harbor858", "notes": [], "ledger": [], "prefix_tokens": 139, "answer": "The cabin's WiFi password is \"harbor858\".", "seconds": 0.89, "key": "wifi password", "value": "harbor858"}
5
+ {"turn": 4, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 167, "answer": "They repaved your street.", "seconds": 0.43}
6
+ {"turn": 5, "kind": "probe", "user": "quick — the locker code?", "notes": [], "ledger": [], "prefix_tokens": 189, "answer": "The gym locker code is 4805.", "seconds": 0.73, "key": "locker code", "expected": "code 4805", "distance": 5, "hit": true}
7
+ {"turn": 6, "kind": "distractor", "user": "finally finished that book", "notes": [], "ledger": [], "prefix_tokens": 214, "answer": "You finally finished reading that book.", "seconds": 0.41}
8
+ {"turn": 7, "kind": "fact", "user": "we always park at spot B19", "notes": [], "ledger": [], "prefix_tokens": 239, "answer": "You always park in spot B19.", "seconds": 0.51, "key": "parking spot", "value": "spot B19"}
9
+ {"turn": 8, "kind": "correction", "user": "actually scratch that, the locker code is code 1688 now", "notes": [], "ledger": [], "prefix_tokens": 273, "answer": "The gym locker code has been updated to 1688.", "seconds": 0.79, "key": "locker code", "value": "code 1688"}
10
+ {"turn": 9, "kind": "distractor", "user": "the cat knocked over a plant", "notes": [], "ledger": [], "prefix_tokens": 303, "answer": "The cat knocked over a plant.", "seconds": 0.53}
11
+ {"turn": 10, "kind": "fact", "user": "the deposit for the venue was $6,400", "notes": [], "ledger": [], "prefix_tokens": 332, "answer": "The deposit for the venue was $6,400.", "seconds": 0.85, "key": "deposit", "value": "$6,400"}
12
+ {"turn": 11, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 362, "answer": "Your phone battery dies faster than usual now.", "seconds": 0.48}
13
+ {"turn": 12, "kind": "distractor", "user": "saw a great documentary yesterday", "notes": [], "ledger": [], "prefix_tokens": 387, "answer": "You saw a great documentary yesterday.", "seconds": 0.34}
14
+ {"turn": 13, "kind": "distractor", "user": "i love this weather lately", "notes": [], "ledger": [], "prefix_tokens": 409, "answer": "You really enjoy the current weather conditions recently.", "seconds": 0.47}
15
+ {"turn": 14, "kind": "fact", "user": "tomas' new teacher is ms. duarte", "notes": [], "ledger": [], "prefix_tokens": 438, "answer": "Tomas' new teacher is Ms. Duarte.", "seconds": 0.59, "key": "kid teacher", "value": "ms. duarte"}
16
+ {"turn": 15, "kind": "distractor", "user": "did you catch the game last night?", "notes": [], "ledger": [], "prefix_tokens": 467, "answer": "I'm sorry, but I don't have any information about a specific game being played last night. Could you please provide more details or clarify which game you're referring to?", "seconds": 1.8}
17
+ {"turn": 16, "kind": "probe", "user": "remind me of the deposit?", "notes": [], "ledger": [], "prefix_tokens": 519, "answer": "The deposit for the venue was $6,400.", "seconds": 0.69, "key": "deposit", "expected": "$6,400", "distance": 6, "hit": true}
18
+ {"turn": 17, "kind": "fact", "user": "the plumber quoted $4,900 for the bathroom", "notes": [], "ledger": [], "prefix_tokens": 554, "answer": "The plumber quoted $4,900 for the bathroom work.", "seconds": 0.85, "key": "plumber quote", "value": "$4,900"}
19
+ {"turn": 18, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 585, "answer": "Your phone battery dies faster than usual now.", "seconds": 0.51}
20
+ {"turn": 19, "kind": "distractor", "user": "traffic was unreal this morning", "notes": [], "ledger": [], "prefix_tokens": 609, "answer": "Traffic was extremely busy and congested this morning.", "seconds": 0.44}
21
+ {"turn": 20, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 638, "answer": "The neighbor is renovating their home once again, causing quite a disturbance due to the noise level.", "seconds": 1.16}
22
+ {"turn": 21, "kind": "fact", "user": "our train leaves from platform 58", "notes": [], "ledger": [], "prefix_tokens": 676, "answer": "Our train departs from Platform 58.", "seconds": 0.64, "key": "train platform", "value": "platform 58"}
23
+ {"turn": 22, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 701, "answer": "They repaved your street.", "seconds": 0.44}
24
+ {"turn": 23, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 722, "answer": "Coffee prices have increased recently.", "seconds": 0.32}
25
+ {"turn": 24, "kind": "fact", "user": "dentist moved me to 7:15pm", "notes": [], "ledger": [], "prefix_tokens": 749, "answer": "You were moved to see the dentist at 7:15 PM.", "seconds": 0.88, "key": "dentist slot", "value": "7:15pm"}
26
+ {"turn": 25, "kind": "distractor", "user": "saw a great documentary yesterday", "notes": [], "ledger": [], "prefix_tokens": 780, "answer": "You watched a great documentary yesterday.", "seconds": 0.4}
27
+ {"turn": 26, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 804, "answer": "Your phone battery dies faster than usual now.", "seconds": 0.49}
28
+ {"turn": 27, "kind": "probe", "user": "quick — the dentist slot?", "notes": [], "ledger": [], "prefix_tokens": 829, "answer": "You were moved to see the dentist at 7:15 PM.", "seconds": 0.87, "key": "dentist slot", "expected": "7:15pm", "distance": 3, "hit": true}
29
+ {"turn": 28, "kind": "fact", "user": "doctor changed the dose to 27mg", "notes": [], "ledger": [], "prefix_tokens": 863, "answer": "The doctor adjusted the medication dosage to 27 mg.", "seconds": 0.82, "key": "meds dose", "value": "27mg"}
30
+ {"turn": 29, "kind": "distractor", "user": "might go for a walk later", "notes": [], "ledger": [], "prefix_tokens": 891, "answer": "You might take a walk later.", "seconds": 0.48}
31
+ {"turn": 30, "kind": "distractor", "user": "finally finished that book", "notes": [], "ledger": [], "prefix_tokens": 912, "answer": "You finally finished reading that book.", "seconds": 0.59}
32
+ {"turn": 31, "kind": "fact", "user": "the couch is order ZT6045, keep it handy", "notes": [], "ledger": [], "prefix_tokens": 943, "answer": "The couch is ordered as model number ZT6045 and should be kept ready.", "seconds": 1.18, "key": "order number", "value": "order ZT6045"}
33
+ {"turn": 32, "kind": "distractor", "user": "what a week, honestly", "notes": [], "ledger": [], "prefix_tokens": 977, "answer": "It sounds like you had a challenging or eventful week.", "seconds": 0.76}
34
+ {"turn": 33, "kind": "distractor", "user": "my back hurts from the gym", "notes": [], "ledger": [], "prefix_tokens": 1005, "answer": "You've injured your back from working out at the gym.", "seconds": 0.81}
35
+ {"turn": 34, "kind": "correction", "user": "actually scratch that, the plumber quote is $4,000 now", "notes": [], "ledger": [], "prefix_tokens": 1042, "answer": "The plumber's quote for the job is now $4,000.", "seconds": 1.11, "key": "plumber quote", "value": "$4,000"}
36
+ {"turn": 35, "kind": "fact", "user": "landlord wants rent by day 97 each month", "notes": [], "ledger": [], "prefix_tokens": 1079, "answer": "The landlord requires the full monthly rent paid by the ninth of the following month.", "seconds": 0.96, "key": "rent due", "value": "day 97"}
37
+ {"turn": 36, "kind": "distractor", "user": "the cat knocked over a plant", "notes": [], "ledger": [], "prefix_tokens": 1111, "answer": "The cat knocked over a plant.", "seconds": 0.4}
38
+ {"turn": 37, "kind": "distractor", "user": "traffic was unreal this morning", "notes": [], "ledger": [], "prefix_tokens": 1133, "answer": "Traffic was extremely busy and congested this morning.", "seconds": 0.49}
39
+ {"turn": 38, "kind": "fact", "user": "we got room 411 at the hotel", "notes": [], "ledger": [], "prefix_tokens": 1163, "answer": "You booked Room 411 at the hotel.", "seconds": 0.54, "key": "hotel room", "value": "room 411"}
40
+ {"turn": 39, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 1193, "answer": "The neighbor is renovating their home once again, causing quite a disturbance due to the noise level.", "seconds": 0.88}
41
+ {"turn": 40, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 1228, "answer": "Coffee prices have increased recently.", "seconds": 0.37}
42
+ {"turn": 41, "kind": "distractor", "user": "i love this weather lately", "notes": [], "ledger": [], "prefix_tokens": 1249, "answer": "You really enjoy the current weather conditions recently.", "seconds": 0.46}
43
+ {"turn": 42, "kind": "fact", "user": "boarding is at gate E17", "notes": [], "ledger": [], "prefix_tokens": 1275, "answer": "Boarding instructions state to board at Gate E17.", "seconds": 0.61, "key": "boarding gate", "value": "gate E17"}
44
+ {"turn": 43, "kind": "distractor", "user": "what a week, honestly", "notes": [], "ledger": [], "prefix_tokens": 1302, "answer": "It sounds like you had a challenging or eventful week.", "seconds": 0.66}
45
+ {"turn": 44, "kind": "distractor", "user": "the cat knocked over a plant", "notes": [], "ledger": [], "prefix_tokens": 1330, "answer": "The cat knocked over a plant.", "seconds": 0.47}
46
+ {"turn": 45, "kind": "fact", "user": "my sister lands on flight AL7371, write that down", "notes": [], "ledger": [], "prefix_tokens": 1361, "answer": "Your sister is scheduled to land on Flight AL7371.", "seconds": 0.82, "key": "sister flight", "value": "flight AL7371"}
47
+ {"turn": 46, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 1390, "answer": "Coffee prices have increased recently.", "seconds": 0.31}
48
+ {"turn": 47, "kind": "correction", "user": "actually scratch that, the sister flight is flight BL4905 now", "notes": [], "ledger": [], "prefix_tokens": 1421, "answer": "Your sister is scheduled to fly on Flight BL4905.", "seconds": 0.7, "key": "sister flight", "value": "flight BL4905"}
49
+ {"turn": 48, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 1450, "answer": "Coffee prices have increased recently.", "seconds": 0.3}
50
+ {"turn": 49, "kind": "fact", "user": "the rental has plate HJC-988", "notes": [], "ledger": [], "prefix_tokens": 1476, "answer": "The rental car is registered under license plate HJC-988.", "seconds": 0.67, "key": "car plate", "value": "HJC-988"}
51
+ {"turn": 50, "kind": "distractor", "user": "my back hurts from the gym", "notes": [], "ledger": [], "prefix_tokens": 1507, "answer": "You've injured your back from working out at the gym.", "seconds": 0.57}
52
+ {"turn": 51, "kind": "distractor", "user": "finally finished that book", "notes": [], "ledger": [], "prefix_tokens": 1533, "answer": "You finally finished reading that book.", "seconds": 0.41}
53
+ {"turn": 52, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 1555, "answer": "Coffee prices have increased recently.", "seconds": 0.41}
54
+ {"turn": 53, "kind": "distractor", "user": "work was chaos today", "notes": [], "ledger": [], "prefix_tokens": 1575, "answer": "Work was chaotic today.", "seconds": 0.29}
55
+ {"turn": 54, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 1599, "answer": "The neighbor is renovating their home once again, causing quite a disturbance due to the noise level.", "seconds": 1.08}
56
+ {"turn": 55, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 1638, "answer": "The neighbor is renovating their home once again, causing quite a disturbance due to the noise level.", "seconds": 1.07}
57
+ {"turn": 56, "kind": "distractor", "user": "did you catch the game last night?", "notes": [], "ledger": [], "prefix_tokens": 1676, "answer": "I'm sorry, but I don't have any information about a specific game being played last night. Could you please provide more details or clarify which game you're referring to?", "seconds": 2.23}
58
+ {"turn": 57, "kind": "distractor", "user": "traffic was unreal this morning", "notes": [], "ledger": [], "prefix_tokens": 1726, "answer": "Traffic was extremely busy and congested this morning.", "seconds": 0.74}
59
+ {"turn": 58, "kind": "distractor", "user": "traffic was unreal this morning", "notes": [], "ledger": [], "prefix_tokens": 1751, "answer": "Traffic was extremely busy and congested this morning.", "seconds": 0.59}
60
+ {"turn": 59, "kind": "distractor", "user": "what a week, honestly", "notes": [], "ledger": [], "prefix_tokens": 1776, "answer": "It sounds like you had a challenging or eventful week.", "seconds": 0.8}
61
+ {"turn": 60, "kind": "probe", "user": "wait, what was the wifi password again?", "notes": [], "ledger": [], "prefix_tokens": 1807, "answer": "The correct WiFi password is \"harbor858.\"", "seconds": 0.68, "key": "wifi password", "expected": "harbor858", "distance": 57, "hit": true}
62
+ {"turn": 61, "kind": "distractor", "user": "finally finished that book", "notes": [], "ledger": [], "prefix_tokens": 1833, "answer": "You finally finished reading that book.", "seconds": 0.48}
63
+ {"turn": 62, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 1855, "answer": "They repaved your street.", "seconds": 0.41}
64
+ {"turn": 63, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 1876, "answer": "They repaved your street.", "seconds": 0.46}
65
+ {"turn": 64, "kind": "distractor", "user": "saw a great documentary yesterday", "notes": [], "ledger": [], "prefix_tokens": 1898, "answer": "You saw a great documentary yesterday.", "seconds": 0.53}
66
+ {"turn": 65, "kind": "distractor", "user": "what a week, honestly", "notes": [], "ledger": [], "prefix_tokens": 1920, "answer": "It sounds like you had a challenging or eventful week.", "seconds": 0.82}
67
+ {"turn": 66, "kind": "distractor", "user": "thinking of making pasta tonight", "notes": [], "ledger": [], "prefix_tokens": 1947, "answer": "You're thinking of making pasta tonight.", "seconds": 0.62}
68
+ {"turn": 67, "kind": "distractor", "user": "work was chaos today", "notes": [], "ledger": [], "prefix_tokens": 1969, "answer": "Work was chaotic today.", "seconds": 0.37}
69
+ {"turn": 68, "kind": "distractor", "user": "might go for a walk later", "notes": [], "ledger": [], "prefix_tokens": 1990, "answer": "You might take a walk later.", "seconds": 0.42}
70
+ {"turn": 69, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 2016, "answer": "The neighbor is renovating their home once again, causing quite a disturbance due to the noise level.", "seconds": 1.14}
71
+ {"turn": 70, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 2051, "answer": "They repaved your street.", "seconds": 0.49}
72
+ {"turn": 71, "kind": "probe", "user": "wait, what was the plumber quote again?", "notes": [], "ledger": [], "prefix_tokens": 2076, "answer": "The plumber's quote for the job is now $4,000.", "seconds": 0.98, "key": "plumber quote", "expected": "$4,000", "distance": 37, "hit": true}
73
+ {"turn": 72, "kind": "distractor", "user": "traffic was unreal this morning", "notes": [], "ledger": [], "prefix_tokens": 2107, "answer": "Traffic was extremely busy and congested this morning.", "seconds": 0.76}
74
+ {"turn": 73, "kind": "correction", "user": "actually scratch that, the parking spot is spot E16 now", "notes": [], "ledger": [], "prefix_tokens": 2140, "answer": "The parking spot is designated as Spot E16.", "seconds": 0.79, "key": "parking spot", "value": "spot E16"}
75
+ {"turn": 74, "kind": "distractor", "user": "i love this weather lately", "notes": [], "ledger": [], "prefix_tokens": 2166, "answer": "You really enjoy the current weather conditions recently.", "seconds": 0.78}
76
+ {"turn": 75, "kind": "distractor", "user": "craving something sweet", "notes": [], "ledger": [], "prefix_tokens": 2189, "answer": "You're craving something sweet.", "seconds": 0.52}
77
+ {"turn": 76, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 2214, "answer": "The neighbor is renovating their home once again, causing quite a disturbance due to the noise level.", "seconds": 1.22}
78
+ {"turn": 77, "kind": "distractor", "user": "thinking of making pasta tonight", "notes": [], "ledger": [], "prefix_tokens": 2249, "answer": "You're considering cooking pasta tonight.", "seconds": 0.65}
79
+ {"turn": 78, "kind": "distractor", "user": "did you catch the game last night?", "notes": [], "ledger": [], "prefix_tokens": 2274, "answer": "I'm sorry, but I don't have any information about a specific game being played last night. Could you please provide more details or clarify which game you're referring to?", "seconds": 2.47}
80
+ {"turn": 79, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 2326, "answer": "Your phone battery dies faster than usual now.", "seconds": 0.9}
81
+ {"turn": 80, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 2352, "answer": "Your phone battery dies faster than usual now.", "seconds": 0.69}
82
+ {"turn": 81, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 2378, "answer": "Your phone battery dies faster than usual now.", "seconds": 0.58}
83
+ {"turn": 82, "kind": "probe", "user": "i forgot the rent due, what was it?", "notes": [], "ledger": [], "prefix_tokens": 2407, "answer": "The rent due was $6,400.", "seconds": 0.79, "key": "rent due", "expected": "day 97", "distance": 47, "hit": false}
84
+ {"turn": 83, "kind": "distractor", "user": "thinking of making pasta tonight", "notes": [], "ledger": [], "prefix_tokens": 2433, "answer": "You're considering cooking pasta tonight.", "seconds": 0.52}
85
+ {"turn": 84, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 2455, "answer": "Coffee prices have increased recently.", "seconds": 0.49}
86
+ {"turn": 85, "kind": "distractor", "user": "thinking of making pasta tonight", "notes": [], "ledger": [], "prefix_tokens": 2476, "answer": "You're considering cooking pasta tonight.", "seconds": 0.54}
87
+ {"turn": 86, "kind": "correction", "user": "actually scratch that, the plumber quote is $1,400 now", "notes": [], "ledger": [], "prefix_tokens": 2508, "answer": "The plumber's quote for the job is now $1,400.", "seconds": 0.96, "key": "plumber quote", "value": "$1,400"}
88
+ {"turn": 87, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 2541, "answer": "Your phone battery dies faster than usual now.", "seconds": 0.58}
89
+ {"turn": 88, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 2565, "answer": "Coffee prices have increased recently.", "seconds": 0.43}
90
+ {"turn": 89, "kind": "distractor", "user": "finally finished that book", "notes": [], "ledger": [], "prefix_tokens": 2585, "answer": "You finally finished reading that book.", "seconds": 0.55}
91
+ {"turn": 90, "kind": "distractor", "user": "my back hurts from the gym", "notes": [], "ledger": [], "prefix_tokens": 2608, "answer": "You've injured your back from working out at the gym.", "seconds": 1.0}
92
+ {"turn": 91, "kind": "distractor", "user": "did you catch the game last night?", "notes": [], "ledger": [], "prefix_tokens": 2638, "answer": "I'm sorry, but I currently don't have any information about a specific game being played last night. Could you please provide more details or clarify which game you're referring to?", "seconds": 1.8}
93
+ {"turn": 92, "kind": "distractor", "user": "work was chaos today", "notes": [], "ledger": [], "prefix_tokens": 2688, "answer": "Work was chaotic today.", "seconds": 0.39}
94
+ {"turn": 93, "kind": "probe", "user": "i forgot the deposit, what was it?", "notes": [], "ledger": [], "prefix_tokens": 2712, "answer": "The deposit for the venue was $6,400.", "seconds": 0.77, "key": "deposit", "expected": "$6,400", "distance": 83, "hit": true}
95
+ {"turn": 94, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 2740, "answer": "Coffee prices have increased recently.", "seconds": 0.44}
96
+ {"turn": 95, "kind": "distractor", "user": "craving something sweet", "notes": [], "ledger": [], "prefix_tokens": 2760, "answer": "You're craving something sweet.", "seconds": 0.52}
97
+ {"turn": 96, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 2785, "answer": "The neighbor is renovating their home once again, causing quite a disturbance due to the noise level.", "seconds": 1.19}
98
+ {"turn": 97, "kind": "probe", "user": "remind me of the hotel room?", "notes": [], "ledger": [], "prefix_tokens": 2823, "answer": "You booked Room 411 at the hotel.", "seconds": 0.83, "key": "hotel room", "expected": "room 411", "distance": 59, "hit": true}
99
+ {"turn": 98, "kind": "probe", "user": "quick — the hotel room?", "notes": [], "ledger": [], "prefix_tokens": 2850, "answer": "You booked Room 411 at the hotel.", "seconds": 0.77, "key": "hotel room", "expected": "room 411", "distance": 60, "hit": true}
100
+ {"turn": 99, "kind": "probe", "user": "i forgot the wifi password, what was it?", "notes": [], "ledger": [], "prefix_tokens": 2881, "answer": "The WiFi password is \"harbor858.\"", "seconds": 0.65, "key": "wifi password", "expected": "harbor858", "distance": 96, "hit": true}
bench/results-qwen3-06b-nothink-budget512.jsonl ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {"turn": 0, "kind": "fact", "user": "the gym locker is code 4805", "notes": [], "ledger": [], "prefix_tokens": 43, "answer": "The code 4805 corresponds to a gym locker. If you have any questions about it, feel free to ask!", "seconds": 3.13, "key": "locker code", "value": "code 4805"}
2
+ {"turn": 1, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 84, "answer": "They repaved your street. Let me know if you need help with anything else!", "seconds": 1.17}
3
+ {"turn": 2, "kind": "distractor", "user": "what a week, honestly", "notes": [], "ledger": [], "prefix_tokens": 116, "answer": "That's a great way to say it! If you need help with anything else, feel free to ask!", "seconds": 1.39}
4
+ {"turn": 3, "kind": "fact", "user": "cabin wifi password is harbor858", "notes": [], "ledger": [], "prefix_tokens": 157, "answer": "The cabin's WiFi password is **harbor858**. Let me know if you need help with anything else!", "seconds": 1.74, "key": "wifi password", "value": "harbor858"}
5
+ {"turn": 4, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 197, "answer": "They repaved your street. Let me know if you need help with anything else!", "seconds": 1.38}
6
+ {"turn": 5, "kind": "probe", "user": "quick — the locker code?", "notes": [], "ledger": [], "prefix_tokens": 230, "answer": "Yes, the locker code is **4805**. Let me know if you need help with anything else!", "seconds": 1.91, "key": "locker code", "expected": "code 4805", "distance": 5, "hit": true}
7
+ {"turn": 6, "kind": "distractor", "user": "finally finished that book", "notes": [], "ledger": [], "prefix_tokens": 268, "answer": "Finally, you finished that book! Let me know if you need help with anything else!", "seconds": 1.17}
8
+ {"turn": 7, "kind": "fact", "user": "we always park at spot B19", "notes": [], "ledger": [], "prefix_tokens": 304, "answer": "Yes, you always park at **spot B19**. Let me know if you need help with anything else!", "seconds": 1.96, "key": "parking spot", "value": "spot B19"}
9
+ {"turn": 8, "kind": "correction", "user": "actually scratch that, the locker code is code 1688 now", "notes": [], "ledger": [], "prefix_tokens": 353, "answer": "Actually, the locker code is now **code 1688**. Let me know if you need help with anything else!", "seconds": 1.81, "key": "locker code", "value": "code 1688"}
10
+ {"turn": 9, "kind": "distractor", "user": "the cat knocked over a plant", "notes": [], "ledger": [], "prefix_tokens": 396, "answer": "The cat knocked over a plant. Let me know if you need help with anything else!", "seconds": 1.45}
11
+ {"turn": 10, "kind": "fact", "user": "the deposit for the venue was $6,400", "notes": [], "ledger": [], "prefix_tokens": 436, "answer": "The deposit for the venue was **$6,400**. Let me know if you need help with anything else!", "seconds": 1.93, "key": "deposit", "value": "$6,400"}
12
+ {"turn": 11, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 479, "answer": "Your phone battery is dying so fast now. Let me know if you need help with anything else!", "seconds": 1.21}
13
+ {"turn": 12, "kind": "distractor", "user": "saw a great documentary yesterday", "notes": [], "ledger": [], "prefix_tokens": 500, "answer": "Saw a great documentary yesterday. Let me know if you need help with anything else!", "seconds": 1.22}
14
+ {"turn": 13, "kind": "distractor", "user": "i love this weather lately", "notes": [], "ledger": [], "prefix_tokens": 502, "answer": "I love this weather lately too! Let me know if you need help with anything else!", "seconds": 1.08}
15
+ {"turn": 14, "kind": "fact", "user": "tomas' new teacher is ms. duarte", "notes": [], "ledger": [], "prefix_tokens": 508, "answer": "Tomás' new teacher is Ms. Duarte. Let me know if you need help with anything else!", "seconds": 1.42, "key": "kid teacher", "value": "ms. duarte"}
16
+ {"turn": 15, "kind": "distractor", "user": "did you catch the game last night?", "notes": [], "ledger": [], "prefix_tokens": 511, "answer": "I don't know if I caught the game last night, but I hope it was a good time! Let me know if you need help with anything else!", "seconds": 2.36}
17
+ {"turn": 16, "kind": "probe", "user": "remind me of the deposit?", "notes": [], "ledger": [], "prefix_tokens": 506, "answer": "Sure! Let me know if you need help with anything else!", "seconds": 1.15, "key": "deposit", "expected": "$6,400", "distance": 6, "hit": false}
18
+ {"turn": 17, "kind": "fact", "user": "the plumber quoted $4,900 for the bathroom", "notes": [], "ledger": [], "prefix_tokens": 508, "answer": "The plumber quoted **$4,900** for the bathroom. Let me know if you need help with anything else!", "seconds": 2.11, "key": "plumber quote", "value": "$4,900"}
19
+ {"turn": 18, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 490, "answer": "Your phone battery is dying so fast now. Let me know if you need help with anything else!", "seconds": 1.39}
20
+ {"turn": 19, "kind": "distractor", "user": "traffic was unreal this morning", "notes": [], "ledger": [], "prefix_tokens": 512, "answer": "Traffic was unreal this morning. Let me know if you need help with anything else!", "seconds": 1.34}
21
+ {"turn": 20, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 499, "answer": "The neighbor is renovating again, so it's loud. Let me know if you need help with anything else!", "seconds": 1.88}
22
+ {"turn": 21, "kind": "fact", "user": "our train leaves from platform 58", "notes": [], "ledger": [], "prefix_tokens": 508, "answer": "Our train leaves from **Platform 58**. Let me know if you need help with anything else!", "seconds": 1.32, "key": "train platform", "value": "platform 58"}
23
+ {"turn": 22, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 511, "answer": "They repaved our street. Let me know if you need help with anything else!", "seconds": 1.11}
24
+ {"turn": 23, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 495, "answer": "Coffee is getting expensive. Let me know if you need help with anything else!", "seconds": 1.03}
25
+ {"turn": 24, "kind": "fact", "user": "dentist moved me to 7:15pm", "notes": [], "ledger": [], "prefix_tokens": 495, "answer": "The dentist moved you to **7:15 PM**. Let me know if you need help with anything else!", "seconds": 1.34, "key": "dentist slot", "value": "7:15pm"}
26
+ {"turn": 25, "kind": "distractor", "user": "saw a great documentary yesterday", "notes": [], "ledger": [], "prefix_tokens": 501, "answer": "Saw a great documentary yesterday! Let me know if you need help with anything else!", "seconds": 1.15}
27
+ {"turn": 26, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 503, "answer": "Your phone battery is dying so fast now. Let me know if you need help with anything else!", "seconds": 1.16}
28
+ {"turn": 27, "kind": "probe", "user": "quick — the dentist slot?", "notes": [], "ledger": [], "prefix_tokens": 497, "answer": "Quick — the **dentist slot**. Let me know if you need help with anything else!", "seconds": 1.33, "key": "dentist slot", "expected": "7:15pm", "distance": 3, "hit": false}
29
+ {"turn": 28, "kind": "fact", "user": "doctor changed the dose to 27mg", "notes": [], "ledger": [], "prefix_tokens": 486, "answer": "The doctor changed the dose to **27 mg**. Let me know if you need help with anything else!", "seconds": 1.76, "key": "meds dose", "value": "27mg"}
30
+ {"turn": 29, "kind": "distractor", "user": "might go for a walk later", "notes": [], "ledger": [], "prefix_tokens": 495, "answer": "You might go for a walk later. Let me know if you need help with anything else!", "seconds": 1.29}
31
+ {"turn": 30, "kind": "distractor", "user": "finally finished that book", "notes": [], "ledger": [], "prefix_tokens": 511, "answer": "Finally finished that book! Let me know if you need help with anything else!", "seconds": 1.22}
32
+ {"turn": 31, "kind": "fact", "user": "the couch is order ZT6045, keep it handy", "notes": [], "ledger": [], "prefix_tokens": 508, "answer": "The couch is ordered as **ZT6045**, and I’ll keep it handy for you. Let me know if you need help with anything else!", "seconds": 2.29, "key": "order number", "value": "order ZT6045"}
33
+ {"turn": 32, "kind": "distractor", "user": "what a week, honestly", "notes": [], "ledger": [], "prefix_tokens": 499, "answer": "What a week! Honestly, it's been a really fun and rewarding time! Let me know if you need help with anything else!", "seconds": 2.23}
34
+ {"turn": 33, "kind": "distractor", "user": "my back hurts from the gym", "notes": [], "ledger": [], "prefix_tokens": 500, "answer": "Your back hurts from the gym. Let me know if you need help with anything else!", "seconds": 1.14}
35
+ {"turn": 34, "kind": "correction", "user": "actually scratch that, the plumber quote is $4,000 now", "notes": [], "ledger": [], "prefix_tokens": 503, "answer": "The plumber quote is now **$4,000**. Let me know if you need help with anything else!", "seconds": 1.89, "key": "plumber quote", "value": "$4,000"}
36
+ {"turn": 35, "kind": "fact", "user": "landlord wants rent by day 97 each month", "notes": [], "ledger": [], "prefix_tokens": 507, "answer": "The landlord wants your rent **by day 97** each month. Let me know if you need help with anything else!", "seconds": 2.02, "key": "rent due", "value": "day 97"}
37
+ {"turn": 36, "kind": "distractor", "user": "the cat knocked over a plant", "notes": [], "ledger": [], "prefix_tokens": 512, "answer": "The cat knocked over a plant. Let me know if you need help with anything else!", "seconds": 1.49}
38
+ {"turn": 37, "kind": "distractor", "user": "traffic was unreal this morning", "notes": [], "ledger": [], "prefix_tokens": 505, "answer": "Traffic was unreal this morning. Let me know if you need help with anything else!", "seconds": 1.27}
39
+ {"turn": 38, "kind": "fact", "user": "we got room 411 at the hotel", "notes": [], "ledger": [], "prefix_tokens": 507, "answer": "We got room **411** at the hotel. Let me know if you need help with anything else!", "seconds": 1.32, "key": "hotel room", "value": "room 411"}
40
+ {"turn": 39, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 488, "answer": "The neighbor is renovating again, and it's **loud**. Let me know if you need help with anything else!", "seconds": 1.55}
41
+ {"turn": 40, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 486, "answer": "Coffee is getting expensive. Let me know if you need help with anything else!", "seconds": 1.16}
42
+ {"turn": 41, "kind": "distractor", "user": "i love this weather lately", "notes": [], "ledger": [], "prefix_tokens": 506, "answer": "I love this weather lately too! It's really nice. Let me know if you need help with anything else!", "seconds": 1.83}
43
+ {"turn": 42, "kind": "fact", "user": "boarding is at gate E17", "notes": [], "ledger": [], "prefix_tokens": 492, "answer": "Boarding is at **Gate E17**. Let me know if you need help with anything else!", "seconds": 1.55, "key": "boarding gate", "value": "gate E17"}
44
+ {"turn": 43, "kind": "distractor", "user": "what a week, honestly", "notes": [], "ledger": [], "prefix_tokens": 510, "answer": "What a week! Honestly, it's been a really fun and rewarding time! Let me know if you need help with anything else!", "seconds": 1.93}
45
+ {"turn": 44, "kind": "distractor", "user": "the cat knocked over a plant", "notes": [], "ledger": [], "prefix_tokens": 505, "answer": "The cat knocked over a plant. Let me know if you need help with anything else!", "seconds": 1.28}
46
+ {"turn": 45, "kind": "fact", "user": "my sister lands on flight AL7371, write that down", "notes": [], "ledger": [], "prefix_tokens": 504, "answer": "My sister lands on flight **AL7371**.", "seconds": 0.99, "key": "sister flight", "value": "flight AL7371"}
47
+ {"turn": 46, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 509, "answer": "Coffee is getting expensive. Let me know if you need help with anything else!", "seconds": 1.02}
48
+ {"turn": 47, "kind": "correction", "user": "actually scratch that, the sister flight is flight BL4905 now", "notes": [], "ledger": [], "prefix_tokens": 500, "answer": "Actually, the sister flight is now **flight BL4905**. Let me know if you need help with anything else!", "seconds": 1.98, "key": "sister flight", "value": "flight BL4905"}
49
+ {"turn": 48, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 495, "answer": "Coffee is getting expensive. Let me know if you need help with anything else!", "seconds": 0.97}
50
+ {"turn": 49, "kind": "fact", "user": "the rental has plate HJC-988", "notes": [], "ledger": [], "prefix_tokens": 497, "answer": "The rental has plate **HJC-988**. Let me know if you need help with anything else!", "seconds": 1.49, "key": "car plate", "value": "HJC-988"}
51
+ {"turn": 50, "kind": "distractor", "user": "my back hurts from the gym", "notes": [], "ledger": [], "prefix_tokens": 505, "answer": "My back hurts from the gym. Let me know if you need help with anything else!", "seconds": 1.25}
52
+ {"turn": 51, "kind": "distractor", "user": "finally finished that book", "notes": [], "ledger": [], "prefix_tokens": 494, "answer": "Finally finished that book! Let me know if you need help with anything else!", "seconds": 1.12}
53
+ {"turn": 52, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 511, "answer": "Coffee is getting expensive. Let me know if you need help with anything else!", "seconds": 1.06}
54
+ {"turn": 53, "kind": "distractor", "user": "work was chaos today", "notes": [], "ledger": [], "prefix_tokens": 511, "answer": "Work was chaos today. Let me know if you need help with anything else!", "seconds": 0.9}
55
+ {"turn": 54, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 505, "answer": "The neighbor is renovating again and it's loud. Let me know if you need help with anything else!", "seconds": 1.37}
56
+ {"turn": 55, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 506, "answer": "The neighbor is renovating again and it's loud. Let me know if you need help with anything else!", "seconds": 1.63}
57
+ {"turn": 56, "kind": "distractor", "user": "did you catch the game last night?", "notes": [], "ledger": [], "prefix_tokens": 509, "answer": "Did you catch the game last night?", "seconds": 0.67}
58
+ {"turn": 57, "kind": "distractor", "user": "traffic was unreal this morning", "notes": [], "ledger": [], "prefix_tokens": 500, "answer": "Traffic was unreal this morning. Let me know if you need help with anything else!", "seconds": 1.16}
59
+ {"turn": 58, "kind": "distractor", "user": "traffic was unreal this morning", "notes": [], "ledger": [], "prefix_tokens": 498, "answer": "Traffic was unreal this morning. Let me know if you need help with anything else!", "seconds": 1.15}
60
+ {"turn": 59, "kind": "distractor", "user": "what a week, honestly", "notes": [], "ledger": [], "prefix_tokens": 511, "answer": "What a week! Honestly, I'm so glad you're having a great time. Let me know if you need help with anything else!", "seconds": 1.65}
61
+ {"turn": 60, "kind": "probe", "user": "wait, what was the wifi password again?", "notes": [], "ledger": [], "prefix_tokens": 509, "answer": "What was the **WiFi password again**? Let me know if you need help with anything else!", "seconds": 1.15, "key": "wifi password", "expected": "harbor858", "distance": 57, "hit": false}
62
+ {"turn": 61, "kind": "distractor", "user": "finally finished that book", "notes": [], "ledger": [], "prefix_tokens": 491, "answer": "Finally finished that book! Let me know if you need help with anything else!", "seconds": 1.06}
63
+ {"turn": 62, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 512, "answer": "They repaved our street! Let me know if you need help with anything else!", "seconds": 1.09}
64
+ {"turn": 63, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 508, "answer": "They repaved our street! Let me know if you need help with anything else!", "seconds": 0.95}
65
+ {"turn": 64, "kind": "distractor", "user": "saw a great documentary yesterday", "notes": [], "ledger": [], "prefix_tokens": 512, "answer": "Saw a great documentary yesterday! Let me know if you need help with anything else!", "seconds": 1.11}
66
+ {"turn": 65, "kind": "distractor", "user": "what a week, honestly", "notes": [], "ledger": [], "prefix_tokens": 511, "answer": "What a week! Honestly, I'm so glad you're having a great time. Let me know if you need help with anything else!", "seconds": 2.27}
67
+ {"turn": 66, "kind": "distractor", "user": "thinking of making pasta tonight", "notes": [], "ledger": [], "prefix_tokens": 493, "answer": "Thinking of making **pasta tonight**, let me know if you'd like some ideas or help with the recipe! 😊", "seconds": 1.71}
68
+ {"turn": 67, "kind": "distractor", "user": "work was chaos today", "notes": [], "ledger": [], "prefix_tokens": 502, "answer": "Work was **chaos today**! Let me know if you'd like help with anything else! 😊", "seconds": 1.23}
69
+ {"turn": 68, "kind": "distractor", "user": "might go for a walk later", "notes": [], "ledger": [], "prefix_tokens": 499, "answer": "Yes, I might go for a **walk later**! Let me know if you'd like help with anything else! 😊", "seconds": 1.76}
70
+ {"turn": 69, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 503, "answer": "The neighbor is **renovating again**, so it's loud. Let me know if you'd like help with anything else! 😊", "seconds": 1.95}
71
+ {"turn": 70, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 510, "answer": "They **repaved our street**! Let me know if you need help with anything else! 😊", "seconds": 1.55}
72
+ {"turn": 71, "kind": "probe", "user": "wait, what was the plumber quote again?", "notes": [], "ledger": [], "prefix_tokens": 496, "answer": "What was the **plumber quote again**? Let me know if you'd like help with anything else! 😊", "seconds": 1.66, "key": "plumber quote", "expected": "$4,000", "distance": 37, "hit": false}
73
+ {"turn": 72, "kind": "distractor", "user": "traffic was unreal this morning", "notes": [], "ledger": [], "prefix_tokens": 492, "answer": "Traffic was **unreal this morning**! Let me know if you'd like help with anything else! 😊", "seconds": 1.76}
74
+ {"turn": 73, "kind": "correction", "user": "actually scratch that, the parking spot is spot E16 now", "notes": [], "ledger": [], "prefix_tokens": 499, "answer": "The **parking spot is now E16**! Let me know if you need help with anything else! 😊", "seconds": 1.71, "key": "parking spot", "value": "spot E16"}
75
+ {"turn": 74, "kind": "distractor", "user": "i love this weather lately", "notes": [], "ledger": [], "prefix_tokens": 509, "answer": "I love this **weather lately**! It's really nice out. Let me know if you need help with anything else! 😊", "seconds": 1.68}
76
+ {"turn": 75, "kind": "distractor", "user": "craving something sweet", "notes": [], "ledger": [], "prefix_tokens": 508, "answer": "I'm so glad to hear that! I love the **sweet** things too! Let me know if you'd like help with anything else! 😊", "seconds": 1.83}
77
+ {"turn": 76, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 502, "answer": "The **neighbor is renovating again**, so it's loud. Let me know if you'd like help with anything else! 😊", "seconds": 1.68}
78
+ {"turn": 77, "kind": "distractor", "user": "thinking of making pasta tonight", "notes": [], "ledger": [], "prefix_tokens": 501, "answer": "Thinking of making **pasta tonight**, let me know if you'd like some ideas or help with the recipe! 😊", "seconds": 1.25}
79
+ {"turn": 78, "kind": "distractor", "user": "did you catch the game last night?", "notes": [], "ledger": [], "prefix_tokens": 504, "answer": "I **did not** catch the game last night. Let me know if you'd like help with anything else! 😊", "seconds": 1.42}
80
+ {"turn": 79, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 510, "answer": "My phone battery **dies so fast now**. It's definitely a common issue. Let me know if you'd like help with anything else! 😊", "seconds": 1.9}
81
+ {"turn": 80, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 503, "answer": "My phone battery **dies so fast now**. It's definitely a common issue. Let me know if you'd like help with anything else! 😊", "seconds": 2.0}
82
+ {"turn": 81, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 509, "answer": "My phone battery **dies so fast now**. It's definitely a common issue. Let me know if you'd like help with anything else! 😊", "seconds": 2.24}
83
+ {"turn": 82, "kind": "probe", "user": "i forgot the rent due, what was it?", "notes": [], "ledger": [], "prefix_tokens": 492, "answer": "I **forgot the rent due**, but I don't know the exact amount. Let me know if you'd like help with anything else! 😊", "seconds": 2.07, "key": "rent due", "expected": "day 97", "distance": 47, "hit": false}
84
+ {"turn": 83, "kind": "distractor", "user": "thinking of making pasta tonight", "notes": [], "ledger": [], "prefix_tokens": 499, "answer": "I **think of making pasta tonight**. Let me know if you'd like some ideas or help with the recipe! 😊", "seconds": 1.96}
85
+ {"turn": 84, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 492, "answer": "The **coffee here is getting expensive**. It's definitely a good time to consider alternatives, like herbal or organic options. Let me know if you'd like help with anything else! 😊", "seconds": 3.02}
86
+ {"turn": 85, "kind": "distractor", "user": "thinking of making pasta tonight", "notes": [], "ledger": [], "prefix_tokens": 504, "answer": "I **think of making pasta tonight**, and I'm really looking forward to it! Let me know if you'd like some ideas or help with the recipe! 😊", "seconds": 2.49}
87
+ {"turn": 86, "kind": "correction", "user": "actually scratch that, the plumber quote is $1,400 now", "notes": [], "ledger": [], "prefix_tokens": 504, "answer": "The **plumber quote is now $1,400**. Let me know if you'd like help with anything else! 😊", "seconds": 2.08, "key": "plumber quote", "value": "$1,400"}
88
+ {"turn": 87, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 508, "answer": "My phone battery **dies so fast now**. It's definitely a common issue. Let me know if you'd like help with anything else! 😊", "seconds": 2.27}
89
+ {"turn": 88, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 512, "answer": "The **coffee here is getting expensive**. It's definitely a good time to consider alternatives like herbal or organic coffee. Let me know if you'd like help with anything else! 😊", "seconds": 2.85}
90
+ {"turn": 89, "kind": "distractor", "user": "finally finished that book", "notes": [], "ledger": [], "prefix_tokens": 485, "answer": "I **finally finished that book**. It was a great read! Let me know if you'd like help with anything else! 😊", "seconds": 1.95}
91
+ {"turn": 90, "kind": "distractor", "user": "my back hurts from the gym", "notes": [], "ledger": [], "prefix_tokens": 480, "answer": "The **my back hurts from the gym**. It's definitely a common issue, especially after a workout. Let me know if you'd like help with anything else! 😊", "seconds": 2.01}
92
+ {"turn": 91, "kind": "distractor", "user": "did you catch the game last night?", "notes": [], "ledger": [], "prefix_tokens": 485, "answer": "I **did not catch the game last night**. Let me know if you'd like help with anything else! 😊", "seconds": 1.56}
93
+ {"turn": 92, "kind": "distractor", "user": "work was chaos today", "notes": [], "ledger": [], "prefix_tokens": 509, "answer": "**Work was chaos today.** It's definitely stressful, but I'm glad you're able to get through it! Let me know if you'd like help with anything else! 😊", "seconds": 2.09}
94
+ {"turn": 93, "kind": "probe", "user": "i forgot the deposit, what was it?", "notes": [], "ledger": [], "prefix_tokens": 490, "answer": "The **deposit was $50**. Let me know if you'd like help with anything else! 😊", "seconds": 1.48, "key": "deposit", "expected": "$6,400", "distance": 83, "hit": false}
95
+ {"turn": 94, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 474, "answer": "The **coffee here is getting expensive**. It's definitely a good time to consider alternatives like herbal or organic coffee. Let me know if you'd like help with anything else! 😊", "seconds": 2.3}
96
+ {"turn": 95, "kind": "distractor", "user": "craving something sweet", "notes": [], "ledger": [], "prefix_tokens": 477, "answer": "**Craving something sweet**. It's definitely a good time to enjoy a treat, like a sweet treat or a cup of coffee. Let me know if you'd like help with anything else! 😊", "seconds": 2.56}
97
+ {"turn": 96, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 484, "answer": "The **neighbor is renovating again, so it's loud**. It's definitely not a good time to be around, especially if you're in the house. Let me know if you'd like help with anything else! 😊", "seconds": 2.65}
98
+ {"turn": 97, "kind": "probe", "user": "remind me of the hotel room?", "notes": [], "ledger": [], "prefix_tokens": 500, "answer": "**Remind me of the hotel room?** Let me know if you'd like help with anything else! 😊", "seconds": 1.42, "key": "hotel room", "expected": "room 411", "distance": 59, "hit": false}
99
+ {"turn": 98, "kind": "probe", "user": "quick — the hotel room?", "notes": [], "ledger": [], "prefix_tokens": 487, "answer": "**Quick — the hotel room?** Let me know if you'd like help with anything else! 😊", "seconds": 1.51, "key": "hotel room", "expected": "room 411", "distance": 60, "hit": false}
100
+ {"turn": 99, "kind": "probe", "user": "i forgot the wifi password, what was it?", "notes": [], "ledger": [], "prefix_tokens": 487, "answer": "The **WiFi password** was **123456**. Let me know if you'd like help with anything else! 😊", "seconds": 2.25, "key": "wifi password", "expected": "harbor858", "distance": 96, "hit": false}
bench/results-qwen3-06b-nothink-full.jsonl ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {"turn": 0, "kind": "fact", "user": "the gym locker is code 4805", "notes": [], "ledger": [], "prefix_tokens": 43, "answer": "The code 4805 corresponds to a gym locker. If you have any questions about it, feel free to ask!", "seconds": 5.03, "key": "locker code", "value": "code 4805"}
2
+ {"turn": 1, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 84, "answer": "They repaved your street. Let me know if you need help with anything else!", "seconds": 1.34}
3
+ {"turn": 2, "kind": "distractor", "user": "what a week, honestly", "notes": [], "ledger": [], "prefix_tokens": 116, "answer": "That's a great way to say it! If you need help with anything else, feel free to ask!", "seconds": 1.4}
4
+ {"turn": 3, "kind": "fact", "user": "cabin wifi password is harbor858", "notes": [], "ledger": [], "prefix_tokens": 157, "answer": "The cabin's WiFi password is **harbor858**. Let me know if you need help with anything else!", "seconds": 1.34, "key": "wifi password", "value": "harbor858"}
5
+ {"turn": 4, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 197, "answer": "They repaved your street. Let me know if you need help with anything else!", "seconds": 0.94}
6
+ {"turn": 5, "kind": "probe", "user": "quick — the locker code?", "notes": [], "ledger": [], "prefix_tokens": 230, "answer": "Yes, the locker code is **4805**. Let me know if you need help with anything else!", "seconds": 1.38, "key": "locker code", "expected": "code 4805", "distance": 5, "hit": false}
7
+ {"turn": 6, "kind": "distractor", "user": "finally finished that book", "notes": [], "ledger": [], "prefix_tokens": 268, "answer": "Finally, you finished that book! Let me know if you need help with anything else!", "seconds": 1.06}
8
+ {"turn": 7, "kind": "fact", "user": "we always park at spot B19", "notes": [], "ledger": [], "prefix_tokens": 304, "answer": "Yes, you always park at **spot B19**. Let me know if you need help with anything else!", "seconds": 1.27, "key": "parking spot", "value": "spot B19"}
9
+ {"turn": 8, "kind": "correction", "user": "actually scratch that, the locker code is code 1688 now", "notes": [], "ledger": [], "prefix_tokens": 353, "answer": "Actually, the locker code is now **code 1688**. Let me know if you need help with anything else!", "seconds": 1.43, "key": "locker code", "value": "code 1688"}
10
+ {"turn": 9, "kind": "distractor", "user": "the cat knocked over a plant", "notes": [], "ledger": [], "prefix_tokens": 396, "answer": "The cat knocked over a plant. Let me know if you need help with anything else!", "seconds": 0.92}
11
+ {"turn": 10, "kind": "fact", "user": "the deposit for the venue was $6,400", "notes": [], "ledger": [], "prefix_tokens": 436, "answer": "The deposit for the venue was **$6,400**. Let me know if you need help with anything else!", "seconds": 1.4, "key": "deposit", "value": "$6,400"}
12
+ {"turn": 11, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 479, "answer": "Your phone battery is dying so fast now. Let me know if you need help with anything else!", "seconds": 0.97}
13
+ {"turn": 12, "kind": "distractor", "user": "saw a great documentary yesterday", "notes": [], "ledger": [], "prefix_tokens": 515, "answer": "Saw a great documentary yesterday. Let me know if you need help with anything else!", "seconds": 1.06}
14
+ {"turn": 13, "kind": "distractor", "user": "i love this weather lately", "notes": [], "ledger": [], "prefix_tokens": 548, "answer": "I love this weather lately! Let me know if you need help with anything else!", "seconds": 1.26}
15
+ {"turn": 14, "kind": "fact", "user": "tomas' new teacher is ms. duarte", "notes": [], "ledger": [], "prefix_tokens": 585, "answer": "Tomás' new teacher is Ms. Duarte. Let me know if you need help with anything else!", "seconds": 1.23, "key": "kid teacher", "value": "ms. duarte"}
16
+ {"turn": 15, "kind": "distractor", "user": "did you catch the game last night?", "notes": [], "ledger": [], "prefix_tokens": 625, "answer": "Yes, I caught the game last night! Let me know if you need help with anything else!", "seconds": 1.23}
17
+ {"turn": 16, "kind": "probe", "user": "remind me of the deposit?", "notes": [], "ledger": [], "prefix_tokens": 662, "answer": "Sure! Let me know if you need help with anything else!", "seconds": 1.04, "key": "deposit", "expected": "$6,400", "distance": 6, "hit": false}
18
+ {"turn": 17, "kind": "fact", "user": "the plumber quoted $4,900 for the bathroom", "notes": [], "ledger": [], "prefix_tokens": 697, "answer": "The plumber quoted **$4,900** for the bathroom. Let me know if you need help with anything else!", "seconds": 1.64, "key": "plumber quote", "value": "$4,900"}
19
+ {"turn": 18, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 740, "answer": "Your phone battery is dying so fast now. Let me know if you need help with anything else!", "seconds": 1.48}
20
+ {"turn": 19, "kind": "distractor", "user": "traffic was unreal this morning", "notes": [], "ledger": [], "prefix_tokens": 775, "answer": "Traffic was unreal this morning. Let me know if you need help with anything else!", "seconds": 1.01}
21
+ {"turn": 20, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 811, "answer": "The neighbor is renovating again, which is causing loud noises. Let me know if you need help with anything else!", "seconds": 1.75}
22
+ {"turn": 21, "kind": "fact", "user": "our train leaves from platform 58", "notes": [], "ledger": [], "prefix_tokens": 853, "answer": "Our train leaves from **platform 58**. Let me know if you need help with anything else!", "seconds": 1.58, "key": "train platform", "value": "platform 58"}
23
+ {"turn": 22, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 890, "answer": "They repaved our street. Let me know if you need help with anything else!", "seconds": 0.92}
24
+ {"turn": 23, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 922, "answer": "Coffee is getting expensive now. Let me know if you need help with anything else!", "seconds": 0.88}
25
+ {"turn": 24, "kind": "fact", "user": "dentist moved me to 7:15pm", "notes": [], "ledger": [], "prefix_tokens": 960, "answer": "The dentist moved you to **7:15 PM**. Let me know if you need help with anything else!", "seconds": 1.26, "key": "dentist slot", "value": "7:15pm"}
26
+ {"turn": 25, "kind": "distractor", "user": "saw a great documentary yesterday", "notes": [], "ledger": [], "prefix_tokens": 1000, "answer": "Saw a great documentary yesterday. Let me know if you need help with anything else!", "seconds": 0.95}
27
+ {"turn": 26, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 1035, "answer": "Your phone battery is dying so fast now. Let me know if you need help with anything else!", "seconds": 1.02}
28
+ {"turn": 27, "kind": "probe", "user": "quick — the dentist slot?", "notes": [], "ledger": [], "prefix_tokens": 1071, "answer": "Yes, the dentist slot is **4805**. Let me know if you need help with anything else!", "seconds": 1.44, "key": "dentist slot", "expected": "7:15pm", "distance": 3, "hit": false}
29
+ {"turn": 28, "kind": "fact", "user": "doctor changed the dose to 27mg", "notes": [], "ledger": [], "prefix_tokens": 1114, "answer": "The doctor changed the dose to **27 mg**. Let me know if you need help with anything else!", "seconds": 1.33, "key": "meds dose", "value": "27mg"}
30
+ {"turn": 29, "kind": "distractor", "user": "might go for a walk later", "notes": [], "ledger": [], "prefix_tokens": 1153, "answer": "You might go for a walk later. Let me know if you need help with anything else!", "seconds": 0.93}
31
+ {"turn": 30, "kind": "distractor", "user": "finally finished that book", "notes": [], "ledger": [], "prefix_tokens": 1186, "answer": "Finally finished that book! Let me know if you need help with anything else!", "seconds": 0.87}
32
+ {"turn": 31, "kind": "fact", "user": "the couch is order ZT6045, keep it handy", "notes": [], "ledger": [], "prefix_tokens": 1226, "answer": "The couch is ordered as **ZT6045**, and I recommend keeping it handy. Let me know if you need help with anything else!", "seconds": 2.16, "key": "order number", "value": "order ZT6045"}
33
+ {"turn": 32, "kind": "distractor", "user": "what a week, honestly", "notes": [], "ledger": [], "prefix_tokens": 1272, "answer": "What a week! Honestly, that's a great way to say it. Let me know if you need help with anything else!", "seconds": 1.79}
34
+ {"turn": 33, "kind": "distractor", "user": "my back hurts from the gym", "notes": [], "ledger": [], "prefix_tokens": 1314, "answer": "Your back hurts from the gym. Let me know if you need help with anything else!", "seconds": 1.2}
35
+ {"turn": 34, "kind": "correction", "user": "actually scratch that, the plumber quote is $4,000 now", "notes": [], "ledger": [], "prefix_tokens": 1357, "answer": "Actually, the plumber quoted **$4,000** now. Let me know if you need help with anything else!", "seconds": 1.83, "key": "plumber quote", "value": "$4,000"}
36
+ {"turn": 35, "kind": "fact", "user": "landlord wants rent by day 97 each month", "notes": [], "ledger": [], "prefix_tokens": 1404, "answer": "The landlord wants rent to be paid **by day 97** each month. Let me know if you need help with anything else!", "seconds": 2.28, "key": "rent due", "value": "day 97"}
37
+ {"turn": 36, "kind": "distractor", "user": "the cat knocked over a plant", "notes": [], "ledger": [], "prefix_tokens": 1448, "answer": "The cat knocked over a plant. Let me know if you need help with anything else!", "seconds": 1.2}
38
+ {"turn": 37, "kind": "distractor", "user": "traffic was unreal this morning", "notes": [], "ledger": [], "prefix_tokens": 1481, "answer": "Traffic was unreal this morning. Let me know if you need help with anything else!", "seconds": 0.89}
39
+ {"turn": 38, "kind": "fact", "user": "we got room 411 at the hotel", "notes": [], "ledger": [], "prefix_tokens": 1518, "answer": "We got room **411** at the hotel. Let me know if you need help with anything else!", "seconds": 1.31, "key": "hotel room", "value": "room 411"}
40
+ {"turn": 39, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 1560, "answer": "The neighbor is renovating again, which is causing loud noises. Let me know if you need help with anything else!", "seconds": 1.35}
41
+ {"turn": 40, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 1599, "answer": "Coffee is getting expensive now. Let me know if you need help with anything else!", "seconds": 1.18}
42
+ {"turn": 41, "kind": "distractor", "user": "i love this weather lately", "notes": [], "ledger": [], "prefix_tokens": 1631, "answer": "I love this weather lately! Let me know if you need help with anything else!", "seconds": 0.87}
43
+ {"turn": 42, "kind": "fact", "user": "boarding is at gate E17", "notes": [], "ledger": [], "prefix_tokens": 1665, "answer": "Boarding is at **Gate E17**. Let me know if you need help with anything else!", "seconds": 1.11, "key": "boarding gate", "value": "gate E17"}
44
+ {"turn": 43, "kind": "distractor", "user": "what a week, honestly", "notes": [], "ledger": [], "prefix_tokens": 1702, "answer": "What a week! Honestly, that's a great way to say it. Let me know if you need help with anything else!", "seconds": 1.26}
45
+ {"turn": 44, "kind": "distractor", "user": "the cat knocked over a plant", "notes": [], "ledger": [], "prefix_tokens": 1744, "answer": "The cat knocked over a plant. Let me know if you need help with anything else!", "seconds": 0.93}
46
+ {"turn": 45, "kind": "fact", "user": "my sister lands on flight AL7371, write that down", "notes": [], "ledger": [], "prefix_tokens": 1786, "answer": "My sister lands on flight **AL7371**. Let me know if you need help with anything else!", "seconds": 1.45, "key": "sister flight", "value": "flight AL7371"}
47
+ {"turn": 46, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 1825, "answer": "Coffee is getting expensive now. Let me know if you need help with anything else!", "seconds": 1.21}
48
+ {"turn": 47, "kind": "correction", "user": "actually scratch that, the sister flight is flight BL4905 now", "notes": [], "ledger": [], "prefix_tokens": 1867, "answer": "Actually, the sister flight is now **flight BL4905**. Let me know if you need help with anything else!", "seconds": 1.67, "key": "sister flight", "value": "flight BL4905"}
49
+ {"turn": 48, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 1909, "answer": "Coffee is getting expensive now. Let me know if you need help with anything else!", "seconds": 1.23}
50
+ {"turn": 49, "kind": "fact", "user": "the rental has plate HJC-988", "notes": [], "ledger": [], "prefix_tokens": 1946, "answer": "The rental has plate **HJC-988**. Let me know if you need help with anything else!", "seconds": 1.21, "key": "car plate", "value": "HJC-988"}
51
+ {"turn": 50, "kind": "distractor", "user": "my back hurts from the gym", "notes": [], "ledger": [], "prefix_tokens": 1986, "answer": "Your back hurts from the gym. Let me know if you need help with anything else!", "seconds": 1.11}
52
+ {"turn": 51, "kind": "distractor", "user": "finally finished that book", "notes": [], "ledger": [], "prefix_tokens": 2018, "answer": "Finally finished that book! Let me know if you need help with anything else!", "seconds": 1.17}
53
+ {"turn": 52, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 2049, "answer": "Coffee is getting expensive now. Let me know if you need help with anything else!", "seconds": 0.9}
54
+ {"turn": 53, "kind": "distractor", "user": "work was chaos today", "notes": [], "ledger": [], "prefix_tokens": 2080, "answer": "Work was chaos today. Let me know if you need help with anything else!", "seconds": 0.99}
55
+ {"turn": 54, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 2115, "answer": "The neighbor is renovating again, which is causing loud noises. Let me know if you need help with anything else!", "seconds": 1.43}
56
+ {"turn": 55, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 2158, "answer": "The neighbor is renovating again, which is causing loud noises. Let me know if you need help with anything else!", "seconds": 1.25}
57
+ {"turn": 56, "kind": "distractor", "user": "did you catch the game last night?", "notes": [], "ledger": [], "prefix_tokens": 2200, "answer": "Yes, I caught the game last night! Let me know if you need help with anything else!", "seconds": 1.18}
58
+ {"turn": 57, "kind": "distractor", "user": "traffic was unreal this morning", "notes": [], "ledger": [], "prefix_tokens": 2235, "answer": "Traffic was unreal this morning. Let me know if you need help with anything else!", "seconds": 1.27}
59
+ {"turn": 58, "kind": "distractor", "user": "traffic was unreal this morning", "notes": [], "ledger": [], "prefix_tokens": 2267, "answer": "Traffic was unreal this morning. Let me know if you need help with anything else!", "seconds": 1.12}
60
+ {"turn": 59, "kind": "distractor", "user": "what a week, honestly", "notes": [], "ledger": [], "prefix_tokens": 2299, "answer": "What a week! Honestly, that's a great way to say it. Let me know if you need help with anything else!", "seconds": 1.55}
61
+ {"turn": 60, "kind": "probe", "user": "wait, what was the wifi password again?", "notes": [], "ledger": [], "prefix_tokens": 2344, "answer": "What was the WiFi password again? Let me know if you need help with anything else!", "seconds": 1.05, "key": "wifi password", "expected": "harbor858", "distance": 57, "hit": false}
62
+ {"turn": 61, "kind": "distractor", "user": "finally finished that book", "notes": [], "ledger": [], "prefix_tokens": 2376, "answer": "Finally finished that book! Let me know if you need help with anything else!", "seconds": 0.88}
63
+ {"turn": 62, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 2407, "answer": "They repaved our street. Let me know if you need help with anything else!", "seconds": 1.21}
64
+ {"turn": 63, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 2439, "answer": "They repaved our street. Let me know if you need help with anything else!", "seconds": 1.26}
65
+ {"turn": 64, "kind": "distractor", "user": "saw a great documentary yesterday", "notes": [], "ledger": [], "prefix_tokens": 2472, "answer": "Saw a great documentary yesterday. Let me know if you need help with anything else!", "seconds": 1.37}
66
+ {"turn": 65, "kind": "distractor", "user": "what a week, honestly", "notes": [], "ledger": [], "prefix_tokens": 2505, "answer": "What a week! Honestly, that's a great way to say it. Let me know if you need help with anything else!", "seconds": 1.82}
67
+ {"turn": 66, "kind": "distractor", "user": "thinking of making pasta tonight", "notes": [], "ledger": [], "prefix_tokens": 2546, "answer": "Thinking of making pasta tonight! Let me know if you need help with anything else!", "seconds": 1.04}
68
+ {"turn": 67, "kind": "distractor", "user": "work was chaos today", "notes": [], "ledger": [], "prefix_tokens": 2577, "answer": "Work was chaotic today. Let me know if you need help with anything else!", "seconds": 0.86}
69
+ {"turn": 68, "kind": "distractor", "user": "might go for a walk later", "notes": [], "ledger": [], "prefix_tokens": 2609, "answer": "You might go for a walk later. Let me know if you need help with anything else!", "seconds": 1.2}
70
+ {"turn": 69, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 2647, "answer": "The neighbor is renovating again, which is causing loud noises. Let me know if you need help with anything else!", "seconds": 1.69}
71
+ {"turn": 70, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 2686, "answer": "They repaved our street. Let me know if you need help with anything else!", "seconds": 1.28}
72
+ {"turn": 71, "kind": "probe", "user": "wait, what was the plumber quote again?", "notes": [], "ledger": [], "prefix_tokens": 2722, "answer": "What was the plumber quote again? Let me know if you need help with anything else!", "seconds": 1.21, "key": "plumber quote", "expected": "$4,000", "distance": 37, "hit": false}
73
+ {"turn": 72, "kind": "distractor", "user": "traffic was unreal this morning", "notes": [], "ledger": [], "prefix_tokens": 2755, "answer": "Traffic was unreal this morning. Let me know if you need help with anything else!", "seconds": 1.26}
74
+ {"turn": 73, "kind": "correction", "user": "actually scratch that, the parking spot is spot E16 now", "notes": [], "ledger": [], "prefix_tokens": 2795, "answer": "Actually, the parking spot is now **spot E16**. Let me know if you need help with anything else!", "seconds": 1.43, "key": "parking spot", "value": "spot E16"}
75
+ {"turn": 74, "kind": "distractor", "user": "i love this weather lately", "notes": [], "ledger": [], "prefix_tokens": 2835, "answer": "I love this weather lately! Let me know if you need help with anything else!", "seconds": 1.14}
76
+ {"turn": 75, "kind": "distractor", "user": "craving something sweet", "notes": [], "ledger": [], "prefix_tokens": 2866, "answer": "Craving something sweet! Let me know if you need help with anything else!", "seconds": 0.99}
77
+ {"turn": 76, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 2901, "answer": "The neighbor is renovating again, which is causing loud noises. Let me know if you need help with anything else!", "seconds": 1.44}
78
+ {"turn": 77, "kind": "distractor", "user": "thinking of making pasta tonight", "notes": [], "ledger": [], "prefix_tokens": 2940, "answer": "Thinking of making pasta tonight! Let me know if you need help with anything else!", "seconds": 1.07}
79
+ {"turn": 78, "kind": "distractor", "user": "did you catch the game last night?", "notes": [], "ledger": [], "prefix_tokens": 2975, "answer": "Yes, I caught the game last night! Let me know if you need help with anything else!", "seconds": 1.17}
80
+ {"turn": 79, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 3012, "answer": "Your phone battery is dying so fast now. Let me know if you need help with anything else!", "seconds": 1.43}
81
+ {"turn": 80, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 3049, "answer": "Your phone battery is dying so fast now. Let me know if you need help with anything else!", "seconds": 1.26}
82
+ {"turn": 81, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 3086, "answer": "Your phone battery is dying so fast now. Let me know if you need help with anything else!", "seconds": 1.32}
83
+ {"turn": 82, "kind": "probe", "user": "i forgot the rent due, what was it?", "notes": [], "ledger": [], "prefix_tokens": 3126, "answer": "I forgot the rent due. Let me know if you need help with anything else!", "seconds": 1.3, "key": "rent due", "expected": "day 97", "distance": 47, "hit": false}
84
+ {"turn": 83, "kind": "distractor", "user": "thinking of making pasta tonight", "notes": [], "ledger": [], "prefix_tokens": 3158, "answer": "Thinking of making pasta tonight! Let me know if you need help with anything else!", "seconds": 1.6}
85
+ {"turn": 84, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 3190, "answer": "Coffee is getting expensive now. Let me know if you need help with anything else!", "seconds": 1.44}
86
+ {"turn": 85, "kind": "distractor", "user": "thinking of making pasta tonight", "notes": [], "ledger": [], "prefix_tokens": 3222, "answer": "Thinking of making pasta tonight! Let me know if you need help with anything else!", "seconds": 1.27}
87
+ {"turn": 86, "kind": "correction", "user": "actually scratch that, the plumber quote is $1,400 now", "notes": [], "ledger": [], "prefix_tokens": 3264, "answer": "Actually, the plumber quote is now **$1,400**. Let me know if you need help with anything else!", "seconds": 1.58, "key": "plumber quote", "value": "$1,400"}
88
+ {"turn": 87, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 3308, "answer": "Your phone battery is dying so fast now. Let me know if you need help with anything else!", "seconds": 1.2}
89
+ {"turn": 88, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 3343, "answer": "Coffee is getting expensive now. Let me know if you need help with anything else!", "seconds": 1.13}
90
+ {"turn": 89, "kind": "distractor", "user": "finally finished that book", "notes": [], "ledger": [], "prefix_tokens": 3374, "answer": "Finally finished that book! Let me know if you need help with anything else!", "seconds": 0.95}
91
+ {"turn": 90, "kind": "distractor", "user": "my back hurts from the gym", "notes": [], "ledger": [], "prefix_tokens": 3406, "answer": "Your back hurts from the gym. Let me know if you need help with anything else!", "seconds": 0.98}
92
+ {"turn": 91, "kind": "distractor", "user": "did you catch the game last night?", "notes": [], "ledger": [], "prefix_tokens": 3442, "answer": "Yes, I caught the game last night! Let me know if you need help with anything else!", "seconds": 1.08}
93
+ {"turn": 92, "kind": "distractor", "user": "work was chaos today", "notes": [], "ledger": [], "prefix_tokens": 3476, "answer": "Work was chaotic today. Let me know if you need help with anything else!", "seconds": 0.9}
94
+ {"turn": 93, "kind": "probe", "user": "i forgot the deposit, what was it?", "notes": [], "ledger": [], "prefix_tokens": 3511, "answer": "I forgot the deposit. Let me know if you need help with anything else!", "seconds": 1.29, "key": "deposit", "expected": "$6,400", "distance": 83, "hit": false}
95
+ {"turn": 94, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 3542, "answer": "Coffee is getting expensive now. Let me know if you need help with anything else!", "seconds": 1.42}
96
+ {"turn": 95, "kind": "distractor", "user": "craving something sweet", "notes": [], "ledger": [], "prefix_tokens": 3573, "answer": "Craving something sweet! Let me know if you need help with anything else!", "seconds": 1.07}
97
+ {"turn": 96, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 3608, "answer": "The neighbor is renovating again, which is causing loud noises. Let me know if you need help with anything else!", "seconds": 1.34}
98
+ {"turn": 97, "kind": "probe", "user": "remind me of the hotel room?", "notes": [], "ledger": [], "prefix_tokens": 3650, "answer": "Remind me of the hotel room? Let me know if you need help with anything else!", "seconds": 1.47, "key": "hotel room", "expected": "room 411", "distance": 59, "hit": false}
99
+ {"turn": 98, "kind": "probe", "user": "quick — the hotel room?", "notes": [], "ledger": [], "prefix_tokens": 3685, "answer": "Quick — the hotel room? Let me know if you need help with anything else!", "seconds": 1.2, "key": "hotel room", "expected": "room 411", "distance": 60, "hit": false}
100
+ {"turn": 99, "kind": "probe", "user": "i forgot the wifi password, what was it?", "notes": [], "ledger": [], "prefix_tokens": 3722, "answer": "I forgot the WiFi password. Let me know if you need help with anything else!", "seconds": 1.54, "key": "wifi password", "expected": "harbor858", "distance": 96, "hit": false}
bench/results-qwen3-06b-thinking-full.jsonl ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {"turn": 0, "kind": "fact", "user": "the gym locker is code 4805", "notes": [], "ledger": [], "prefix_tokens": 59, "answer": "the gym locker is code 4805", "seconds": 7.86, "key": "locker code", "value": "code 4805"}
2
+ {"turn": 1, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 84, "answer": "The street was repaved.", "seconds": 8.01}
3
+ {"turn": 2, "kind": "distractor", "user": "what a week, honestly", "notes": [], "ledger": [], "prefix_tokens": 105, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to?", "seconds": 9.42}
4
+ {"turn": 3, "kind": "fact", "user": "cabin wifi password is harbor858", "notes": [], "ledger": [], "prefix_tokens": 149, "answer": "The cabin's WiFi password is **harbor858**.", "seconds": 6.81, "key": "wifi password", "value": "harbor858"}
5
+ {"turn": 4, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 178, "answer": "", "seconds": 36.7}
6
+ {"turn": 5, "kind": "probe", "user": "quick — the locker code?", "notes": [], "ledger": [], "prefix_tokens": 194, "answer": "The locker code is **4805**.", "seconds": 8.86, "key": "locker code", "expected": "code 4805", "distance": 5, "hit": true}
7
+ {"turn": 6, "kind": "distractor", "user": "finally finished that book", "notes": [], "ledger": [], "prefix_tokens": 219, "answer": "The locker code is **4805**. Let me know if you need anything else!", "seconds": 13.18}
8
+ {"turn": 7, "kind": "fact", "user": "we always park at spot B19", "notes": [], "ledger": [], "prefix_tokens": 257, "answer": "The parking spot is **B19**.", "seconds": 5.16, "key": "parking spot", "value": "spot B19"}
9
+ {"turn": 8, "kind": "correction", "user": "actually scratch that, the locker code is code 1688 now", "notes": [], "ledger": [], "prefix_tokens": 292, "answer": "The locker code is now **1688**. Let me know if you need anything else!", "seconds": 13.07, "key": "locker code", "value": "code 1688"}
10
+ {"turn": 9, "kind": "distractor", "user": "the cat knocked over a plant", "notes": [], "ledger": [], "prefix_tokens": 329, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to?", "seconds": 17.58}
11
+ {"turn": 10, "kind": "fact", "user": "the deposit for the venue was $6,400", "notes": [], "ledger": [], "prefix_tokens": 376, "answer": "The deposit for the venue was **$6,400**.", "seconds": 7.27, "key": "deposit", "value": "$6,400"}
12
+ {"turn": 11, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 408, "answer": "The locker code is **4805**. The deposit for the venue was **$6,400**. Let me know if you need anything else!", "seconds": 25.67}
13
+ {"turn": 12, "kind": "distractor", "user": "saw a great documentary yesterday", "notes": [], "ledger": [], "prefix_tokens": 459, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to?", "seconds": 11.36}
14
+ {"turn": 13, "kind": "distractor", "user": "i love this weather lately", "notes": [], "ledger": [], "prefix_tokens": 499, "answer": "I'm glad you're enjoying the weather! Let me know if there's anything specific you'd like to ask or need help with! 😊", "seconds": 13.31}
15
+ {"turn": 14, "kind": "fact", "user": "tomas' new teacher is ms. duarte", "notes": [], "ledger": [], "prefix_tokens": 548, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 11.61, "key": "kid teacher", "value": "ms. duarte"}
16
+ {"turn": 15, "kind": "distractor", "user": "did you catch the game last night?", "notes": [], "ledger": [], "prefix_tokens": 593, "answer": "I don't have information about the game. Could you please provide the specific code or amount you're referring to? 😊", "seconds": 23.6}
17
+ {"turn": 16, "kind": "probe", "user": "remind me of the deposit?", "notes": [], "ledger": [], "prefix_tokens": 635, "answer": "The deposit for the venue was **$6,400**. Let me know if you need anything else! 😊", "seconds": 9.25, "key": "deposit", "expected": "$6,400", "distance": 6, "hit": true}
18
+ {"turn": 17, "kind": "fact", "user": "the plumber quoted $4,900 for the bathroom", "notes": [], "ledger": [], "prefix_tokens": 683, "answer": "The plumber quoted **$4,900** for the bathroom. Let me know if you need anything else! 😊", "seconds": 8.9, "key": "plumber quote", "value": "$4,900"}
19
+ {"turn": 18, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 726, "answer": "The locker code is **4805**. Let me know if you need anything else! 😊", "seconds": 23.26}
20
+ {"turn": 19, "kind": "distractor", "user": "traffic was unreal this morning", "notes": [], "ledger": [], "prefix_tokens": 763, "answer": "The traffic was unreal this morning. Let me know if there's anything specific you'd like to ask or need help with! 😊", "seconds": 12.69}
21
+ {"turn": 20, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 809, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 18.35}
22
+ {"turn": 21, "kind": "fact", "user": "our train leaves from platform 58", "notes": [], "ledger": [], "prefix_tokens": 854, "answer": "The train leaves from **platform 58**. Let me know if you need anything else! 😊", "seconds": 11.41, "key": "train platform", "value": "platform 58"}
23
+ {"turn": 22, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 891, "answer": "The street was repaved. The locker code is **4805**, and the deposit for the venue was **$6,400**. Let me know if you need anything else! 😊", "seconds": 13.2}
24
+ {"turn": 23, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 949, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 10.33}
25
+ {"turn": 24, "kind": "fact", "user": "dentist moved me to 7:15pm", "notes": [], "ledger": [], "prefix_tokens": 997, "answer": "The dentist moved you to **7:15 PM**. The locker code is **4805**, and the deposit for the venue was **$6,400**. Let me know if you need anything else! 😊", "seconds": 11.4, "key": "dentist slot", "value": "7:15pm"}
26
+ {"turn": 25, "kind": "distractor", "user": "saw a great documentary yesterday", "notes": [], "ledger": [], "prefix_tokens": 1063, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 9.5}
27
+ {"turn": 26, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 1107, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 10.51}
28
+ {"turn": 27, "kind": "probe", "user": "quick — the dentist slot?", "notes": [], "ledger": [], "prefix_tokens": 1150, "answer": "The dentist slot was moved to **7:15 PM**. The locker code is **4805**, and the deposit for the venue was **$6,400**. Let me know if you need anything else! 😊", "seconds": 13.88, "key": "dentist slot", "expected": "7:15pm", "distance": 3, "hit": true}
29
+ {"turn": 28, "kind": "fact", "user": "doctor changed the dose to 27mg", "notes": [], "ledger": [], "prefix_tokens": 1220, "answer": "The doctor changed the dose to **27 mg**. The locker code is **4805**, and the deposit for the venue was **$6,400**. Let me know if you need anything else! 😊", "seconds": 11.3, "key": "meds dose", "value": "27mg"}
30
+ {"turn": 29, "kind": "distractor", "user": "might go for a walk later", "notes": [], "ledger": [], "prefix_tokens": 1285, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 16.49}
31
+ {"turn": 30, "kind": "distractor", "user": "finally finished that book", "notes": [], "ledger": [], "prefix_tokens": 1326, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 12.75}
32
+ {"turn": 31, "kind": "fact", "user": "the couch is order ZT6045, keep it handy", "notes": [], "ledger": [], "prefix_tokens": 1377, "answer": "The couch is ordered as **ZT6045**. Let me know if you need anything else! 😊", "seconds": 7.88, "key": "order number", "value": "order ZT6045"}
33
+ {"turn": 32, "kind": "distractor", "user": "what a week, honestly", "notes": [], "ledger": [], "prefix_tokens": 1417, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 11.78}
34
+ {"turn": 33, "kind": "distractor", "user": "my back hurts from the gym", "notes": [], "ledger": [], "prefix_tokens": 1460, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 11.96}
35
+ {"turn": 34, "kind": "correction", "user": "actually scratch that, the plumber quote is $4,000 now", "notes": [], "ledger": [], "prefix_tokens": 1512, "answer": "The plumber's quote is now **$4,000**. The locker code is **4805**, and the deposit for the venue was **$6,400**. Let me know if you need anything else! 😊", "seconds": 23.26, "key": "plumber quote", "value": "$4,000"}
36
+ {"turn": 35, "kind": "fact", "user": "landlord wants rent by day 97 each month", "notes": [], "ledger": [], "prefix_tokens": 1585, "answer": "The landlord wants rent to be paid by **day 97** each month. Let me know if you need further assistance! 😊", "seconds": 14.17, "key": "rent due", "value": "day 97"}
37
+ {"turn": 36, "kind": "distractor", "user": "the cat knocked over a plant", "notes": [], "ledger": [], "prefix_tokens": 1629, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 15.85}
38
+ {"turn": 37, "kind": "distractor", "user": "traffic was unreal this morning", "notes": [], "ledger": [], "prefix_tokens": 1671, "answer": "The traffic was unreal this morning. Let me know if there's anything specific you'd like to ask or need help with! 😊", "seconds": 11.08}
39
+ {"turn": 38, "kind": "fact", "user": "we got room 411 at the hotel", "notes": [], "ledger": [], "prefix_tokens": 1718, "answer": "The room number is **411**. Let me know if you need anything else! 😊", "seconds": 11.08, "key": "hotel room", "value": "room 411"}
40
+ {"turn": 39, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 1758, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 10.53}
41
+ {"turn": 40, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 1800, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 10.66}
42
+ {"turn": 41, "kind": "distractor", "user": "i love this weather lately", "notes": [], "ledger": [], "prefix_tokens": 1842, "answer": "I'm glad you're enjoying the weather! Let me know if there's anything specific you'd like to ask or need help with! 😊", "seconds": 14.22}
43
+ {"turn": 42, "kind": "fact", "user": "boarding is at gate E17", "notes": [], "ledger": [], "prefix_tokens": 1888, "answer": "The boarding is at **Gate E17**. Let me know if you need anything else! 😊", "seconds": 11.3, "key": "boarding gate", "value": "gate E17"}
44
+ {"turn": 43, "kind": "distractor", "user": "what a week, honestly", "notes": [], "ledger": [], "prefix_tokens": 1925, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 11.17}
45
+ {"turn": 44, "kind": "distractor", "user": "the cat knocked over a plant", "notes": [], "ledger": [], "prefix_tokens": 1968, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 9.75}
46
+ {"turn": 45, "kind": "fact", "user": "my sister lands on flight AL7371, write that down", "notes": [], "ledger": [], "prefix_tokens": 2019, "answer": "The flight is AL7371.", "seconds": 7.96, "key": "sister flight", "value": "flight AL7371"}
47
+ {"turn": 46, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 2043, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 10.39}
48
+ {"turn": 47, "kind": "correction", "user": "actually scratch that, the sister flight is flight BL4905 now", "notes": [], "ledger": [], "prefix_tokens": 2095, "answer": "The flight is now **BL4905**. Let me know if you need anything else! 😊", "seconds": 8.43, "key": "sister flight", "value": "flight BL4905"}
49
+ {"turn": 48, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 2133, "answer": "I'm sorry, but I don't have a code or amount to reference. Could you please provide the specific code or amount you're referring to? 😊", "seconds": 11.7}
50
+ {"turn": 49, "kind": "fact", "user": "the rental has plate HJC-988", "notes": [], "ledger": [], "prefix_tokens": 2185, "answer": "The rental has a plate number of **HJC-988**. Let me know if you need anything else! 😊", "seconds": 8.05, "key": "car plate", "value": "HJC-988"}
51
+ {"turn": 50, "kind": "distractor", "user": "my back hurts from the gym", "notes": [], "ledger": [], "prefix_tokens": 2228, "answer": "I'm sorry, but I don't have a code or amount to reference. Could you please provide the specific code or amount you're referring to? 😊", "seconds": 11.07}
52
+ {"turn": 51, "kind": "distractor", "user": "finally finished that book", "notes": [], "ledger": [], "prefix_tokens": 2274, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 10.79}
53
+ {"turn": 52, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 2316, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 10.85}
54
+ {"turn": 53, "kind": "distractor", "user": "work was chaos today", "notes": [], "ledger": [], "prefix_tokens": 2357, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 9.32}
55
+ {"turn": 54, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 2403, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 11.44}
56
+ {"turn": 55, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 2449, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 11.89}
57
+ {"turn": 56, "kind": "distractor", "user": "did you catch the game last night?", "notes": [], "ledger": [], "prefix_tokens": 2494, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 13.07}
58
+ {"turn": 57, "kind": "distractor", "user": "traffic was unreal this morning", "notes": [], "ledger": [], "prefix_tokens": 2536, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 15.01}
59
+ {"turn": 58, "kind": "distractor", "user": "traffic was unreal this morning", "notes": [], "ledger": [], "prefix_tokens": 2578, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 14.12}
60
+ {"turn": 59, "kind": "distractor", "user": "what a week, honestly", "notes": [], "ledger": [], "prefix_tokens": 2620, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 18.2}
61
+ {"turn": 60, "kind": "probe", "user": "wait, what was the wifi password again?", "notes": [], "ledger": [], "prefix_tokens": 2666, "answer": "The cabin's WiFi password is **harbor858**. Let me know if you need anything else! 😊", "seconds": 18.89, "key": "wifi password", "expected": "harbor858", "distance": 57, "hit": true}
62
+ {"turn": 61, "kind": "distractor", "user": "finally finished that book", "notes": [], "ledger": [], "prefix_tokens": 2705, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 19.44}
63
+ {"turn": 62, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 2747, "answer": "The street was repaved. The locker code is **4805**, and the deposit for the venue was **$6,400**. Let me know if you need anything else! 😊", "seconds": 16.64}
64
+ {"turn": 63, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 2805, "answer": "The street was repaved. The locker code is **4805**, and the deposit for the venue was **$6,400**. Let me know if you need anything else! 😊", "seconds": 22.18}
65
+ {"turn": 64, "kind": "distractor", "user": "saw a great documentary yesterday", "notes": [], "ledger": [], "prefix_tokens": 2864, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 12.83}
66
+ {"turn": 65, "kind": "distractor", "user": "what a week, honestly", "notes": [], "ledger": [], "prefix_tokens": 2906, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 17.67}
67
+ {"turn": 66, "kind": "distractor", "user": "thinking of making pasta tonight", "notes": [], "ledger": [], "prefix_tokens": 2948, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 11.21}
68
+ {"turn": 67, "kind": "distractor", "user": "work was chaos today", "notes": [], "ledger": [], "prefix_tokens": 2989, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 13.41}
69
+ {"turn": 68, "kind": "distractor", "user": "might go for a walk later", "notes": [], "ledger": [], "prefix_tokens": 3032, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 12.75}
70
+ {"turn": 69, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 3078, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 10.96}
71
+ {"turn": 70, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 3120, "answer": "The street was repaved. The locker code is **4805**, and the deposit for the venue was **$6,400**. Let me know if you need anything else! 😊", "seconds": 12.0}
72
+ {"turn": 71, "kind": "probe", "user": "wait, what was the plumber quote again?", "notes": [], "ledger": [], "prefix_tokens": 3182, "answer": "The plumber's quote is now **$4,000**. Let me know if you need anything else! 😊", "seconds": 15.18, "key": "plumber quote", "expected": "$4,000", "distance": 37, "hit": true}
73
+ {"turn": 72, "kind": "distractor", "user": "traffic was unreal this morning", "notes": [], "ledger": [], "prefix_tokens": 3223, "answer": "The traffic was unreal this morning. Let me know if there's anything specific you'd like to ask or need help with! 😊", "seconds": 11.76}
74
+ {"turn": 73, "kind": "correction", "user": "actually scratch that, the parking spot is spot E16 now", "notes": [], "ledger": [], "prefix_tokens": 3273, "answer": "The parking spot is now **E16**. Let me know if you need anything else! 😊", "seconds": 10.55, "key": "parking spot", "value": "spot E16"}
75
+ {"turn": 74, "kind": "distractor", "user": "i love this weather lately", "notes": [], "ledger": [], "prefix_tokens": 3310, "answer": "I'm glad you're enjoying the weather! Let me know if there's anything specific you'd like to ask or need help with! 😊", "seconds": 10.43}
76
+ {"turn": 75, "kind": "distractor", "user": "craving something sweet", "notes": [], "ledger": [], "prefix_tokens": 3353, "answer": "I'm glad you're enjoying the weather! Let me know if there's anything sweet you'd like to try or need help with! 😊", "seconds": 12.69}
77
+ {"turn": 76, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 3401, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 9.23}
78
+ {"turn": 77, "kind": "distractor", "user": "thinking of making pasta tonight", "notes": [], "ledger": [], "prefix_tokens": 3443, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 14.04}
79
+ {"turn": 78, "kind": "distractor", "user": "did you catch the game last night?", "notes": [], "ledger": [], "prefix_tokens": 3488, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 11.2}
80
+ {"turn": 79, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 3532, "answer": "The locker code is **4805**, and the deposit for the venue was **$6,400**. Let me know if you need anything else! 😊", "seconds": 13.1}
81
+ {"turn": 80, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 3586, "answer": "The locker code is **4805**, and the deposit for the venue was **$6,400**. Let me know if you need anything else! 😊", "seconds": 9.34}
82
+ {"turn": 81, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 3640, "answer": "The locker code is **4805**, and the deposit for the venue was **$6,400**. Let me know if you need anything else! 😊", "seconds": 11.13}
83
+ {"turn": 82, "kind": "probe", "user": "i forgot the rent due, what was it?", "notes": [], "ledger": [], "prefix_tokens": 3697, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to?", "seconds": 18.73, "key": "rent due", "expected": "day 97", "distance": 47, "hit": false}
84
+ {"turn": 83, "kind": "distractor", "user": "thinking of making pasta tonight", "notes": [], "ledger": [], "prefix_tokens": 3737, "answer": "The locker code is **4805**, and the deposit for the venue was **$6,400**. Let me know if you need anything else! 😊", "seconds": 10.29}
85
+ {"turn": 84, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 3789, "answer": "I'm sorry, but I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 11.15}
86
+ {"turn": 85, "kind": "distractor", "user": "thinking of making pasta tonight", "notes": [], "ledger": [], "prefix_tokens": 3836, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 12.65}
87
+ {"turn": 86, "kind": "correction", "user": "actually scratch that, the plumber quote is $1,400 now", "notes": [], "ledger": [], "prefix_tokens": 3888, "answer": "The plumber's quote is now **$1,400**. Let me know if you need anything else! 😊", "seconds": 8.2, "key": "plumber quote", "value": "$1,400"}
88
+ {"turn": 87, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 3931, "answer": "I don't have a code or", "seconds": 38.62}
89
+ {"turn": 88, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 3953, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 8.41}
90
+ {"turn": 89, "kind": "distractor", "user": "finally finished that book", "notes": [], "ledger": [], "prefix_tokens": 3994, "answer": "The locker code is **4805**, and the deposit for the venue was **$6,400**. Let me know if you need anything else! 😊", "seconds": 15.8}
91
+ {"turn": 90, "kind": "distractor", "user": "my back hurts from the gym", "notes": [], "ledger": [], "prefix_tokens": 4047, "answer": "The locker code is **4805**, and the deposit for the venue was **$6,400**. Let me know if you need anything else! 😊", "seconds": 10.16}
92
+ {"turn": 91, "kind": "distractor", "user": "did you catch the game last night?", "notes": [], "ledger": [], "prefix_tokens": 4102, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 11.88}
93
+ {"turn": 92, "kind": "distractor", "user": "work was chaos today", "notes": [], "ledger": [], "prefix_tokens": 4143, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 10.39}
94
+ {"turn": 93, "kind": "probe", "user": "i forgot the deposit, what was it?", "notes": [], "ledger": [], "prefix_tokens": 4189, "answer": "The deposit for the venue was **$6,400**. Let me know if you need anything else! 😊", "seconds": 10.88, "key": "deposit", "expected": "$6,400", "distance": 83, "hit": true}
95
+ {"turn": 94, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 4230, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 10.12}
96
+ {"turn": 95, "kind": "distractor", "user": "craving something sweet", "notes": [], "ledger": [], "prefix_tokens": 4271, "answer": "I'm glad you're enjoying the weather! Let me know if there's anything sweet you'd like to try or need help with! 😊", "seconds": 13.71}
97
+ {"turn": 96, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 4319, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 17.57}
98
+ {"turn": 97, "kind": "probe", "user": "remind me of the hotel room?", "notes": [], "ledger": [], "prefix_tokens": 4364, "answer": "I don't have a code or amount to reference. Could you please provide the specific code and amount you're referring to? 😊", "seconds": 13.63, "key": "hotel room", "expected": "room 411", "distance": 59, "hit": false}
99
+ {"turn": 98, "kind": "probe", "user": "quick — the hotel room?", "notes": [], "ledger": [], "prefix_tokens": 4407, "answer": "The hotel room code is **4805**. Let me know if you need anything else! 😊", "seconds": 29.88, "key": "hotel room", "expected": "room 411", "distance": 60, "hit": false}
100
+ {"turn": 99, "kind": "probe", "user": "i forgot the wifi password, what was it?", "notes": [], "ledger": [], "prefix_tokens": 4450, "answer": "The WiFi password is **harbor858**. Let me know if you need help with anything else! 😊", "seconds": 9.93, "key": "wifi password", "expected": "harbor858", "distance": 96, "hit": true}
bench/results-smollm2-360m-full.jsonl ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {"turn": 0, "kind": "fact", "user": "the gym locker is code 4805", "notes": [], "ledger": [], "prefix_tokens": 61, "answer": "The gym locker is code 4805.", "seconds": 1.94, "key": "locker code", "value": "code 4805"}
2
+ {"turn": 1, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 89, "answer": "They repaved our street.", "seconds": 0.4}
3
+ {"turn": 2, "kind": "distractor", "user": "what a week, honestly", "notes": [], "ledger": [], "prefix_tokens": 111, "answer": "What a week, honestly.", "seconds": 0.39}
4
+ {"turn": 3, "kind": "fact", "user": "cabin wifi password is harbor858", "notes": [], "ledger": [], "prefix_tokens": 139, "answer": "Cabin WiFi Password: Harbor 858.", "seconds": 0.62, "key": "wifi password", "value": "harbor858"}
5
+ {"turn": 4, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 167, "answer": "They repaved our street.", "seconds": 0.3}
6
+ {"turn": 5, "kind": "probe", "user": "quick — the locker code?", "notes": [], "ledger": [], "prefix_tokens": 191, "answer": "The locker code is 4805.", "seconds": 0.74, "key": "locker code", "expected": "code 4805", "distance": 5, "hit": true}
7
+ {"turn": 6, "kind": "distractor", "user": "finally finished that book", "notes": [], "ledger": [], "prefix_tokens": 218, "answer": "Finished that book.", "seconds": 0.59}
8
+ {"turn": 7, "kind": "fact", "user": "we always park at spot B19", "notes": [], "ledger": [], "prefix_tokens": 242, "answer": "We always park at spot B19.", "seconds": 0.54, "key": "parking spot", "value": "spot B19"}
9
+ {"turn": 8, "kind": "correction", "user": "actually scratch that, the locker code is code 1688 now", "notes": [], "ledger": [], "prefix_tokens": 278, "answer": "The locker code is now code 1688.", "seconds": 0.99, "key": "locker code", "value": "code 1688"}
10
+ {"turn": 9, "kind": "distractor", "user": "the cat knocked over a plant", "notes": [], "ledger": [], "prefix_tokens": 308, "answer": "The cat knocked over a plant.", "seconds": 0.6}
11
+ {"turn": 10, "kind": "fact", "user": "the deposit for the venue was $6,400", "notes": [], "ledger": [], "prefix_tokens": 338, "answer": "The deposit for the venue was $6,400.", "seconds": 0.97, "key": "deposit", "value": "$6,400"}
12
+ {"turn": 11, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 369, "answer": "My phone battery dies so fast now.", "seconds": 0.78}
13
+ {"turn": 12, "kind": "distractor", "user": "saw a great documentary yesterday", "notes": [], "ledger": [], "prefix_tokens": 394, "answer": "Saw a great documentary yesterday.", "seconds": 0.64}
14
+ {"turn": 13, "kind": "distractor", "user": "i love this weather lately", "notes": [], "ledger": [], "prefix_tokens": 417, "answer": "I love this weather lately.", "seconds": 0.44}
15
+ {"turn": 14, "kind": "fact", "user": "tomas' new teacher is ms. duarte", "notes": [], "ledger": [], "prefix_tokens": 444, "answer": "Tomas' new teacher is Ms. Duarte.", "seconds": 0.56, "key": "kid teacher", "value": "ms. duarte"}
16
+ {"turn": 15, "kind": "distractor", "user": "did you catch the game last night?", "notes": [], "ledger": [], "prefix_tokens": 474, "answer": "I caught the game last night.", "seconds": 0.39}
17
+ {"turn": 16, "kind": "probe", "user": "remind me of the deposit?", "notes": [], "ledger": [], "prefix_tokens": 499, "answer": "The deposit is $6,400.", "seconds": 0.72, "key": "deposit", "expected": "$6,400", "distance": 6, "hit": true}
18
+ {"turn": 17, "kind": "fact", "user": "the plumber quoted $4,900 for the bathroom", "notes": [], "ledger": [], "prefix_tokens": 533, "answer": "The plumber quoted $4,900 for the bathroom.", "seconds": 1.03, "key": "plumber quote", "value": "$4,900"}
19
+ {"turn": 18, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 565, "answer": "My phone battery dies so fast now.", "seconds": 0.64}
20
+ {"turn": 19, "kind": "distractor", "user": "traffic was unreal this morning", "notes": [], "ledger": [], "prefix_tokens": 589, "answer": "Traffic was unreal this morning.", "seconds": 0.61}
21
+ {"turn": 20, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 616, "answer": "The neighbor is renovating again, so loud.", "seconds": 0.75}
22
+ {"turn": 21, "kind": "fact", "user": "our train leaves from platform 58", "notes": [], "ledger": [], "prefix_tokens": 645, "answer": "Our train leaves from platform 58.", "seconds": 0.63, "key": "train platform", "value": "platform 58"}
23
+ {"turn": 22, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 670, "answer": "They repaved our street.", "seconds": 0.34}
24
+ {"turn": 23, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 694, "answer": "Coffee here is getting expensive.", "seconds": 0.36}
25
+ {"turn": 24, "kind": "fact", "user": "dentist moved me to 7:15pm", "notes": [], "ledger": [], "prefix_tokens": 723, "answer": "The dentist moved me to 7:15pm.", "seconds": 0.65, "key": "dentist slot", "value": "7:15pm"}
26
+ {"turn": 25, "kind": "distractor", "user": "saw a great documentary yesterday", "notes": [], "ledger": [], "prefix_tokens": 752, "answer": "I saw a great documentary yesterday.", "seconds": 0.47}
27
+ {"turn": 26, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 777, "answer": "My phone battery dies so fast now.", "seconds": 0.38}
28
+ {"turn": 27, "kind": "probe", "user": "quick — the dentist slot?", "notes": [], "ledger": [], "prefix_tokens": 802, "answer": "The dentist slot is 7:15pm.", "seconds": 0.57, "key": "dentist slot", "expected": "7:15pm", "distance": 3, "hit": true}
29
+ {"turn": 28, "kind": "fact", "user": "doctor changed the dose to 27mg", "notes": [], "ledger": [], "prefix_tokens": 833, "answer": "Doctor changed the dose to 27mg.", "seconds": 0.54, "key": "meds dose", "value": "27mg"}
30
+ {"turn": 29, "kind": "distractor", "user": "might go for a walk later", "notes": [], "ledger": [], "prefix_tokens": 860, "answer": "Maybe we could go for a walk later.", "seconds": 0.52}
31
+ {"turn": 30, "kind": "distractor", "user": "finally finished that book", "notes": [], "ledger": [], "prefix_tokens": 885, "answer": "Finished that book.", "seconds": 0.31}
32
+ {"turn": 31, "kind": "fact", "user": "the couch is order ZT6045, keep it handy", "notes": [], "ledger": [], "prefix_tokens": 915, "answer": "The couch is order ZT6045, keep it handy.", "seconds": 0.79, "key": "order number", "value": "order ZT6045"}
33
+ {"turn": 32, "kind": "distractor", "user": "what a week, honestly", "notes": [], "ledger": [], "prefix_tokens": 946, "answer": "What a week, honestly.", "seconds": 0.3}
34
+ {"turn": 33, "kind": "distractor", "user": "my back hurts from the gym", "notes": [], "ledger": [], "prefix_tokens": 969, "answer": "My back hurts from the gym.", "seconds": 0.33}
35
+ {"turn": 34, "kind": "correction", "user": "actually scratch that, the plumber quote is $4,000 now", "notes": [], "ledger": [], "prefix_tokens": 1003, "answer": "The plumber quote is now $4,000.", "seconds": 0.81, "key": "plumber quote", "value": "$4,000"}
36
+ {"turn": 35, "kind": "fact", "user": "landlord wants rent by day 97 each month", "notes": [], "ledger": [], "prefix_tokens": 1038, "answer": "Landlord wants rent by day 97 each month.", "seconds": 0.68, "key": "rent due", "value": "day 97"}
37
+ {"turn": 36, "kind": "distractor", "user": "the cat knocked over a plant", "notes": [], "ledger": [], "prefix_tokens": 1067, "answer": "The cat knocked over a plant.", "seconds": 0.42}
38
+ {"turn": 37, "kind": "distractor", "user": "traffic was unreal this morning", "notes": [], "ledger": [], "prefix_tokens": 1090, "answer": "Traffic was unreal this morning.", "seconds": 0.37}
39
+ {"turn": 38, "kind": "fact", "user": "we got room 411 at the hotel", "notes": [], "ledger": [], "prefix_tokens": 1118, "answer": "We got room 411 at the hotel.", "seconds": 0.54, "key": "hotel room", "value": "room 411"}
40
+ {"turn": 39, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 1149, "answer": "The neighbor is renovating again, so loud.", "seconds": 0.51}
41
+ {"turn": 40, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 1177, "answer": "Coffee here is getting expensive.", "seconds": 0.39}
42
+ {"turn": 41, "kind": "distractor", "user": "i love this weather lately", "notes": [], "ledger": [], "prefix_tokens": 1200, "answer": "I love this weather lately.", "seconds": 0.36}
43
+ {"turn": 42, "kind": "fact", "user": "boarding is at gate E17", "notes": [], "ledger": [], "prefix_tokens": 1224, "answer": "Bearing in mind the weather, I would say that boarding is at gate E17.", "seconds": 1.06, "key": "boarding gate", "value": "gate E17"}
44
+ {"turn": 43, "kind": "distractor", "user": "what a week, honestly", "notes": [], "ledger": [], "prefix_tokens": 1259, "answer": "What a week, honestly.", "seconds": 0.37}
45
+ {"turn": 44, "kind": "distractor", "user": "the cat knocked over a plant", "notes": [], "ledger": [], "prefix_tokens": 1282, "answer": "The cat knocked over a plant.", "seconds": 0.38}
46
+ {"turn": 45, "kind": "fact", "user": "my sister lands on flight AL7371, write that down", "notes": [], "ledger": [], "prefix_tokens": 1314, "answer": "My sister lands on flight AL7371.", "seconds": 0.69, "key": "sister flight", "value": "flight AL7371"}
47
+ {"turn": 46, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 1343, "answer": "Coffee here is getting expensive.", "seconds": 0.45}
48
+ {"turn": 47, "kind": "correction", "user": "actually scratch that, the sister flight is flight BL4905 now", "notes": [], "ledger": [], "prefix_tokens": 1376, "answer": "The sister flight is flight BL4905.", "seconds": 0.6, "key": "sister flight", "value": "flight BL4905"}
49
+ {"turn": 48, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 1405, "answer": "Coffee here is getting expensive.", "seconds": 0.43}
50
+ {"turn": 49, "kind": "fact", "user": "the rental has plate HJC-988", "notes": [], "ledger": [], "prefix_tokens": 1433, "answer": "The rental has plate HJC-988.", "seconds": 0.68, "key": "car plate", "value": "HJC-988"}
51
+ {"turn": 50, "kind": "distractor", "user": "my back hurts from the gym", "notes": [], "ledger": [], "prefix_tokens": 1461, "answer": "My back hurts from the gym.", "seconds": 0.45}
52
+ {"turn": 51, "kind": "distractor", "user": "finally finished that book", "notes": [], "ledger": [], "prefix_tokens": 1484, "answer": "I finally finished that book.", "seconds": 0.48}
53
+ {"turn": 52, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 1508, "answer": "Coffee here is getting expensive.", "seconds": 0.43}
54
+ {"turn": 53, "kind": "distractor", "user": "work was chaos today", "notes": [], "ledger": [], "prefix_tokens": 1530, "answer": "Work was chaos today.", "seconds": 0.28}
55
+ {"turn": 54, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 1555, "answer": "The neighbor is renovating again, so loud.", "seconds": 0.73}
56
+ {"turn": 55, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 1585, "answer": "The neighbor is renovating again, so loud.", "seconds": 0.75}
57
+ {"turn": 56, "kind": "distractor", "user": "did you catch the game last night?", "notes": [], "ledger": [], "prefix_tokens": 1614, "answer": "I didn't catch the game last night.", "seconds": 0.57}
58
+ {"turn": 57, "kind": "distractor", "user": "traffic was unreal this morning", "notes": [], "ledger": [], "prefix_tokens": 1639, "answer": "Traffic was unreal this morning.", "seconds": 0.43}
59
+ {"turn": 58, "kind": "distractor", "user": "traffic was unreal this morning", "notes": [], "ledger": [], "prefix_tokens": 1662, "answer": "Traffic was unreal this morning.", "seconds": 0.48}
60
+ {"turn": 59, "kind": "distractor", "user": "what a week, honestly", "notes": [], "ledger": [], "prefix_tokens": 1685, "answer": "What a week, honestly.", "seconds": 0.47}
61
+ {"turn": 60, "kind": "probe", "user": "wait, what was the wifi password again?", "notes": [], "ledger": [], "prefix_tokens": 1712, "answer": "The wifi password was $4,000.", "seconds": 0.8, "key": "wifi password", "expected": "harbor858", "distance": 57, "hit": false}
62
+ {"turn": 61, "kind": "distractor", "user": "finally finished that book", "notes": [], "ledger": [], "prefix_tokens": 1740, "answer": "I finally finished that book.", "seconds": 0.49}
63
+ {"turn": 62, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 1762, "answer": "They repaved our street.", "seconds": 0.41}
64
+ {"turn": 63, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 1784, "answer": "They repaved our street.", "seconds": 0.44}
65
+ {"turn": 64, "kind": "distractor", "user": "saw a great documentary yesterday", "notes": [], "ledger": [], "prefix_tokens": 1807, "answer": "I saw a great documentary yesterday.", "seconds": 0.59}
66
+ {"turn": 65, "kind": "distractor", "user": "what a week, honestly", "notes": [], "ledger": [], "prefix_tokens": 1830, "answer": "What a week, honestly.", "seconds": 0.5}
67
+ {"turn": 66, "kind": "distractor", "user": "thinking of making pasta tonight", "notes": [], "ledger": [], "prefix_tokens": 1852, "answer": "I think you should make pasta tonight.", "seconds": 0.55}
68
+ {"turn": 67, "kind": "distractor", "user": "work was chaos today", "notes": [], "ledger": [], "prefix_tokens": 1875, "answer": "Work was chaos today.", "seconds": 0.4}
69
+ {"turn": 68, "kind": "distractor", "user": "might go for a walk later", "notes": [], "ledger": [], "prefix_tokens": 1897, "answer": "Maybe we could go for a walk later.", "seconds": 0.69}
70
+ {"turn": 69, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 1926, "answer": "The neighbor is renovating again, so loud.", "seconds": 0.67}
71
+ {"turn": 70, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 1952, "answer": "They repaved our street.", "seconds": 0.4}
72
+ {"turn": 71, "kind": "probe", "user": "wait, what was the plumber quote again?", "notes": [], "ledger": [], "prefix_tokens": 1979, "answer": "The plumber quote was $4,000.", "seconds": 1.0, "key": "plumber quote", "expected": "$4,000", "distance": 37, "hit": true}
73
+ {"turn": 72, "kind": "distractor", "user": "traffic was unreal this morning", "notes": [], "ledger": [], "prefix_tokens": 2007, "answer": "Traffic was unreal this morning.", "seconds": 0.45}
74
+ {"turn": 73, "kind": "correction", "user": "actually scratch that, the parking spot is spot E16 now", "notes": [], "ledger": [], "prefix_tokens": 2038, "answer": "The parking spot is spot E16.", "seconds": 0.57, "key": "parking spot", "value": "spot E16"}
75
+ {"turn": 74, "kind": "distractor", "user": "i love this weather lately", "notes": [], "ledger": [], "prefix_tokens": 2063, "answer": "I love this weather lately.", "seconds": 0.45}
76
+ {"turn": 75, "kind": "distractor", "user": "craving something sweet", "notes": [], "ledger": [], "prefix_tokens": 2084, "answer": "I crave something sweet.", "seconds": 0.34}
77
+ {"turn": 76, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 2109, "answer": "The neighbor is renovating again, so loud.", "seconds": 0.75}
78
+ {"turn": 77, "kind": "distractor", "user": "thinking of making pasta tonight", "notes": [], "ledger": [], "prefix_tokens": 2135, "answer": "I think you should make pasta tonight.", "seconds": 0.58}
79
+ {"turn": 78, "kind": "distractor", "user": "did you catch the game last night?", "notes": [], "ledger": [], "prefix_tokens": 2162, "answer": "I didn't catch the game last night.", "seconds": 0.65}
80
+ {"turn": 79, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 2189, "answer": "My phone battery dies so fast now.", "seconds": 0.62}
81
+ {"turn": 80, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 2215, "answer": "My phone battery dies so fast now.", "seconds": 0.63}
82
+ {"turn": 81, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 2241, "answer": "My phone battery dies so fast now.", "seconds": 0.62}
83
+ {"turn": 82, "kind": "probe", "user": "i forgot the rent due, what was it?", "notes": [], "ledger": [], "prefix_tokens": 2270, "answer": "I forgot the rent due, what was it?", "seconds": 0.81, "key": "rent due", "expected": "day 97", "distance": 47, "hit": false}
84
+ {"turn": 83, "kind": "distractor", "user": "thinking of making pasta tonight", "notes": [], "ledger": [], "prefix_tokens": 2296, "answer": "I think you should make pasta tonight.", "seconds": 0.52}
85
+ {"turn": 84, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 2322, "answer": "Coffee here is getting expensive.", "seconds": 0.43}
86
+ {"turn": 85, "kind": "distractor", "user": "thinking of making pasta tonight", "notes": [], "ledger": [], "prefix_tokens": 2345, "answer": "I think you should make pasta tonight.", "seconds": 0.7}
87
+ {"turn": 86, "kind": "correction", "user": "actually scratch that, the plumber quote is $1,400 now", "notes": [], "ledger": [], "prefix_tokens": 2380, "answer": "The plumber quote is now $1,400.", "seconds": 0.78, "key": "plumber quote", "value": "$1,400"}
88
+ {"turn": 87, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 2411, "answer": "My phone battery dies so fast now.", "seconds": 0.46}
89
+ {"turn": 88, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 2437, "answer": "Coffee here is getting expensive.", "seconds": 0.49}
90
+ {"turn": 89, "kind": "distractor", "user": "finally finished that book", "notes": [], "ledger": [], "prefix_tokens": 2460, "answer": "I finally finished that book.", "seconds": 0.47}
91
+ {"turn": 90, "kind": "distractor", "user": "my back hurts from the gym", "notes": [], "ledger": [], "prefix_tokens": 2483, "answer": "My back hurts from the gym.", "seconds": 0.41}
92
+ {"turn": 91, "kind": "distractor", "user": "did you catch the game last night?", "notes": [], "ledger": [], "prefix_tokens": 2509, "answer": "I didn't catch the game last night.", "seconds": 0.6}
93
+ {"turn": 92, "kind": "distractor", "user": "work was chaos today", "notes": [], "ledger": [], "prefix_tokens": 2533, "answer": "Work was chaos today.", "seconds": 0.39}
94
+ {"turn": 93, "kind": "probe", "user": "i forgot the deposit, what was it?", "notes": [], "ledger": [], "prefix_tokens": 2558, "answer": "I forgot the deposit, what was it?", "seconds": 0.62, "key": "deposit", "expected": "$6,400", "distance": 83, "hit": false}
95
+ {"turn": 94, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 2585, "answer": "Coffee here is getting expensive.", "seconds": 0.42}
96
+ {"turn": 95, "kind": "distractor", "user": "craving something sweet", "notes": [], "ledger": [], "prefix_tokens": 2607, "answer": "I crave something sweet.", "seconds": 0.31}
97
+ {"turn": 96, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 2632, "answer": "The neighbor is renovating again, so loud.", "seconds": 0.6}
98
+ {"turn": 97, "kind": "probe", "user": "remind me of the hotel room?", "notes": [], "ledger": [], "prefix_tokens": 2661, "answer": "I remember the hotel room.", "seconds": 0.4, "key": "hotel room", "expected": "room 411", "distance": 59, "hit": false}
99
+ {"turn": 98, "kind": "probe", "user": "quick — the hotel room?", "notes": [], "ledger": [], "prefix_tokens": 2684, "answer": "I remember the hotel room.", "seconds": 0.41, "key": "hotel room", "expected": "room 411", "distance": 60, "hit": false}
100
+ {"turn": 99, "kind": "probe", "user": "i forgot the wifi password, what was it?", "notes": [], "ledger": [], "prefix_tokens": 2712, "answer": "I forgot the wifi password, what was it?", "seconds": 0.87, "key": "wifi password", "expected": "harbor858", "distance": 96, "hit": false}
bench/results-tinyllama-11b-full.jsonl ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {"turn": 0, "kind": "fact", "user": "the gym locker is code 4805", "notes": [], "ledger": [], "prefix_tokens": 73, "answer": "The gym locker code is 4805.", "seconds": 1.71, "key": "locker code", "value": "code 4805"}
2
+ {"turn": 1, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 108, "answer": "The street that was repaved is 4806.", "seconds": 0.46}
3
+ {"turn": 2, "kind": "distractor", "user": "what a week, honestly", "notes": [], "ledger": [], "prefix_tokens": 144, "answer": "Yes, it was a busy week for you! I hope you had a chance to enjoy some downtime and relax. Have a great weekend!", "seconds": 1.85}
4
+ {"turn": 3, "kind": "fact", "user": "cabin wifi password is harbor858", "notes": [], "ledger": [], "prefix_tokens": 204, "answer": "The cabin wifi password is harbor858.", "seconds": 0.5, "key": "wifi password", "value": "harbor858"}
5
+ {"turn": 4, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 238, "answer": "The street that was repaved is 4806.", "seconds": 0.46}
6
+ {"turn": 5, "kind": "probe", "user": "quick — the locker code?", "notes": [], "ledger": [], "prefix_tokens": 275, "answer": "Yes, the locker code is 4805.", "seconds": 0.53, "key": "locker code", "expected": "code 4805", "distance": 5, "hit": true}
7
+ {"turn": 6, "kind": "distractor", "user": "finally finished that book", "notes": [], "ledger": [], "prefix_tokens": 310, "answer": "Yes, you finished that book! I'm glad you enjoyed it. Have a great day!", "seconds": 0.94}
8
+ {"turn": 7, "kind": "fact", "user": "we always park at spot B19", "notes": [], "ledger": [], "prefix_tokens": 355, "answer": "Yes, you always park at spot B19. I hope you find a parking spot that's convenient for you. Have a great day!", "seconds": 1.22, "key": "parking spot", "value": "spot B19"}
9
+ {"turn": 8, "kind": "correction", "user": "actually scratch that, the locker code is code 1688 now", "notes": [], "ledger": [], "prefix_tokens": 420, "answer": "The locker code is now 1688.", "seconds": 0.48, "key": "locker code", "value": "code 1688"}
10
+ {"turn": 9, "kind": "distractor", "user": "the cat knocked over a plant", "notes": [], "ledger": [], "prefix_tokens": 456, "answer": "The cat knocked over a plant.", "seconds": 0.34}
11
+ {"turn": 10, "kind": "fact", "user": "the deposit for the venue was $6,400", "notes": [], "ledger": [], "prefix_tokens": 495, "answer": "The deposit for the venue is $6,400.", "seconds": 0.6, "key": "deposit", "value": "$6,400"}
12
+ {"turn": 11, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 534, "answer": "Yes, that's a common issue with modern phones. I hope you have a backup battery or a charger to keep your phone charged. Have a great day!", "seconds": 1.29}
13
+ {"turn": 12, "kind": "distractor", "user": "saw a great documentary yesterday", "notes": [], "ledger": [], "prefix_tokens": 593, "answer": "Yes, I watched a great documentary yesterday. It was called \"The Social Dilemma\" and it's a must-watch for anyone interested in social media and its impact on society. Have a great day!", "seconds": 2.2}
14
+ {"turn": 13, "kind": "distractor", "user": "i love this weather lately", "notes": [], "ledger": [], "prefix_tokens": 662, "answer": "Yes, I've been enjoying the warm weather lately. Have a great day!", "seconds": 0.69}
15
+ {"turn": 14, "kind": "fact", "user": "tomas' new teacher is ms. duarte", "notes": [], "ledger": [], "prefix_tokens": 708, "answer": "Yes, Tomas' new teacher is Ms. Duarte. I hope you find her to be a great teacher. Have a great day!", "seconds": 1.24, "key": "kid teacher", "value": "ms. duarte"}
16
+ {"turn": 15, "kind": "distractor", "user": "did you catch the game last night?", "notes": [], "ledger": [], "prefix_tokens": 763, "answer": "Yes, I did catch the game last night. The Red Sox won, but it was a close game. Have a great day!", "seconds": 1.17}
17
+ {"turn": 16, "kind": "probe", "user": "remind me of the deposit?", "notes": [], "ledger": [], "prefix_tokens": 816, "answer": "Sure! The deposit for the venue is $6,400.", "seconds": 0.92, "key": "deposit", "expected": "$6,400", "distance": 6, "hit": true}
18
+ {"turn": 17, "kind": "fact", "user": "the plumber quoted $4,900 for the bathroom", "notes": [], "ledger": [], "prefix_tokens": 865, "answer": "Yes, that's correct. The plumber quoted $4,900 for the bathroom.", "seconds": 1.15, "key": "plumber quote", "value": "$4,900"}
19
+ {"turn": 18, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 911, "answer": "Yes, that's a common issue with modern phones. I hope you have a backup battery or a charger to keep your phone charged. Have a great day!", "seconds": 1.53}
20
+ {"turn": 19, "kind": "distractor", "user": "traffic was unreal this morning", "notes": [], "ledger": [], "prefix_tokens": 970, "answer": "Yes, traffic was unreal this morning. It was a nightmare getting to work. Have a great day!", "seconds": 1.17}
21
+ {"turn": 20, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 1020, "answer": "Yes, the neighbor is renovating again. It's a bit noisy, but it's not too bad. Have a great day!", "seconds": 1.26}
22
+ {"turn": 21, "kind": "fact", "user": "our train leaves from platform 58", "notes": [], "ledger": [], "prefix_tokens": 1075, "answer": "Yes, our train leaves from platform 58. I hope you're on the right train. Have a great day!", "seconds": 1.09, "key": "train platform", "value": "platform 58"}
23
+ {"turn": 22, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 1123, "answer": "Yes, they repaved our street. It's been a while since the last time they did it. Have a great day!", "seconds": 1.15}
24
+ {"turn": 23, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 1174, "answer": "Yes, coffee here is getting expensive. I've been buying it at a local coffee shop for a while now. Have a great day!", "seconds": 1.09}
25
+ {"turn": 24, "kind": "fact", "user": "dentist moved me to 7:15pm", "notes": [], "ledger": [], "prefix_tokens": 1232, "answer": "Yes, the dentist moved you to 7:15pm. I hope you're not too late. Have a great day!", "seconds": 1.23, "key": "dentist slot", "value": "7:15pm"}
26
+ {"turn": 25, "kind": "distractor", "user": "saw a great documentary yesterday", "notes": [], "ledger": [], "prefix_tokens": 1285, "answer": "Yes, I watched a great documentary yesterday. It was called \"The Social Dilemma\" and it's a must-watch for anyone interested in social media and its impact on society. Have a great day!", "seconds": 2.12}
27
+ {"turn": 26, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 1355, "answer": "Yes, that's a common issue with modern phones. I hope you have a backup battery or a charger to keep your phone charged. Have a great day!", "seconds": 1.36}
28
+ {"turn": 27, "kind": "probe", "user": "quick — the dentist slot?", "notes": [], "ledger": [], "prefix_tokens": 1414, "answer": "Yes, the dentist slot is 7:15pm. I hope you're not too late. Have a great day!", "seconds": 1.26, "key": "dentist slot", "expected": "7:15pm", "distance": 3, "hit": true}
29
+ {"turn": 28, "kind": "fact", "user": "doctor changed the dose to 27mg", "notes": [], "ledger": [], "prefix_tokens": 1471, "answer": "Yes, the doctor changed the dose to 27mg. I hope you're taking your medication as prescribed. Have a great day!", "seconds": 1.22, "key": "meds dose", "value": "27mg"}
30
+ {"turn": 29, "kind": "distractor", "user": "might go for a walk later", "notes": [], "ledger": [], "prefix_tokens": 1528, "answer": "Yes, you might go for a walk later. It's a beautiful day outside, and I'm sure you'll enjoy a leisurely stroll. Have a great day!", "seconds": 1.52}
31
+ {"turn": 30, "kind": "distractor", "user": "finally finished that book", "notes": [], "ledger": [], "prefix_tokens": 1590, "answer": "Yes, you finished that book! I'm glad you enjoyed it. Have a great day!", "seconds": 1.01}
32
+ {"turn": 31, "kind": "fact", "user": "the couch is order ZT6045, keep it handy", "notes": [], "ledger": [], "prefix_tokens": 1643, "answer": "Yes, the couch is the ZT6045. I hope you find it comfortable and convenient to sit on. Have a great day!", "seconds": 1.23, "key": "order number", "value": "order ZT6045"}
33
+ {"turn": 32, "kind": "distractor", "user": "what a week, honestly", "notes": [], "ledger": [], "prefix_tokens": 1697, "answer": "Yes, it was a busy week for you! I hope you had a chance to enjoy some downtime and relax. Have a great weekend!", "seconds": 1.16}
34
+ {"turn": 33, "kind": "distractor", "user": "my back hurts from the gym", "notes": [], "ledger": [], "prefix_tokens": 1753, "answer": "Yes, your back probably hurt from the gym. I hope you're able to rest it and avoid any further pain. Have a great day!", "seconds": 1.17}
35
+ {"turn": 34, "kind": "correction", "user": "actually scratch that, the plumber quote is $4,000 now", "notes": [], "ledger": [], "prefix_tokens": 1818, "answer": "Yes, the plumber's quote is $4,000 now. I hope you're able to find a more affordable option. Have a great day!", "seconds": 1.21, "key": "plumber quote", "value": "$4,000"}
36
+ {"turn": 35, "kind": "fact", "user": "landlord wants rent by day 97 each month", "notes": [], "ledger": [], "prefix_tokens": 1883, "answer": "Yes, the landlord wants rent by day 97 each month. I hope you're able to pay it on time and avoid any late fees. Have a great day!", "seconds": 1.57, "key": "rent due", "value": "day 97"}
37
+ {"turn": 36, "kind": "distractor", "user": "the cat knocked over a plant", "notes": [], "ledger": [], "prefix_tokens": 1946, "answer": "Yes, the cat knocked over a plant. I hope you're able to replace it without any damage. Have a great day!", "seconds": 1.19}
38
+ {"turn": 37, "kind": "distractor", "user": "traffic was unreal this morning", "notes": [], "ledger": [], "prefix_tokens": 1998, "answer": "Yes, traffic was unreal this morning. It was a nightmare getting to work. Have a great day!", "seconds": 1.04}
39
+ {"turn": 38, "kind": "fact", "user": "we got room 411 at the hotel", "notes": [], "ledger": [], "prefix_tokens": 2049, "answer": "Yes, we got room 411 at the hotel. I hope you're able to find a comfortable and convenient room. Have a great day!", "seconds": 1.47, "key": "hotel room", "value": "room 411"}
40
+ {"turn": 39, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 2107, "answer": "Yes, the neighbor is renovating again. It's a bit noisy, but it's not too bad. Have a great day!", "seconds": 1.31}
41
+ {"turn": 40, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 2161, "answer": "Yes, coffee here is getting expensive. I've been buying it at a local coffee shop for a while now. Have a great day!", "seconds": 1.27}
42
+ {"turn": 41, "kind": "distractor", "user": "i love this weather lately", "notes": [], "ledger": [], "prefix_tokens": 2214, "answer": "Yes, I love this weather lately. Have a great day. Have,coffee here is getting expensive.coffee here is getting expensive.coffee here, have a great coffee.", "seconds": 1.72}
43
+ {"turn": 42, "kind": "fact", "user": "boarding is at gate E17", "notes": [], "ledger": [], "prefix_tokens": 2281, "answer": "I'm here, coffee|assistant,covers,coffee|coffee, coffee|coffice,coffice\nGetting avenue\n\nYes, coffee|coiling.\n|assistant, coffee|assistant, have,cofficing,ining, \"me, andcold, andcold,fficing, you, apartment, andffing,cofficing, andffs,ffic, no for a, noffic,ffic,oport,ffic,1,going,fficing,apple,fficing,", "seconds": 4.87, "key": "boarding gate", "value": "gate E17"}
44
+ {"turn": 43, "kind": "distractor", "user": "what a week, honestly", "notes": [], "ledger": [], "prefix_tokens": 2424, "answer": "forfonanton16ppofffficmingmingfficantonfficfficppingming,apfficantonponapper", "seconds": 0.94}
45
+ {"turn": 44, "kind": "distractor", "user": "the cat knocked over a plant", "notes": [], "ledger": [], "prefix_tokens": 2470, "answer": "canapping", "seconds": 0.16}
46
+ {"turn": 45, "kind": "fact", "user": "my sister lands on flight AL7371, write that down", "notes": [], "ledger": [], "prefix_tokens": 2503, "answer": "todayppingdingmentutordersmingdersaclesftdersmingmingmingdersdersmerdersmingmingminggercedderspping", "seconds": 0.9, "key": "sister flight", "value": "flight AL7371"}
47
+ {"turn": 46, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 2552, "answer": "stringringmentvoiceppergerpping tonmingcespspspsppedged1111dingcedcedments or1 ormentmentmentficments", "seconds": 1.23}
48
+ {"turn": 47, "kind": "correction", "user": "actually scratch that, the sister flight is flight BL4905 now", "notes": [], "ledger": [], "prefix_tokens": 2617, "answer": "motoriesumspping", "seconds": 0.26, "key": "sister flight", "value": "flight BL4905"}
49
+ {"turn": 48, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 2645, "answer": "199meddeddedums forirsSidenoteSidenotegeddersdersatorator spocedming spocedgedgedgeddersdersmingmingmingmentmingmingmingming referring sentenceppingshesirs9mingders\rusinghesdingdingsteridesirsirsirsirsirsorsors7111 orums or dates orumsoriesumsumsumsidingdingmentums withums91 with mention with7777 myponppings111st letterppings witheing with199777 dutponponppings7 stirsSidenoteSidenoteSidenoteSidenote7", "seconds": 4.56}
50
+ {"turn": 49, "kind": "fact", "user": "the rental has plate HJC-988", "notes": [], "ledger": [], "prefix_tokens": 2797, "answer": "spe fine instant instant instant\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n97\n\n\n\nmy\n\n\n\nmy\nmy\n\n\n\n\nmy my fine my my\nmy relaxing my my my my is is my fine myting my my\n\n\n\n\nusing\n\n\n\nusing my my my my my instant my instant\n\nmy locked my\n\n\n\nusing my my\n\n\n\n\n\n\n\n\n\nlo,", "seconds": 4.75, "key": "car plate", "value": "HJC-988"}
51
+ {"turn": 50, "kind": "distractor", "user": "my back hurts from the gym", "notes": [], "ledger": [], "prefix_tokens": 2928, "answer": "rep rep rep rep rep rep rep rep rep rep rep rep rep rep rep rep\n\n\n\n\n\n\n\n\nrep rep rep rep rep rep p rep p rep rep rep rep\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nsing p", "seconds": 4.73}
52
+ {"turn": 51, "kind": "distractor", "user": "finally finished that book", "notes": [], "ledger": [], "prefix_tokens": 3043, "answer": "my the my p p p p my p, my p rep rep rep rep rep rep rep rep rep rep rep rep rep rep rep rep rep rep rep rep rep rep\r\r\r\r\r rep rep rep rep rep rep rep rep rep rep rep rep rep", "seconds": 4.7}
53
+ {"turn": 52, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 3119, "answer": "pent, my p rep rep rep rep rep rep rep rep rep rep rep rep rep rep rep rep rep rep rep rep rep rep\r\r\r\r\r rep rep rep rep rep rep rep rep rep rep rep rep rep rep my rep rep har har har har har har har p rep p rep har har har har har har har har har har my mytes my my my my my rep my har har har p rep har har har har p p p my har p har p p p p p p p p p har p har p p p p p p p p p p p p p", "seconds": 5.37}
54
+ {"turn": 53, "kind": "distractor", "user": "work was chaos today", "notes": [], "ledger": [], "prefix_tokens": 3261, "answer": "p p p p p p p p p p p p har p p to to p p har har har har p p p p har p p p my har my har my har my har har har har har har har har har har p p har har har har har har har har har har p p har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har", "seconds": 5.01}
55
+ {"turn": 54, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 3407, "answer": "har har har har har har har har har har har har har har har har har har the the har the the my the har har har har the my har har har har har har har har har har my har har har har har har har har har har har har my the har har har har har the har the the the har har har har har har har the har har har har har my har har har har har the har the har har har har har har har har har har the har har har har har har har har my my my har har har har har har har har har har", "seconds": 4.59}
56
+ {"turn": 55, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 3553, "answer": "har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har for the har the the the the har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har har the the the the", "seconds": 5.27}
57
+ {"turn": 56, "kind": "distractor", "user": "did you catch the game last night?", "notes": [], "ledger": [], "prefix_tokens": 3698, "answer": "", "seconds": 0.25}
58
+ {"turn": 57, "kind": "distractor", "user": "traffic was unreal this morning", "notes": [], "ledger": [], "prefix_tokens": 3722, "answer": "", "seconds": 0.17}
59
+ {"turn": 58, "kind": "distractor", "user": "traffic was unreal this morning", "notes": [], "ledger": [], "prefix_tokens": 3746, "answer": "the the the the the the the my", "seconds": 0.53}
60
+ {"turn": 59, "kind": "distractor", "user": "what a week, honestly", "notes": [], "ledger": [], "prefix_tokens": 3777, "answer": "", "seconds": 0.17}
61
+ {"turn": 60, "kind": "probe", "user": "wait, what was the wifi password again?", "notes": [], "ledger": [], "prefix_tokens": 3804, "answer": "my har the the the the the the my the the the", "seconds": 0.73, "key": "wifi password", "expected": "harbor858", "distance": 57, "hit": false}
62
+ {"turn": 61, "kind": "distractor", "user": "finally finished that book", "notes": [], "ledger": [], "prefix_tokens": 3838, "answer": "", "seconds": 0.18}
63
+ {"turn": 62, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 3860, "answer": "*** my my my my the the the the the the the the the the the the the the the my my my the the the the the the the the the the", "seconds": 1.42}
64
+ {"turn": 63, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 3915, "answer": "the the har har the the the the har har har har the the the har the har the har the the the har har the the the the har har the the the the the the the the har the the the the the the the the the the the the the har the har the har the the har the har the har har har har the the the the the the the the the the the the the the the the har the s the s the the the s the p s the s my s the p the the the the the the the the the", "seconds": 4.97}
65
+ {"turn": 64, "kind": "distractor", "user": "saw a great documentary yesterday", "notes": [], "ledger": [], "prefix_tokens": 4051, "answer": "", "seconds": 0.25}
66
+ {"turn": 65, "kind": "distractor", "user": "what a week, honestly", "notes": [], "ledger": [], "prefix_tokens": 4074, "answer": "the cab the har the har the the my the the the the the har the the har the har the the the the the cab cab har", "seconds": 1.46}
67
+ {"turn": 66, "kind": "distractor", "user": "thinking of making pasta tonight", "notes": [], "ledger": [], "prefix_tokens": 4127, "answer": "the the the the the har", "seconds": 0.42}
68
+ {"turn": 67, "kind": "distractor", "user": "work was chaos today", "notes": [], "ledger": [], "prefix_tokens": 4155, "answer": "", "seconds": 0.2}
69
+ {"turn": 68, "kind": "distractor", "user": "might go for a walk later", "notes": [], "ledger": [], "prefix_tokens": 4179, "answer": "my my my my my my my my my my my my my my my my my my my", "seconds": 0.97}
70
+ {"turn": 69, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 4224, "answer": "", "seconds": 0.2}
71
+ {"turn": 70, "kind": "distractor", "user": "they repaved our street", "notes": [], "ledger": [], "prefix_tokens": 4246, "answer": "", "seconds": 0.21}
72
+ {"turn": 71, "kind": "probe", "user": "wait, what was the plumber quote again?", "notes": [], "ledger": [], "prefix_tokens": 4273, "answer": "", "seconds": 0.21, "key": "plumber quote", "expected": "$4,000", "distance": 37, "hit": false}
73
+ {"turn": 72, "kind": "distractor", "user": "traffic was unreal this morning", "notes": [], "ledger": [], "prefix_tokens": 4297, "answer": "", "seconds": 0.21}
74
+ {"turn": 73, "kind": "correction", "user": "actually scratch that, the parking spot is spot E16 now", "notes": [], "ledger": [], "prefix_tokens": 4329, "answer": "", "seconds": 0.21, "key": "parking spot", "value": "spot E16"}
75
+ {"turn": 74, "kind": "distractor", "user": "i love this weather lately", "notes": [], "ledger": [], "prefix_tokens": 4352, "answer": "", "seconds": 0.21}
76
+ {"turn": 75, "kind": "distractor", "user": "craving something sweet", "notes": [], "ledger": [], "prefix_tokens": 4373, "answer": "ven ven ven ven ven ven ven ven ven ven ven ven ven ven ven ven ven ven ven ven ven ven ven ven ven ven ven scratch ven scratch", "seconds": 1.62}
77
+ {"turn": 76, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 4429, "answer": "", "seconds": 0.22}
78
+ {"turn": 77, "kind": "distractor", "user": "thinking of making pasta tonight", "notes": [], "ledger": [], "prefix_tokens": 4454, "answer": "", "seconds": 0.21}
79
+ {"turn": 78, "kind": "distractor", "user": "did you catch the game last night?", "notes": [], "ledger": [], "prefix_tokens": 4479, "answer": "", "seconds": 0.21}
80
+ {"turn": 79, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 4503, "answer": "", "seconds": 0.21}
81
+ {"turn": 80, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 4527, "answer": "", "seconds": 0.21}
82
+ {"turn": 81, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 4551, "answer": "", "seconds": 0.22}
83
+ {"turn": 82, "kind": "probe", "user": "i forgot the rent due, what was it?", "notes": [], "ledger": [], "prefix_tokens": 4578, "answer": "", "seconds": 0.21, "key": "rent due", "expected": "day 97", "distance": 47, "hit": false}
84
+ {"turn": 83, "kind": "distractor", "user": "thinking of making pasta tonight", "notes": [], "ledger": [], "prefix_tokens": 4603, "answer": "", "seconds": 0.21}
85
+ {"turn": 84, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 4627, "answer": "", "seconds": 0.22}
86
+ {"turn": 85, "kind": "distractor", "user": "thinking of making pasta tonight", "notes": [], "ledger": [], "prefix_tokens": 4652, "answer": "", "seconds": 0.22}
87
+ {"turn": 86, "kind": "correction", "user": "actually scratch that, the plumber quote is $1,400 now", "notes": [], "ledger": [], "prefix_tokens": 4686, "answer": "du du du du du du du du du du du du du du du du du du du du", "seconds": 0.93, "key": "plumber quote", "value": "$1,400"}
88
+ {"turn": 87, "kind": "distractor", "user": "my phone battery dies so fast now", "notes": [], "ledger": [], "prefix_tokens": 4730, "answer": "", "seconds": 0.22}
89
+ {"turn": 88, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 4754, "answer": "du du du du du du du du du du du du du du du du du du du", "seconds": 0.9}
90
+ {"turn": 89, "kind": "distractor", "user": "finally finished that book", "notes": [], "ledger": [], "prefix_tokens": 4795, "answer": "", "seconds": 0.23}
91
+ {"turn": 90, "kind": "distractor", "user": "my back hurts from the gym", "notes": [], "ledger": [], "prefix_tokens": 4820, "answer": "", "seconds": 0.22}
92
+ {"turn": 91, "kind": "distractor", "user": "did you catch the game last night?", "notes": [], "ledger": [], "prefix_tokens": 4845, "answer": "du du du du du du du du now m du du du du du du du du du du du m", "seconds": 1.0}
93
+ {"turn": 92, "kind": "distractor", "user": "work was chaos today", "notes": [], "ledger": [], "prefix_tokens": 4889, "answer": "du du du du du du du du du du du du du du du du du du du du du du du du du du du du du du du du du du du du du du", "seconds": 1.49}
94
+ {"turn": 93, "kind": "probe", "user": "i forgot the deposit, what was it?", "notes": [], "ledger": [], "prefix_tokens": 4954, "answer": "", "seconds": 0.22, "key": "deposit", "expected": "$6,400", "distance": 83, "hit": false}
95
+ {"turn": 94, "kind": "distractor", "user": "coffee here is getting expensive", "notes": [], "ledger": [], "prefix_tokens": 4978, "answer": "z z z z du z du z z z z z z z z z z z z z z z z z z", "seconds": 1.28}
96
+ {"turn": 95, "kind": "distractor", "user": "craving something sweet", "notes": [], "ledger": [], "prefix_tokens": 5024, "answer": "", "seconds": 0.22}
97
+ {"turn": 96, "kind": "distractor", "user": "the neighbor is renovating again, so loud", "notes": [], "ledger": [], "prefix_tokens": 5050, "answer": "", "seconds": 0.22}
98
+ {"turn": 97, "kind": "probe", "user": "remind me of the hotel room?", "notes": [], "ledger": [], "prefix_tokens": 5075, "answer": "", "seconds": 0.22, "key": "hotel room", "expected": "room 411", "distance": 59, "hit": false}
99
+ {"turn": 98, "kind": "probe", "user": "quick — the hotel room?", "notes": [], "ledger": [], "prefix_tokens": 5098, "answer": "", "seconds": 0.23, "key": "hotel room", "expected": "room 411", "distance": 60, "hit": false}
100
+ {"turn": 99, "kind": "probe", "user": "i forgot the wifi password, what was it?", "notes": [], "ledger": [], "prefix_tokens": 5126, "answer": "", "seconds": 0.22, "key": "wifi password", "expected": "harbor858", "distance": 96, "hit": false}
marimo-diffusion-0.6b.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f3e8b8fc60b5c27bd8d7299379df6e4f17acf67188ed1f79ac77c2735276a9e8
3
+ size 1192207707
src/diffusion_lm/__init__.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Small masked discrete diffusion language model."""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from diffusion_lm.config import ExperimentConfig, ModelConfig, TrainingConfig, load_config
6
+
7
+ if TYPE_CHECKING:
8
+ from diffusion_lm.model import DiffusionTransformer
9
+
10
+ __all__ = [
11
+ "DiffusionTransformer",
12
+ "ExperimentConfig",
13
+ "ModelConfig",
14
+ "TrainingConfig",
15
+ "load_config",
16
+ ]
17
+
18
+ __version__ = "0.1.0"
19
+
20
+
21
+ def __getattr__(name: str):
22
+ if name == "DiffusionTransformer":
23
+ from diffusion_lm.model import DiffusionTransformer
24
+
25
+ return DiffusionTransformer
26
+ raise AttributeError(name)
src/diffusion_lm/chat_server.py ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OpenAI-compatible chat endpoint over the reasoning engine.
2
+
3
+ Serves ``/v1/chat/completions`` and ``/v1/models`` so any OpenAI-API chat client can talk to a
4
+ hybrid checkpoint with its own sampler — the model cannot run under llama.cpp-family runtimes,
5
+ whose autoregressive decoding never matches the block-denoising objective.
6
+
7
+ Clients resend the full message history on every request and carry no thinking notes, while the
8
+ training layout replaces messages outside the visible window with the ledger of notes taken on
9
+ them. The server therefore caches each turn's notes keyed by a hash of the exact history that
10
+ produced it: a client that resends history verbatim reconstructs the same ledger the playground
11
+ would hold. On a cache miss (server restart, edited history) the older turns simply contribute
12
+ nothing, which is the playground's restart behaviour as well.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import hashlib
19
+ import json
20
+ import threading
21
+ import time
22
+ import uuid
23
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
24
+ from pathlib import Path
25
+
26
+ from diffusion_lm.claims import (
27
+ KEEP_MESSAGES,
28
+ SYSTEM,
29
+ chat_prefix,
30
+ ledger_line,
31
+ ledger_notes,
32
+ merge_notes,
33
+ )
34
+ from diffusion_lm.reasoning_playground import ReasoningEngine
35
+ from diffusion_lm.train import resolve_device
36
+
37
+ MAX_ANSWER_TOKENS = 384
38
+ NOTE_CACHE_LIMIT = 4096
39
+
40
+
41
+ def _turn_key(system: str, messages: list[dict[str, str]]) -> str:
42
+ payload = json.dumps(
43
+ [system] + [[m['role'], m['content']] for m in messages],
44
+ ensure_ascii=False, sort_keys=False,
45
+ )
46
+ return hashlib.sha256(payload.encode('utf-8')).hexdigest()
47
+
48
+
49
+ class ChatService:
50
+ """One engine plus the note cache; generation is serialized on the GPU."""
51
+
52
+ def __init__(self, engine: ReasoningEngine, model_id: str, keep_messages: int) -> None:
53
+ self.engine = engine
54
+ self.model_id = model_id
55
+ self.keep_messages = keep_messages
56
+ self.notes: dict[str, str] = {}
57
+ self.lock = threading.Lock()
58
+
59
+ def _attach_notes(self, system: str, messages: list[dict[str, str]]) -> list[dict[str, str]]:
60
+ attached = []
61
+ for index, message in enumerate(messages):
62
+ entry = {'role': message['role'], 'content': message['content']}
63
+ if message['role'] == 'assistant':
64
+ note = self.notes.get(_turn_key(system, messages[: index + 1]))
65
+ if note:
66
+ entry['note'] = note
67
+ attached.append(entry)
68
+ return attached
69
+
70
+ def build_prefix(self, system: str, messages: list[dict[str, str]]) -> str:
71
+ attached = self._attach_notes(system, messages)
72
+ older = merge_notes(ledger_notes(attached, self.keep_messages))
73
+ window = attached[max(0, len(attached) - self.keep_messages):]
74
+ return chat_prefix(
75
+ [{'role': m['role'], 'content': m['content']} for m in window],
76
+ system=system, extra=ledger_line(older),
77
+ )
78
+
79
+ def remember(self, system: str, messages: list[dict[str, str]],
80
+ answer: str, note: str) -> None:
81
+ if not note:
82
+ return
83
+ if len(self.notes) >= NOTE_CACHE_LIMIT:
84
+ self.notes.clear()
85
+ turn = messages + [{'role': 'assistant', 'content': answer}]
86
+ self.notes[_turn_key(system, turn)] = note
87
+
88
+ def generate(self, system: str, messages: list[dict[str, str]], *, temperature: float,
89
+ top_p: float, max_tokens: int, seed: int):
90
+ """Yield ``(answer_so_far, note, done)``; the note arrives with the final snapshot."""
91
+
92
+ prefix = self.build_prefix(system, messages)
93
+ blocks: list[tuple[int, str]] = []
94
+ answer = ''
95
+ with self.lock:
96
+ for _, answer, _ in self.engine.stream_chat(
97
+ prefix,
98
+ temperature=temperature,
99
+ # 16 measured optimal on qwen06b-genchat-sft (2026-08-12 five-point sweep):
100
+ # best numeric fidelity, clean doubling, half the latency of 32. Below 8 both
101
+ # fidelity and fluency degrade while latency barely moves.
102
+ steps_per_block=16,
103
+ max_answer_tokens=min(MAX_ANSWER_TOKENS, max_tokens),
104
+ top_p=top_p,
105
+ seed=seed,
106
+ blocks_out=blocks,
107
+ ):
108
+ yield answer, '', False
109
+ note = '; '.join(text for _, text in blocks if text)
110
+ self.remember(system, messages, answer.strip(), note)
111
+ yield answer, note, True
112
+
113
+
114
+ def _split_messages(raw: list[dict]) -> tuple[str, list[dict[str, str]]]:
115
+ system = SYSTEM
116
+ messages = []
117
+ for message in raw:
118
+ role, content = message.get('role'), str(message.get('content') or '')
119
+ if role == 'system':
120
+ system = content or system
121
+ elif role in ('user', 'assistant'):
122
+ messages.append({'role': role, 'content': content})
123
+ return system, messages
124
+
125
+
126
+ class Handler(BaseHTTPRequestHandler):
127
+ service: ChatService
128
+
129
+ def log_message(self, format: str, *args) -> None: # noqa: A002
130
+ pass
131
+
132
+ def _json(self, status: int, body: dict) -> None:
133
+ data = json.dumps(body, ensure_ascii=False).encode('utf-8')
134
+ self.send_response(status)
135
+ self.send_header('Content-Type', 'application/json')
136
+ self.send_header('Content-Length', str(len(data)))
137
+ self.send_header('Access-Control-Allow-Origin', '*')
138
+ self.end_headers()
139
+ self.wfile.write(data)
140
+
141
+ def do_OPTIONS(self) -> None: # noqa: N802
142
+ self.send_response(204)
143
+ self.send_header('Access-Control-Allow-Origin', '*')
144
+ self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
145
+ self.send_header('Access-Control-Allow-Headers', 'Content-Type, Authorization')
146
+ self.end_headers()
147
+
148
+ def do_GET(self) -> None: # noqa: N802
149
+ if self.path.rstrip('/') in ('/v1/models', '/models'):
150
+ self._json(200, {'object': 'list', 'data': [
151
+ {'id': self.service.model_id, 'object': 'model', 'owned_by': 'mini-mdlm'},
152
+ ]})
153
+ else:
154
+ self._json(404, {'error': 'not found'})
155
+
156
+ def do_POST(self) -> None: # noqa: N802
157
+ if self.path.rstrip('/') not in ('/v1/chat/completions', '/chat/completions'):
158
+ self._json(404, {'error': 'not found'})
159
+ return
160
+ try:
161
+ length = int(self.headers.get('Content-Length', 0))
162
+ request = json.loads(self.rfile.read(length))
163
+ system, messages = _split_messages(request.get('messages') or [])
164
+ if not messages or messages[-1]['role'] != 'user':
165
+ raise ValueError('last message must be from the user')
166
+ except (ValueError, json.JSONDecodeError) as error:
167
+ self._json(400, {'error': {'message': str(error), 'type': 'invalid_request_error'}})
168
+ return
169
+
170
+ temperature = float(request.get('temperature') or 0.8)
171
+ top_p = float(request.get('top_p') or 0.95)
172
+ max_tokens = int(request.get('max_tokens') or MAX_ANSWER_TOKENS)
173
+ seed = int(request.get('seed') or 0)
174
+ stream = bool(request.get('stream'))
175
+ completion_id = f'chatcmpl-{uuid.uuid4().hex[:24]}'
176
+ created = int(time.time())
177
+
178
+ snapshots = self.service.generate(
179
+ system, messages, temperature=temperature, top_p=top_p,
180
+ max_tokens=max_tokens, seed=seed,
181
+ )
182
+ if not stream:
183
+ answer = note = ''
184
+ for answer, note, _ in snapshots:
185
+ pass
186
+ message = {'role': 'assistant', 'content': answer.strip()}
187
+ if note:
188
+ message['reasoning_content'] = note
189
+ self._json(200, {
190
+ 'id': completion_id, 'object': 'chat.completion', 'created': created,
191
+ 'model': self.service.model_id,
192
+ 'choices': [{'index': 0, 'message': message, 'finish_reason': 'stop'}],
193
+ })
194
+ return
195
+
196
+ self.send_response(200)
197
+ self.send_header('Content-Type', 'text/event-stream')
198
+ self.send_header('Cache-Control', 'no-cache')
199
+ self.send_header('Access-Control-Allow-Origin', '*')
200
+ self.end_headers()
201
+
202
+ def chunk(delta: dict, finish: str | None = None) -> bytes:
203
+ body = {
204
+ 'id': completion_id, 'object': 'chat.completion.chunk', 'created': created,
205
+ 'model': self.service.model_id,
206
+ 'choices': [{'index': 0, 'delta': delta, 'finish_reason': finish}],
207
+ }
208
+ return f'data: {json.dumps(body, ensure_ascii=False)}\n\n'.encode('utf-8')
209
+
210
+ try:
211
+ self.wfile.write(chunk({'role': 'assistant'}))
212
+ sent = ''
213
+ for answer, _, done in snapshots:
214
+ if len(answer) > len(sent):
215
+ self.wfile.write(chunk({'content': answer[len(sent):]}))
216
+ self.wfile.flush()
217
+ sent = answer
218
+ self.wfile.write(chunk({}, finish='stop'))
219
+ self.wfile.write(b'data: [DONE]\n\n')
220
+ except (BrokenPipeError, ConnectionResetError):
221
+ pass
222
+
223
+
224
+ def main() -> None:
225
+ parser = argparse.ArgumentParser(description=__doc__)
226
+ parser.add_argument('--checkpoint', type=Path,
227
+ default=Path('outputs/qwen06b-genchat-sft/inference-latest.pt'))
228
+ parser.add_argument('--tokenizer', type=Path,
229
+ default=Path('artifacts/tokenizer-qwen3-adaptive.json'))
230
+ parser.add_argument('--model-id', default=None,
231
+ help='name reported to clients; defaults to the checkpoint directory')
232
+ parser.add_argument('--keep-messages', type=int, default=KEEP_MESSAGES)
233
+ parser.add_argument('--host', default='127.0.0.1')
234
+ parser.add_argument('--port', type=int, default=7998)
235
+ parser.add_argument('--device', default='auto')
236
+ args = parser.parse_args()
237
+
238
+ device = resolve_device(args.device)
239
+ engine = ReasoningEngine(args.checkpoint, args.tokenizer, device)
240
+ if not engine.chat_ready:
241
+ raise SystemExit(f'{args.checkpoint} is not an adaptive hybrid over a ChatML tokenizer')
242
+ Handler.service = ChatService(
243
+ engine, args.model_id or args.checkpoint.parent.name, args.keep_messages,
244
+ )
245
+ server = ThreadingHTTPServer((args.host, args.port), Handler)
246
+ print(f'serving {Handler.service.model_id} on http://{args.host}:{args.port}/v1')
247
+ server.serve_forever()
248
+
249
+
250
+ if __name__ == '__main__':
251
+ main()
src/diffusion_lm/chatcorpus.py ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Render the general-chat corpus into the adaptive training layout.
2
+
3
+ The corpus (``scripts/gen_chat_dataset.py``) stores conversations as system prompt plus turns,
4
+ each assistant turn carrying its own notes. This module turns them into the layout the hybrid
5
+ objective trains on: one example per assistant turn, whose prefix holds the system prompt, the
6
+ merged ledger of notes whose messages have fallen out of the visible window, and the last
7
+ ``keep_messages`` messages verbatim.
8
+
9
+ Dropping the older messages is the point. With the full transcript in the prefix the model can
10
+ re-read instead of remember and the thinking block stops being memory, which is what
11
+ :func:`diffusion_lm.claims.to_examples` established for the claims corpus. The window and the
12
+ ledger merge are imported from that module rather than reimplemented, so the prefix a training
13
+ example sees is byte-identical to the one the playground builds at inference.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import random
21
+ from collections import Counter
22
+ from pathlib import Path
23
+
24
+ from diffusion_lm.claims import (
25
+ IM_END,
26
+ KEEP_MESSAGES,
27
+ chat_prefix,
28
+ ledger_line,
29
+ ledger_notes,
30
+ merge_notes,
31
+ )
32
+ from diffusion_lm.reasoning import ReasoningExample
33
+
34
+ NOTE_JOIN = '; '
35
+
36
+
37
+ def load(paths: list[Path]) -> list[dict]:
38
+ """Read consolidated corpus files, tagging each conversation with its origin file."""
39
+
40
+ conversations = []
41
+ for path in paths:
42
+ for line in path.open(encoding='utf-8'):
43
+ if not line.strip():
44
+ continue
45
+ conversation = json.loads(line)
46
+ conversation.setdefault('source', path.stem)
47
+ conversations.append(conversation)
48
+ return conversations
49
+
50
+
51
+ def _messages(conversation: dict) -> list[dict[str, str]]:
52
+ """Corpus turns in the message shape the ledger helpers expect.
53
+
54
+ A turn's notes collapse into one ``note`` string joined by ``NOTE_JOIN``, which is the
55
+ separator :func:`diffusion_lm.claims.merge_notes` splits on, so a multi-fact turn still
56
+ contributes one ledger entry per fact.
57
+ """
58
+
59
+ messages = []
60
+ for turn in conversation.get('turns') or []:
61
+ message = {'role': turn['role'], 'content': turn['content']}
62
+ notes = [str(note).strip() for note in (turn.get('thinking') or []) if str(note).strip()]
63
+ if turn['role'] == 'assistant' and notes:
64
+ message['note'] = NOTE_JOIN.join(notes)
65
+ messages.append(message)
66
+ return messages
67
+
68
+
69
+ def to_examples(
70
+ conversation: dict, *, keep_messages: int = KEEP_MESSAGES
71
+ ) -> list[ReasoningExample]:
72
+ """One example per assistant turn, each thinking note becoming its own block.
73
+
74
+ A turn the corpus marked as needing no notes yields an empty chain, which is what teaches
75
+ the controller to answer without opening a thinking block; the encoder accepts it.
76
+ """
77
+
78
+ messages = _messages(conversation)
79
+ turns = conversation.get('turns') or []
80
+ reference = str(conversation.get('reference') or '')
81
+ last = max((i for i, m in enumerate(messages) if m['role'] == 'assistant'), default=-1)
82
+
83
+ examples = []
84
+ for index, message in enumerate(messages):
85
+ if message['role'] != 'assistant' or index == 0:
86
+ continue
87
+ history = messages[:index]
88
+ older = merge_notes(ledger_notes(history, keep_messages))
89
+ window = history[max(0, index - keep_messages):]
90
+ notes = [str(n).strip() for n in (turns[index].get('thinking') or []) if str(n).strip()]
91
+ examples.append(ReasoningExample(
92
+ problem=chat_prefix(window, system=conversation['system'], extra=ledger_line(older)),
93
+ steps=tuple(notes),
94
+ answer=message['content'] + IM_END,
95
+ expected_answer=reference if index == last else '',
96
+ ))
97
+ return examples
98
+
99
+
100
+ def _document(conversation: dict, index: int) -> str:
101
+ """Split key. Two conversations built from one passage share its facts.
102
+
103
+ Splitting by example would leak within a conversation as well, so the whole conversation
104
+ travels together and grounded slices travel with their source item.
105
+ """
106
+
107
+ return str(conversation.get('source_id') or f'{conversation.get("source", "")}-{index}')
108
+
109
+
110
+ def _apply_caps(
111
+ conversations: list[dict], caps: dict[str, int], seed: int
112
+ ) -> list[dict]:
113
+ """Drop conversations so a source contributes at most ``caps[source]`` of them.
114
+
115
+ Capping is by CONVERSATION but the reason is examples: a source's weight in the mix is its
116
+ turn count, not its row count, and the two differ by an order of magnitude (CoQA yields 11.9
117
+ examples per conversation against 1.07 for a single-question source). Sampling is seeded and
118
+ whole conversations travel together, so the split stays document-clean.
119
+ """
120
+
121
+ if not caps:
122
+ return conversations
123
+ rng = random.Random(seed)
124
+ by_source: dict[str, list[int]] = {}
125
+ for index, conversation in enumerate(conversations):
126
+ by_source.setdefault(conversation.get('source', ''), []).append(index)
127
+ dropped: set[int] = set()
128
+ for source, limit in caps.items():
129
+ indices = by_source.get(source)
130
+ if indices is None:
131
+ raise ValueError(f'no conversations carry source {source!r}')
132
+ if len(indices) <= limit:
133
+ print(f'cap {source}={limit}: {len(indices)} present, nothing dropped')
134
+ continue
135
+ dropped |= set(indices) - set(rng.sample(indices, limit))
136
+ print(f'cap {source}={limit}: dropped {len(indices) - limit:,} of {len(indices):,}')
137
+ return [c for index, c in enumerate(conversations) if index not in dropped]
138
+
139
+
140
+ def _parse_caps(pairs: list[str]) -> dict[str, int]:
141
+ caps = {}
142
+ for pair in pairs:
143
+ source, _, count = pair.partition('=')
144
+ if not count.isdigit():
145
+ raise ValueError(f'--cap expects SOURCE=N, got {pair!r}')
146
+ caps[source] = int(count)
147
+ return caps
148
+
149
+
150
+ def prepare(args: argparse.Namespace) -> None:
151
+ import numpy as np
152
+
153
+ from diffusion_lm.reasoning import ExampleEncoder, LayoutSpec, _write_packed, size_token_ids
154
+ from diffusion_lm.tokenizer import load_tokenizer
155
+
156
+ tokenizer = load_tokenizer(args.tokenizer)
157
+ spec = LayoutSpec(seq_len=args.seq_len, block=min(args.sizes), max_slots=args.max_slots,
158
+ sizes=tuple(sorted(args.sizes)))
159
+ encoder = ExampleEncoder(tokenizer, spec)
160
+
161
+ conversations = _apply_caps(load(args.inputs), _parse_caps(args.cap or []), args.seed)
162
+ documents = sorted({_document(c, i) for i, c in enumerate(conversations)})
163
+ rng = random.Random(args.seed)
164
+ rng.shuffle(documents)
165
+ held = set(documents[:max(1, round(len(documents) * args.val_fraction))])
166
+
167
+ split: dict[str, list[tuple]] = {'train': [], 'validation': []}
168
+ dropped = 0
169
+ blocks: Counter[int] = Counter()
170
+ empty_chains = 0
171
+ for index, conversation in enumerate(conversations):
172
+ bucket = 'validation' if _document(conversation, index) in held else 'train'
173
+ for example in to_examples(conversation, keep_messages=args.keep_messages):
174
+ encoded = encoder.encode_adaptive(example)
175
+ if encoded is None:
176
+ dropped += 1
177
+ continue
178
+ blocks.update(encoded.block_sizes)
179
+ empty_chains += not encoded.block_sizes
180
+ split[bucket].append((encoded.tokens, encoded.regions))
181
+
182
+ args.output_dir.mkdir(parents=True, exist_ok=True)
183
+ for name, rows in split.items():
184
+ if not rows:
185
+ raise ValueError(f'no examples in the {name} split')
186
+ _write_packed(
187
+ args.output_dir / f'{name}-adaptive.bin',
188
+ np.stack([tokens for tokens, _ in rows]),
189
+ np.stack([regions for _, regions in rows]),
190
+ layout='adaptive', spec=spec, tokenizer_path=args.tokenizer, tokenizer=tokenizer,
191
+ extra_metadata={
192
+ 'sizes': list(spec.sizes),
193
+ # reasoning_train resolves the adaptive control ids from the pack, not the
194
+ # tokenizer, and refuses a pack without them.
195
+ 'size_token_ids': size_token_ids(tokenizer, spec.sizes),
196
+ 'source': 'general-chat',
197
+ },
198
+ )
199
+ print(f'{name}: {len(rows):,} examples -> {args.output_dir}')
200
+ total = sum(len(rows) for rows in split.values())
201
+ print(f'{len(conversations):,} conversations, {len(documents):,} documents, '
202
+ f'{dropped:,} dropped at encode ({dropped / max(1, dropped + total):.1%})')
203
+ print(f'examples answering with no thinking block: {empty_chains:,} '
204
+ f'({empty_chains / max(1, total):.1%})')
205
+ print('block sizes: ' + ', '.join(f'{size}:{count:,}' for size, count in sorted(blocks.items())))
206
+
207
+
208
+ def main() -> None:
209
+ parser = argparse.ArgumentParser(description=__doc__)
210
+ sub = parser.add_subparsers(dest='command', required=True)
211
+
212
+ prep = sub.add_parser('prepare', help='pack the corpus into the adaptive layout')
213
+ prep.add_argument('--inputs', type=Path, nargs='+', required=True)
214
+ prep.add_argument('--tokenizer', type=Path,
215
+ default=Path('artifacts/tokenizer-qwen3-adaptive.json'))
216
+ prep.add_argument('--output-dir', type=Path, required=True)
217
+ prep.add_argument('--seq-len', type=int, default=2048)
218
+ prep.add_argument('--sizes', type=int, nargs='+', default=[32, 64, 128])
219
+ prep.add_argument('--max-slots', type=int, default=40)
220
+ prep.add_argument('--keep-messages', type=int, default=KEEP_MESSAGES)
221
+ prep.add_argument('--cap', nargs='*', metavar='SOURCE=N',
222
+ help='keep at most N conversations from a source, e.g. ground-coqa=2273; '
223
+ 'weight in the mix is examples, and sources differ ~10x in examples '
224
+ 'per conversation')
225
+ prep.add_argument('--val-fraction', type=float, default=0.02)
226
+ prep.add_argument('--seed', type=int, default=1337)
227
+
228
+ args = parser.parse_args()
229
+ prepare(args)
230
+
231
+
232
+ if __name__ == '__main__':
233
+ main()
src/diffusion_lm/claims.py ADDED
@@ -0,0 +1,512 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Synthetic multi-turn claim intake: dialogues with a known fact set, and a scorer.
2
+
3
+ The target behaviour is memory rather than knowledge: every fact the model must report is
4
+ present in the conversation, so a small model is not being asked to recall the world. Some
5
+ turns supersede an earlier value, which is what separates tracking state from copying the
6
+ last thing seen. Because the record is generated, recall and precision are exact, and no
7
+ judge is needed to score a recap.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import random
15
+ import re
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+
19
+ from diffusion_lm.reasoning import ReasoningExample
20
+
21
+ FIRST_NAMES = ('Marta', 'Diego', 'Luciana', 'Rodrigo', 'Camila', 'Nestor', 'Sofia', 'Ariel')
22
+ LAST_NAMES = ('Quiroga', 'Benitez', 'Salvatierra', 'Uriarte', 'Ferreyra', 'Zabala', 'Otamendi')
23
+ MAKES = (('Peugeot', '208'), ('Toyota', 'Etios'), ('Renault', 'Sandero'), ('Fiat', 'Cronos'),
24
+ ('Chevrolet', 'Onix'), ('Volkswagen', 'Gol'))
25
+ STREETS = ('Av. Rivadavia', 'Calle Mitre', 'Ruta 8', 'Av. San Martin', 'Calle Belgrano')
26
+ CITIES = ('Moron', 'Rosario', 'La Plata', 'Cordoba', 'Bahia Blanca', 'Mendoza')
27
+ DAMAGE_PARTS = ('front bumper', 'left headlight', 'driver door', 'rear hatch', 'right mirror',
28
+ 'windshield', 'rear bumper')
29
+ WEATHER = ('heavy rain', 'clear skies', 'fog', 'light drizzle')
30
+ FILLERS = ('Sorry, one moment.', 'Are you still there?', 'This is my first claim.',
31
+ 'Ok.', 'Thanks for the help.', 'Can you repeat that?')
32
+ ACKS = ('No problem, take your time.', 'Yes, I am here.', 'Understood, please go on.',
33
+ 'Of course.', 'Happy to help.')
34
+
35
+
36
+ @dataclass
37
+ class Claim:
38
+ """One claim record plus the facts a faithful report has to carry."""
39
+
40
+ fields: dict[str, str]
41
+ corrected: dict[str, str] = field(default_factory=dict)
42
+
43
+ @property
44
+ def truth(self) -> dict[str, str]:
45
+ """Field values after corrections, which is what a report must state."""
46
+
47
+ resolved = dict(self.fields)
48
+ resolved.update(self.corrected)
49
+ return resolved
50
+
51
+
52
+ def _plate(rng: random.Random) -> str:
53
+ letters = ''.join(rng.choice('ABCDEFGHJKLMNPRSTUVWXYZ') for _ in range(3))
54
+ return f'{letters}-{rng.randint(1000, 9999)}'
55
+
56
+
57
+ def generate_claim(rng: random.Random) -> Claim:
58
+ """Build a claim whose values are distinctive enough to score by exact match."""
59
+
60
+ make, model = rng.choice(MAKES)
61
+ fields = {
62
+ 'policy_number': f'PL-{rng.randint(10000, 99999)}',
63
+ 'claimant': f'{rng.choice(FIRST_NAMES)} {rng.choice(LAST_NAMES)}',
64
+ 'incident_date': f'{rng.randint(1, 28):02d}/{rng.randint(1, 12):02d}/2026',
65
+ 'incident_time': f'{rng.randint(0, 23):02d}:{rng.choice(("05", "15", "40", "50"))}',
66
+ 'location': f'{rng.choice(STREETS)}, {rng.choice(CITIES)}',
67
+ 'vehicle': f'{make} {model} {rng.randint(2012, 2025)}',
68
+ 'plate': _plate(rng),
69
+ 'weather': rng.choice(WEATHER),
70
+ 'damage': rng.choice(DAMAGE_PARTS),
71
+ 'estimate': f'${rng.randint(2, 40) * 1000 + rng.choice((150, 450, 800)):,}',
72
+ 'other_plate': _plate(rng),
73
+ 'police_report': f'PR-{rng.randint(100000, 999999)}',
74
+ 'witness': f'{rng.choice(FIRST_NAMES)} {rng.choice(LAST_NAMES)}',
75
+ }
76
+ return Claim(fields=fields)
77
+
78
+
79
+ QUESTIONS = {
80
+ 'policy_number': 'Can you give me your policy number?',
81
+ 'claimant': 'Who is the policy holder?',
82
+ 'incident_date': 'What date did this happen?',
83
+ 'incident_time': 'Roughly what time?',
84
+ 'location': 'Where exactly did it happen?',
85
+ 'vehicle': 'Which vehicle was involved?',
86
+ 'plate': "What is your vehicle's plate?",
87
+ 'weather': 'How was the weather at the time?',
88
+ 'damage': 'What part of the car was damaged?',
89
+ 'estimate': 'Do you have a repair estimate?',
90
+ 'other_plate': 'Did you get the other vehicle plate?',
91
+ 'police_report': 'Was a police report filed?',
92
+ 'witness': 'Was there any witness?',
93
+ }
94
+
95
+ ANSWERS = {
96
+ 'policy_number': 'My policy is {value}.',
97
+ 'claimant': 'The holder is {value}.',
98
+ 'incident_date': 'It was on {value}.',
99
+ 'incident_time': 'Around {value}.',
100
+ 'location': 'On {value}.',
101
+ 'vehicle': "It's a {value}.",
102
+ 'plate': 'The plate is {value}.',
103
+ 'weather': 'There was {value}.',
104
+ 'damage': 'The {value} took the hit.',
105
+ 'estimate': 'The shop quoted {value}.',
106
+ 'other_plate': 'Yes, {value}.',
107
+ 'police_report': 'Yes, report {value}.',
108
+ 'witness': '{value} saw everything.',
109
+ }
110
+
111
+
112
+ def render_dialogue(
113
+ claim: Claim, rng: random.Random, *, turns: int = 6, corrections: int = 1,
114
+ chitchat: int = 2,
115
+ ) -> list[dict[str, str]]:
116
+ """Reveal the record across turns, superseding some values along the way.
117
+
118
+ Each turn carries one to three fields, so the amount to remember per turn varies the
119
+ way it would in a real intake.
120
+ """
121
+
122
+ keys = list(claim.fields)
123
+ rng.shuffle(keys)
124
+ # Deliberately uneven: with a constant number of facts per turn every ledger block lands on
125
+ # the same size and the control decision stops carrying information.
126
+ batches: list[list[str]] = []
127
+ while keys:
128
+ take = min(len(keys), rng.choice((1, 1, 2, 3, 4, 5)))
129
+ batches.append(keys[:take])
130
+ keys = keys[take:]
131
+
132
+ messages: list[dict[str, str]] = []
133
+ for batch in batches:
134
+ messages.append({'role': 'assistant', 'content': ' '.join(QUESTIONS[k] for k in batch)})
135
+ said = ' '.join(ANSWERS[k].format(value=claim.fields[k]) for k in batch)
136
+ messages.append({'role': 'user', 'content': said,
137
+ 'values': {k: claim.fields[k] for k in batch}})
138
+
139
+ # Turns that carry no new fact, so the assistant answers with zero thinking blocks. A
140
+ # conversation is full of these and without them the model never learns to skip thinking.
141
+ for _ in range(chitchat):
142
+ at = rng.randrange(1, max(2, len(messages)))
143
+ messages.insert(at, {'role': 'assistant', 'content': rng.choice(ACKS)})
144
+ messages.insert(at, {'role': 'user', 'content': rng.choice(FILLERS), 'values': {}})
145
+
146
+ correctable = [k for k in claim.fields if k in ('plate', 'other_plate', 'estimate',
147
+ 'incident_time', 'police_report')]
148
+ rng.shuffle(correctable)
149
+ for key in correctable[:corrections]:
150
+ if key.endswith('plate'):
151
+ new = _plate(rng)
152
+ elif key == 'estimate':
153
+ new = f'${rng.randint(2, 40) * 1000 + 700:,}'
154
+ elif key == 'incident_time':
155
+ new = f'{rng.randint(0, 23):02d}:30'
156
+ else:
157
+ new = f'PR-{rng.randint(100000, 999999)}'
158
+ claim.corrected[key] = new
159
+ messages.append({
160
+ 'role': 'user',
161
+ 'content': f'Sorry, I misspoke earlier: {ANSWERS[key].format(value=new)} '
162
+ f'Not {claim.fields[key]}.',
163
+ 'values': {key: new},
164
+ })
165
+ # An assistant turn right after the correction is what makes the update a training
166
+ # target: without it the only place the corrected value is taught is the final report.
167
+ messages.append({'role': 'assistant', 'content': 'Noted, I corrected that detail.'})
168
+ return messages
169
+
170
+
171
+ _IDENTIFIERS = re.compile(
172
+ r'(?:P[LR]-\d{5,6}' # policy and police report numbers
173
+ r'|[A-Z]{3}-\d{4}' # plates
174
+ r'|\$[\d,]+' # money
175
+ r'|\d{2}/\d{2}/\d{4}' # dates
176
+ r'|\b\d{2}:\d{2}\b)' # times
177
+ )
178
+
179
+
180
+ def score_report(text: str, claim: Claim) -> dict[str, object]:
181
+ """Exact-match recall of the resolved values, plus the two ways a report lies.
182
+
183
+ ``stale_kept`` reports a value the conversation replaced, which means the model copied
184
+ instead of tracking state. ``invented`` counts identifier-shaped strings that were never
185
+ said at all, which is confabulation rather than a memory slip; they are different
186
+ failures and worth separating.
187
+ """
188
+
189
+ truth = claim.truth
190
+ found = {key: (value in text) for key, value in truth.items()}
191
+ stale = {
192
+ key: (claim.fields[key] in text)
193
+ for key in claim.corrected
194
+ if claim.fields[key] != claim.corrected[key]
195
+ }
196
+ spoken = set(truth.values()) | set(claim.fields.values())
197
+ said_ids = {token for value in spoken for token in _IDENTIFIERS.findall(value)}
198
+ invented = sorted({token for token in _IDENTIFIERS.findall(text)} - said_ids)
199
+ recalled = sum(found.values())
200
+ return {
201
+ 'fields': len(truth),
202
+ 'recalled': recalled,
203
+ 'recall': recalled / max(1, len(truth)),
204
+ 'missing': sorted(k for k, ok in found.items() if not ok),
205
+ 'stale_kept': sorted(k for k, bad in stale.items() if bad),
206
+ 'invented': invented,
207
+ }
208
+
209
+
210
+ LEDGER = '{key}: {value}'
211
+ KEEP_MESSAGES = 4
212
+ IM_START = '<|im_start|>'
213
+ IM_END = '<|im_end|>'
214
+ SYSTEM = (
215
+ 'You are a claim intake assistant. Track every detail the customer gives and always use '
216
+ 'the corrected value when they correct themselves.'
217
+ )
218
+
219
+
220
+ def chatml_turn(role: str, content: str) -> str:
221
+ """One ChatML turn. Both markers are single tokens in the adaptive tokenizer."""
222
+
223
+ return f'{IM_START}{role}\n{content}{IM_END}\n'
224
+
225
+
226
+ def ledger_line(entries: list[str]) -> str:
227
+ """The ``Known so far`` system-turn line carrying facts whose messages were dropped."""
228
+
229
+ return 'Known so far: ' + '; '.join(entries) + '.' if entries else ''
230
+
231
+
232
+ _NOTE_FACT = re.compile(r'^([^:]{1,48}): (.+)$')
233
+
234
+
235
+ def merge_notes(notes: list[str]) -> list[str]:
236
+ """Fold note fragments into one entry per key, latest value winning.
237
+
238
+ Concatenating raw notes would re-expose superseded values, which is the failure the corpus
239
+ charges hardest. Fragments that do not parse as ``key: value`` pass through in order,
240
+ deduplicated verbatim, so an unkeyed reasoning step still reaches the prefix as prose.
241
+
242
+ Both the training renderer and the playground call this. Keeping one implementation is the
243
+ point: a second copy is how the train and inference views of the same history drift apart.
244
+ """
245
+
246
+ facts: dict[str, str] = {}
247
+ loose: list[str] = []
248
+ for note in notes:
249
+ for fragment in note.split('; '):
250
+ fragment = fragment.strip().rstrip('.')
251
+ if not fragment:
252
+ continue
253
+ match = _NOTE_FACT.match(fragment)
254
+ if match:
255
+ facts[match.group(1)] = match.group(2)
256
+ elif fragment not in loose:
257
+ loose.append(fragment)
258
+ return [f'{key}: {value}' for key, value in facts.items()] + loose
259
+
260
+
261
+ def window_start(messages: list[dict[str, str]], keep: int) -> int:
262
+ return 0 if keep <= 0 else max(0, len(messages) - keep)
263
+
264
+
265
+ def ledger_notes(messages: list[dict[str, str]], keep: int) -> list[str]:
266
+ """Notes whose user message fell out of the visible window, in turn order.
267
+
268
+ An assistant turn's note enters the ledger exactly when the user message it took notes on
269
+ has fallen out of the window, which is what makes the note the only remaining carrier.
270
+ """
271
+
272
+ start = window_start(messages, keep)
273
+ return [
274
+ message['note'] for index, message in enumerate(messages)
275
+ if message['role'] == 'assistant' and message.get('note') and index - 1 < start
276
+ ]
277
+
278
+
279
+ def chat_prefix(turns: list[dict[str, str]], *, system: str = SYSTEM, extra: str = '') -> str:
280
+ """Conversation prefix ending where the assistant's generation begins.
281
+
282
+ The accumulated ledger rides in the system turn rather than as a fake dialogue message:
283
+ it is persistent state, and putting it there also teaches the model to condition on a
284
+ system prompt, which none of the other corpora do.
285
+ """
286
+
287
+ merged = system if not extra else f'{system}\n{extra}'
288
+ rendered = [chatml_turn('system', merged)]
289
+ rendered += [chatml_turn(turn['role'], turn['content']) for turn in turns]
290
+ return ''.join(rendered) + f'{IM_START}assistant\n'
291
+
292
+
293
+ def _chunks(items: list, size: int) -> list[list]:
294
+ return [items[i:i + size] for i in range(0, len(items), size)]
295
+
296
+
297
+ def report_text(claim: Claim) -> str:
298
+ """The report a faithful assistant produces, one resolved field per line."""
299
+
300
+ return 'Claim report. ' + ' '.join(
301
+ LEDGER.format(key=key, value=value) + '.' for key, value in claim.truth.items()
302
+ )
303
+
304
+
305
+ def to_examples(
306
+ claim: Claim, messages: list[dict[str, str]], *, keep_messages: int = KEEP_MESSAGES
307
+ ) -> list[ReasoningExample]:
308
+ """One training example per assistant turn, with the history deliberately truncated.
309
+
310
+ Older messages are dropped and replaced by the ledger of what they revealed, so the
311
+ accumulated notes — not the transcript — are what carries the past. That is the whole
312
+ point: with the full transcript in the prefix the model can re-read instead of remember,
313
+ and the thinking block stops being memory. A final example asks for the report, whose
314
+ thinking consolidates every fact.
315
+
316
+ Thought steps hold one fact each, which keeps a block's content short and its length a
317
+ function of how much the turn actually revealed.
318
+ """
319
+
320
+ examples: list[ReasoningExample] = []
321
+ # Values as stated at each point, so a ledger never shows a correction that has not
322
+ # happened yet: training on resolved values would teach the model to know the future.
323
+ known: dict[str, str] = {}
324
+ for index, message in enumerate(messages):
325
+ if message['role'] != 'assistant' or index == 0:
326
+ continue
327
+ previous = messages[index - 1]
328
+ learned = dict(previous.get('values', {}))
329
+ known.update(learned)
330
+ dropped = messages[max(0, index - keep_messages):index]
331
+ seen: dict[str, str] = {}
332
+ for earlier in messages[:max(0, index - keep_messages)]:
333
+ seen.update(earlier.get('values', {}))
334
+ older = [LEDGER.format(key=key, value=value) for key, value in seen.items()]
335
+ examples.append(ReasoningExample(
336
+ problem=chat_prefix(dropped, extra=ledger_line(older)),
337
+ steps=(('; '.join(LEDGER.format(key=key, value=value)
338
+ for key, value in learned.items()),)
339
+ if learned else ()),
340
+ answer=message['content'] + IM_END,
341
+ expected_answer='',
342
+ ))
343
+
344
+ tail = messages[-keep_messages:] + [{'role': 'user', 'content': 'Write the claim report.'}]
345
+ examples.append(ReasoningExample(
346
+ problem=chat_prefix(tail),
347
+ steps=tuple(
348
+ '; '.join(LEDGER.format(key=key, value=value) for key, value in group)
349
+ for group in _chunks(list(claim.truth.items()), 8)
350
+ ),
351
+ answer=report_text(claim) + IM_END,
352
+ expected_answer='',
353
+ ))
354
+ return examples
355
+
356
+
357
+ def build(count: int, seed: int, turns: int, corrections: int) -> list[dict[str, object]]:
358
+ rng = random.Random(seed)
359
+ records = []
360
+ for index in range(count):
361
+ claim = generate_claim(rng)
362
+ messages = render_dialogue(claim, rng, turns=turns, corrections=corrections)
363
+ records.append({
364
+ 'index': index,
365
+ 'messages': messages,
366
+ 'truth': claim.truth,
367
+ 'superseded': {k: claim.fields[k] for k in claim.corrected},
368
+ })
369
+ return records
370
+
371
+
372
+ def prepare(args: argparse.Namespace) -> None:
373
+ """Pack dialogues into the adaptive layout, splitting BY DIALOGUE.
374
+
375
+ Splitting by example would leak: two examples from one dialogue share its facts, so a
376
+ validation example's answer would already appear in a training example's prefix.
377
+ """
378
+
379
+ import numpy as np
380
+
381
+ from diffusion_lm.reasoning import ExampleEncoder, LayoutSpec, _write_packed
382
+ from diffusion_lm.tokenizer import load_tokenizer
383
+
384
+ tokenizer = load_tokenizer(args.tokenizer)
385
+ spec = LayoutSpec(seq_len=args.seq_len, block=min(args.sizes), max_slots=args.max_slots,
386
+ sizes=tuple(sorted(args.sizes)))
387
+ encoder = ExampleEncoder(tokenizer, spec)
388
+ rng = random.Random(args.seed)
389
+
390
+ split: dict[str, list[tuple]] = {'train': [], 'validation': []}
391
+ dropped = 0
392
+ for index in range(args.count):
393
+ claim = generate_claim(rng)
394
+ messages = render_dialogue(claim, rng, turns=args.turns,
395
+ corrections=args.corrections,
396
+ chitchat=args.chitchat)
397
+ bucket = 'validation' if index % args.val_every == 0 else 'train'
398
+ for example in to_examples(claim, messages, keep_messages=args.keep_messages):
399
+ encoded = encoder.encode_adaptive(example)
400
+ if encoded is None:
401
+ dropped += 1
402
+ continue
403
+ split[bucket].append((encoded.tokens, encoded.regions))
404
+
405
+ for name, rows in split.items():
406
+ if not rows:
407
+ raise ValueError(f'no examples in the {name} split')
408
+ _write_packed(
409
+ args.output_dir / f'{name}-adaptive.bin',
410
+ np.stack([tokens for tokens, _ in rows]),
411
+ np.stack([regions for _, regions in rows]),
412
+ layout='adaptive', spec=spec, tokenizer_path=args.tokenizer, tokenizer=tokenizer,
413
+ extra_metadata={'sizes': list(spec.sizes), 'source': 'claims-chatml'},
414
+ )
415
+ print(f'{name}: {len(rows):,} examples -> {args.output_dir}')
416
+ print(f'{dropped:,} dropped at encode')
417
+
418
+
419
+ def mix(args: argparse.Namespace) -> None:
420
+ """Concatenate two packs so claims hold ``--claims-share`` of the examples."""
421
+
422
+ import numpy as np
423
+
424
+ from diffusion_lm.reasoning import regions_path
425
+
426
+ rng = np.random.default_rng(args.seed)
427
+ for name in ('train', 'validation'):
428
+ parts = []
429
+ for directory, share in ((args.claims_dir, args.claims_share), (args.other_dir, None)):
430
+ path = directory / f'{name}-adaptive.bin'
431
+ meta = json.loads(Path(str(path) + '.json').read_text())
432
+ tokens = np.fromfile(path, dtype=np.dtype(meta['dtype'])).reshape(
433
+ meta['example_count'], meta['seq_len']
434
+ )
435
+ parts.append((tokens, np.load(regions_path(path)), share))
436
+ (claims_tokens, claims_regions, share), (other_tokens, other_regions, _) = parts
437
+ target = int(round(share / (1.0 - share) * len(other_tokens)))
438
+ if target < len(claims_tokens):
439
+ keep = rng.choice(len(claims_tokens), size=target, replace=False)
440
+ claims_tokens, claims_regions = claims_tokens[keep], claims_regions[keep]
441
+ tokens = np.concatenate([claims_tokens, other_tokens])
442
+ regions = np.concatenate([claims_regions, other_regions])
443
+ order = rng.permutation(len(tokens))
444
+ tokens, regions = tokens[order], regions[order]
445
+ args.output_dir.mkdir(parents=True, exist_ok=True)
446
+ tokens.tofile(args.output_dir / f'{name}-adaptive.bin')
447
+ np.save(regions_path(args.output_dir / f'{name}-adaptive.bin'), regions)
448
+ meta.update({'example_count': int(len(tokens)), 'source': 'claims+glaive',
449
+ 'claims_examples': int(len(claims_tokens)),
450
+ 'other_examples': int(len(other_tokens))})
451
+ Path(str(args.output_dir / f'{name}-adaptive.bin') + '.json').write_text(
452
+ json.dumps(meta, indent=2) + '\n'
453
+ )
454
+ actual = len(claims_tokens) / len(tokens)
455
+ print(f'{name}: {len(tokens):,} examples, claims share {actual:.3f}')
456
+
457
+
458
+ def main() -> None:
459
+ parser = argparse.ArgumentParser(description=__doc__)
460
+ sub = parser.add_subparsers(dest='command', required=True)
461
+
462
+ dialogues = sub.add_parser('build', help='write dialogues as JSONL for probing')
463
+ dialogues.add_argument('--count', type=int, default=64)
464
+ dialogues.add_argument('--seed', type=int, default=1337)
465
+ dialogues.add_argument('--turns', type=int, default=6)
466
+ dialogues.add_argument('--corrections', type=int, default=1)
467
+ dialogues.add_argument('--output', type=Path, required=True)
468
+
469
+ pack = sub.add_parser('prepare', help='pack dialogues into the adaptive layout')
470
+ pack.add_argument('--count', type=int, default=2700)
471
+ pack.add_argument('--seed', type=int, default=1337)
472
+ pack.add_argument('--turns', type=int, default=6)
473
+ pack.add_argument('--corrections', type=int, default=1)
474
+ pack.add_argument('--chitchat', type=int, default=2,
475
+ help='turns with no new fact, which train zero-block answers')
476
+ pack.add_argument('--keep-messages', type=int, default=KEEP_MESSAGES)
477
+ pack.add_argument('--tokenizer', type=Path, required=True)
478
+ pack.add_argument('--output-dir', type=Path, required=True)
479
+ pack.add_argument('--seq-len', type=int, default=2048)
480
+ pack.add_argument('--max-slots', type=int, default=64)
481
+ pack.add_argument('--sizes', type=int, nargs='+', default=[32, 64, 128])
482
+ pack.add_argument('--val-every', type=int, default=20,
483
+ help='every Nth dialogue goes to validation, whole')
484
+
485
+ blend = sub.add_parser('mix', help='blend a claims pack into another pack')
486
+ blend.add_argument('--claims-dir', type=Path, required=True)
487
+ blend.add_argument('--other-dir', type=Path, required=True)
488
+ blend.add_argument('--output-dir', type=Path, required=True)
489
+ blend.add_argument('--claims-share', type=float, default=0.30)
490
+ blend.add_argument('--seed', type=int, default=1337)
491
+
492
+ args = parser.parse_args()
493
+ if args.command == 'prepare':
494
+ prepare(args)
495
+ return
496
+ if args.command == 'mix':
497
+ mix(args)
498
+ return
499
+
500
+ records = build(args.count, args.seed, args.turns, args.corrections)
501
+ args.output.parent.mkdir(parents=True, exist_ok=True)
502
+ with args.output.open('w', encoding='utf-8') as handle:
503
+ for record in records:
504
+ handle.write(json.dumps(record, ensure_ascii=False) + '\n')
505
+ turns = sum(len(r['messages']) for r in records) / len(records)
506
+ facts = sum(len(r['truth']) for r in records) / len(records)
507
+ print(f'wrote {len(records)} dialogues to {args.output} '
508
+ f'({turns:.1f} messages and {facts:.1f} facts each)')
509
+
510
+
511
+ if __name__ == '__main__':
512
+ main()
src/diffusion_lm/config.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Typed experiment configuration loaded from YAML."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import yaml
10
+
11
+
12
+ MODEL_BACKBONES = ("project", "hf-qwen3")
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class ModelConfig:
17
+ """Architecture for the bidirectional token denoiser."""
18
+
19
+ vocab_size: int
20
+ mask_token_id: int
21
+ max_seq_len: int
22
+ d_model: int
23
+ n_layers: int
24
+ n_heads: int
25
+ d_ff: int
26
+ dropout: float = 0.1
27
+ tie_embeddings: bool = True
28
+ activation_checkpointing: bool = False
29
+ use_flex_attention: bool = False
30
+ compile_backbone: bool = False
31
+ forbidden_output_token_ids: tuple[int, ...] = (0, 1, 2, 4)
32
+ backbone: str = "project"
33
+ pretrained_path: str | None = None
34
+ # First id of the untrained tail of a pretrained embedding matrix; logits from that id
35
+ # upward are masked without enumerating hundreds of forbidden ids.
36
+ forbidden_output_from: int | None = None
37
+
38
+ def __post_init__(self) -> None:
39
+ if self.vocab_size <= 1:
40
+ raise ValueError("vocab_size must be greater than one")
41
+ if self.backbone not in MODEL_BACKBONES:
42
+ raise ValueError(f"backbone must be one of {MODEL_BACKBONES}")
43
+ if self.backbone != "project" and self.pretrained_path is None:
44
+ raise ValueError("pretrained backbones require pretrained_path")
45
+ if self.forbidden_output_from is not None and not (
46
+ 0 < self.forbidden_output_from <= self.vocab_size
47
+ ):
48
+ raise ValueError("forbidden_output_from must be inside the vocabulary")
49
+ if not 0 <= self.mask_token_id < self.vocab_size:
50
+ raise ValueError("mask_token_id must be inside the vocabulary")
51
+ if self.max_seq_len <= 0:
52
+ raise ValueError("max_seq_len must be positive")
53
+ if self.d_model <= 0 or self.n_layers <= 0 or self.n_heads <= 0 or self.d_ff <= 0:
54
+ raise ValueError("all model dimensions must be positive")
55
+ if self.d_model % self.n_heads != 0:
56
+ raise ValueError("d_model must be divisible by n_heads")
57
+ if not 0.0 <= self.dropout < 1.0:
58
+ raise ValueError("dropout must be in [0, 1)")
59
+ if not isinstance(self.activation_checkpointing, bool):
60
+ raise ValueError("activation_checkpointing must be a boolean")
61
+ if not isinstance(self.use_flex_attention, bool):
62
+ raise ValueError("use_flex_attention must be a boolean")
63
+ if not isinstance(self.compile_backbone, bool):
64
+ raise ValueError('compile_backbone must be a boolean')
65
+
66
+ forbidden_ids = tuple(self.forbidden_output_token_ids)
67
+ if any(
68
+ isinstance(token_id, bool) or not isinstance(token_id, int)
69
+ for token_id in forbidden_ids
70
+ ):
71
+ raise ValueError("forbidden_output_token_ids must contain integers")
72
+ if len(set(forbidden_ids)) != len(forbidden_ids):
73
+ raise ValueError("forbidden_output_token_ids must not contain duplicates")
74
+ if any(not 0 <= token_id < self.vocab_size for token_id in forbidden_ids):
75
+ raise ValueError("forbidden output token ids must be inside the vocabulary")
76
+ if self.mask_token_id not in forbidden_ids:
77
+ raise ValueError("mask_token_id must be a forbidden output token")
78
+ if self.backbone == "project" and 3 in forbidden_ids:
79
+ raise ValueError("EOS token id 3 must remain an allowed output token")
80
+ object.__setattr__(self, "forbidden_output_token_ids", forbidden_ids)
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class TrainingConfig:
85
+ """Single-device training settings for the first research iteration."""
86
+
87
+ train_data: str
88
+ tokenizer: str
89
+ output_dir: str = "outputs/run"
90
+ val_data: str | None = None
91
+ batch_size: int = 32
92
+ gradient_accumulation_steps: int = 1
93
+ max_steps: int = 10_000
94
+ learning_rate: float = 3e-4
95
+ min_learning_rate: float = 3e-5
96
+ warmup_steps: int = 500
97
+ weight_decay: float = 0.1
98
+ grad_clip: float = 1.0
99
+ mask_eps: float = 1e-3
100
+ seed: int = 1337
101
+ device: str = "auto"
102
+ precision: str = "auto"
103
+ optimizer: str = "adamw"
104
+ optimizer_min_8bit_size: int = 4096
105
+ optimizer_embedding_32bit: bool = True
106
+ require_fused_attention: bool = False
107
+ save_inference_checkpoint: bool = False
108
+ num_workers: int = 0
109
+ log_interval: int = 10
110
+ eval_interval: int = 500
111
+ eval_batches: int = 20
112
+ save_interval: int = 500
113
+ keep_last_checkpoints: int = 3
114
+
115
+ def __post_init__(self) -> None:
116
+ if self.batch_size <= 0 or self.gradient_accumulation_steps <= 0:
117
+ raise ValueError("batch sizes must be positive")
118
+ if self.max_steps <= 0:
119
+ raise ValueError("max_steps must be positive")
120
+ if not 0.0 < self.learning_rate:
121
+ raise ValueError("learning_rate must be positive")
122
+ if not 0.0 <= self.min_learning_rate <= self.learning_rate:
123
+ raise ValueError("min_learning_rate must be between zero and learning_rate")
124
+ if not 0 <= self.warmup_steps < self.max_steps:
125
+ raise ValueError("warmup_steps must be non-negative and less than max_steps")
126
+ if self.weight_decay < 0.0:
127
+ raise ValueError("weight_decay must be non-negative")
128
+ if self.grad_clip <= 0.0:
129
+ raise ValueError("grad_clip must be positive")
130
+ if not 0.0 < self.mask_eps < 1.0:
131
+ raise ValueError("mask_eps must be in (0, 1)")
132
+ if self.num_workers < 0:
133
+ raise ValueError("num_workers must be non-negative")
134
+ if self.log_interval <= 0 or self.eval_interval <= 0 or self.save_interval <= 0:
135
+ raise ValueError("log, eval, and save intervals must be positive")
136
+ if self.eval_batches <= 0:
137
+ raise ValueError("eval_batches must be positive")
138
+ if self.keep_last_checkpoints < 0:
139
+ raise ValueError("keep_last_checkpoints must be non-negative")
140
+ if self.precision not in {"auto", "float32", "bfloat16", "float16"}:
141
+ raise ValueError("precision must be auto, float32, bfloat16, or float16")
142
+ if self.optimizer not in {"adamw", "adamw8bit"}:
143
+ raise ValueError("optimizer must be adamw or adamw8bit")
144
+ if self.optimizer_min_8bit_size <= 0:
145
+ raise ValueError("optimizer_min_8bit_size must be positive")
146
+ if not isinstance(self.optimizer_embedding_32bit, bool):
147
+ raise ValueError("optimizer_embedding_32bit must be a boolean")
148
+ if not isinstance(self.require_fused_attention, bool):
149
+ raise ValueError("require_fused_attention must be a boolean")
150
+ if not isinstance(self.save_inference_checkpoint, bool):
151
+ raise ValueError("save_inference_checkpoint must be a boolean")
152
+
153
+
154
+ @dataclass(frozen=True)
155
+ class ExperimentConfig:
156
+ model: ModelConfig
157
+ training: TrainingConfig
158
+
159
+ def to_dict(self) -> dict[str, Any]:
160
+ return asdict(self)
161
+
162
+
163
+ def load_config(path: str | Path) -> ExperimentConfig:
164
+ """Load and validate an experiment YAML file."""
165
+
166
+ config_path = Path(path)
167
+ with config_path.open("r", encoding="utf-8") as handle:
168
+ raw = yaml.safe_load(handle)
169
+ if not isinstance(raw, dict) or "model" not in raw or "training" not in raw:
170
+ raise ValueError("config must contain top-level model and training mappings")
171
+ return ExperimentConfig(
172
+ model=ModelConfig(**raw["model"]),
173
+ training=TrainingConfig(**raw["training"]),
174
+ )
src/diffusion_lm/corpus.py ADDED
@@ -0,0 +1,920 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Download and prepare the pinned FineWeb-Edu 10B sample.
2
+
3
+ The heavy ``huggingface_hub`` and ``pyarrow`` dependencies are imported only by the operations that
4
+ need them. Raw Parquet files are cached locally, then converted into independently replaceable
5
+ uint16/uint32 shards with deterministic document-level train/validation assignment.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import hashlib
12
+ import json
13
+ import os
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Any, Iterable, Iterator, Literal, Sequence
17
+
18
+ import numpy as np
19
+
20
+ from diffusion_lm.data import PACKED_MANIFEST_FORMAT
21
+ from diffusion_lm.tokenizer import (
22
+ load_tokenizer,
23
+ special_token_ids,
24
+ train_tokenizer_from_iterator,
25
+ )
26
+
27
+
28
+ FINEWEB_EDU_REPO_ID = "HuggingFaceFW/fineweb-edu"
29
+ FINEWEB_EDU_CONFIG = "sample-10BT"
30
+ FINEWEB_EDU_REVISION = "87f09149ef4734204d70ed1d046ddc9ca3f2b8f9"
31
+ FINEWEB_EDU_PATH_PREFIX = "sample/10BT/"
32
+ SOURCE_STATE_FORMAT = "mini-diffusion-lm-corpus-source-v1"
33
+ SPLIT_HASH_PERSON = b"mini-mdlm-split"
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class CorpusSource:
38
+ """One pinned Hub dataset: where its Parquets live and how rows are read.
39
+
40
+ ``id_column=None`` derives the split id from a sha256 of the text, which keeps the
41
+ train/validation assignment order-independent for datasets without a stable row id.
42
+ """
43
+
44
+ name: str
45
+ repo_id: str
46
+ revision: str
47
+ path_prefix: str
48
+ text_column: str = "text"
49
+ id_column: str | None = "id"
50
+ config: str | None = None
51
+
52
+
53
+ CORPUS_SOURCES: dict[str, CorpusSource] = {
54
+ source.name: source
55
+ for source in (
56
+ CorpusSource(
57
+ name="fineweb-edu",
58
+ repo_id=FINEWEB_EDU_REPO_ID,
59
+ revision=FINEWEB_EDU_REVISION,
60
+ path_prefix=FINEWEB_EDU_PATH_PREFIX,
61
+ config=FINEWEB_EDU_CONFIG,
62
+ ),
63
+ CorpusSource(
64
+ name="ultra-fineweb-en",
65
+ repo_id="openbmb/Ultra-FineWeb",
66
+ revision="7ddd4170ce03e0afbd7d9b80d4bc0b8eebf877e4",
67
+ path_prefix="data/ultrafineweb_en/",
68
+ text_column="content",
69
+ id_column=None,
70
+ ),
71
+ CorpusSource(
72
+ name="cosmopedia-v2",
73
+ repo_id="HuggingFaceTB/smollm-corpus",
74
+ revision="3ba9d605774198c5868892d7a8deda78031a781f",
75
+ path_prefix="cosmopedia-v2/",
76
+ id_column=None,
77
+ config="cosmopedia-v2",
78
+ ),
79
+ CorpusSource(
80
+ name="finemath-4plus",
81
+ repo_id="HuggingFaceTB/finemath",
82
+ revision="e92b25a616738fe95dc186b64dfb19f9c8525594",
83
+ path_prefix="finemath-4plus/",
84
+ id_column=None,
85
+ config="finemath-4plus",
86
+ ),
87
+ )
88
+ }
89
+
90
+
91
+ def _require_huggingface_hub():
92
+ try:
93
+ from huggingface_hub import HfApi, snapshot_download
94
+ except ImportError as exc: # pragma: no cover - exercised in minimal installations.
95
+ raise RuntimeError(
96
+ "FineWeb-Edu download requires huggingface_hub; install the corpus dependencies"
97
+ ) from exc
98
+ return HfApi, snapshot_download
99
+
100
+
101
+ def _require_parquet():
102
+ try:
103
+ import pyarrow.parquet as parquet
104
+ except ImportError as exc: # pragma: no cover - exercised in minimal installations.
105
+ raise RuntimeError(
106
+ "FineWeb-Edu preparation requires pyarrow; install the corpus dependencies"
107
+ ) from exc
108
+ return parquet
109
+
110
+
111
+ def _atomic_json(path: Path, value: dict[str, Any]) -> None:
112
+ path.parent.mkdir(parents=True, exist_ok=True)
113
+ temporary = path.with_name(f".{path.name}.tmp")
114
+ temporary.unlink(missing_ok=True)
115
+ with temporary.open("w", encoding="utf-8") as handle:
116
+ json.dump(value, handle, indent=2, sort_keys=True)
117
+ handle.write("\n")
118
+ handle.flush()
119
+ os.fsync(handle.fileno())
120
+ temporary.replace(path)
121
+
122
+
123
+ def _read_json(path: Path) -> dict[str, Any]:
124
+ try:
125
+ with path.open("r", encoding="utf-8") as handle:
126
+ value = json.load(handle)
127
+ except (OSError, json.JSONDecodeError) as exc:
128
+ raise ValueError(f"could not read corpus state {path}: {exc}") from exc
129
+ if not isinstance(value, dict):
130
+ raise ValueError(f"corpus state must be a JSON object: {path}")
131
+ return value
132
+
133
+
134
+ def _sha256_file(path: Path, *, chunk_size: int = 1024 * 1024) -> str:
135
+ digest = hashlib.sha256()
136
+ with path.open("rb") as handle:
137
+ for chunk in iter(lambda: handle.read(chunk_size), b""):
138
+ digest.update(chunk)
139
+ return digest.hexdigest()
140
+
141
+
142
+ def download_source(
143
+ source: CorpusSource,
144
+ raw_dir: str | Path,
145
+ *,
146
+ max_files: int | None = None,
147
+ max_workers: int = 1,
148
+ ) -> list[Path]:
149
+ """Download a pinned source's Parquets into ``raw_dir`` using the Hub cache."""
150
+
151
+ if max_files is not None and max_files <= 0:
152
+ raise ValueError("max_files must be positive")
153
+ if max_workers <= 0:
154
+ raise ValueError("max_workers must be positive")
155
+ HfApi, snapshot_download = _require_huggingface_hub()
156
+ api = HfApi()
157
+ repository_files = api.list_repo_files(
158
+ repo_id=source.repo_id,
159
+ repo_type="dataset",
160
+ revision=source.revision,
161
+ )
162
+ parquet_names = sorted(
163
+ name
164
+ for name in repository_files
165
+ if name.startswith(source.path_prefix) and name.endswith(".parquet")
166
+ )
167
+ if max_files is not None:
168
+ parquet_names = parquet_names[:max_files]
169
+ if not parquet_names:
170
+ raise RuntimeError(
171
+ f"no Parquet files found for {source.repo_id}@{source.revision} "
172
+ f"under {source.path_prefix}"
173
+ )
174
+
175
+ destination = Path(raw_dir)
176
+ destination.mkdir(parents=True, exist_ok=True)
177
+ snapshot_root = Path(
178
+ snapshot_download(
179
+ repo_id=source.repo_id,
180
+ repo_type="dataset",
181
+ revision=source.revision,
182
+ allow_patterns=parquet_names,
183
+ local_dir=str(destination),
184
+ max_workers=max_workers,
185
+ )
186
+ )
187
+ paths: list[Path] = []
188
+ for name in parquet_names:
189
+ path = snapshot_root / name
190
+ if not path.is_file():
191
+ raise FileNotFoundError(f"Hub download did not produce {path}")
192
+ paths.append(path)
193
+ return paths
194
+
195
+
196
+ def download_fineweb_edu(
197
+ raw_dir: str | Path,
198
+ *,
199
+ revision: str = FINEWEB_EDU_REVISION,
200
+ max_files: int | None = None,
201
+ max_workers: int = 1,
202
+ ) -> list[Path]:
203
+ """Download the pinned FineWeb-Edu sample (compatibility entry point)."""
204
+
205
+ from dataclasses import replace
206
+
207
+ source = replace(CORPUS_SOURCES["fineweb-edu"], revision=revision)
208
+ return download_source(source, raw_dir, max_files=max_files, max_workers=max_workers)
209
+
210
+
211
+ def find_local_parquets(
212
+ raw_dir: str | Path, path_prefix: str = FINEWEB_EDU_PATH_PREFIX
213
+ ) -> list[Path]:
214
+ """Find already downloaded source Parquets in stable source order."""
215
+
216
+ root = Path(raw_dir)
217
+ preferred = sorted((root / path_prefix).glob("*.parquet"))
218
+ paths = preferred or sorted(root.rglob("*.parquet"))
219
+ if not paths:
220
+ raise FileNotFoundError(f"no Parquet files found below {root}")
221
+ return paths
222
+
223
+
224
+ def document_split(
225
+ document_id: str,
226
+ *,
227
+ validation_modulus: int = 1024,
228
+ validation_bucket: int = 0,
229
+ ) -> Literal["train", "validation"]:
230
+ """Assign a stable document ID to train or validation without depending on row order."""
231
+
232
+ if not document_id:
233
+ raise ValueError("document_id must be non-empty")
234
+ if validation_modulus <= 1:
235
+ raise ValueError("validation_modulus must be greater than one")
236
+ if not 0 <= validation_bucket < validation_modulus:
237
+ raise ValueError("validation_bucket must be inside validation_modulus")
238
+ digest = hashlib.blake2b(
239
+ document_id.encode("utf-8"), digest_size=8, person=SPLIT_HASH_PERSON
240
+ ).digest()
241
+ bucket = int.from_bytes(digest, "little") % validation_modulus
242
+ return "validation" if bucket == validation_bucket else "train"
243
+
244
+
245
+ def _iter_parquet_batches(
246
+ paths: Sequence[Path],
247
+ *,
248
+ batch_size: int,
249
+ text_column: str = "text",
250
+ id_column: str | None = "id",
251
+ ) -> Iterator[tuple[Path, list[str], list[str]]]:
252
+ if batch_size <= 0:
253
+ raise ValueError("batch_size must be positive")
254
+ parquet = _require_parquet()
255
+ read_columns = [text_column] if id_column is None else [id_column, text_column]
256
+ for path in paths:
257
+ source = parquet.ParquetFile(path)
258
+ try:
259
+ batches = source.iter_batches(batch_size=batch_size, columns=read_columns)
260
+ for batch in batches:
261
+ columns = batch.to_pydict()
262
+ texts = columns[text_column]
263
+ if id_column is not None:
264
+ ids = columns[id_column]
265
+ else:
266
+ # Content-derived ids keep the split assignment order-independent.
267
+ ids = [
268
+ hashlib.sha256(text.encode("utf-8")).hexdigest()
269
+ if isinstance(text, str)
270
+ else ""
271
+ for text in texts
272
+ ]
273
+ if len(ids) != len(texts):
274
+ raise ValueError(f"mismatched id/text columns in {path}")
275
+ yield path, ids, texts
276
+ except (KeyError, ValueError) as exc:
277
+ raise ValueError(
278
+ f"expected columns {read_columns} in {path}: {exc}"
279
+ ) from exc
280
+
281
+
282
+ def iter_tokenizer_text(
283
+ paths: Iterable[str | Path],
284
+ *,
285
+ max_utf8_bytes: int = 1 << 29,
286
+ batch_size: int = 512,
287
+ validation_modulus: int = 1024,
288
+ validation_bucket: int = 0,
289
+ text_column: str = "text",
290
+ id_column: str | None = "id",
291
+ ) -> Iterator[list[str]]:
292
+ """Yield bounded train-only text batches for iterator-based tokenizer training."""
293
+
294
+ if max_utf8_bytes <= 0:
295
+ raise ValueError("max_utf8_bytes must be positive")
296
+ sources = sorted(Path(path) for path in paths)
297
+ used_bytes = 0
298
+ output: list[str] = []
299
+ for _path, ids, texts in _iter_parquet_batches(
300
+ sources, batch_size=batch_size, text_column=text_column, id_column=id_column
301
+ ):
302
+ for document_id, text in zip(ids, texts, strict=True):
303
+ if not isinstance(document_id, str) or not isinstance(text, str) or not text:
304
+ continue
305
+ if document_split(
306
+ document_id,
307
+ validation_modulus=validation_modulus,
308
+ validation_bucket=validation_bucket,
309
+ ) != "train":
310
+ continue
311
+ encoded_bytes = len(text.encode("utf-8"))
312
+ if used_bytes and used_bytes + encoded_bytes > max_utf8_bytes:
313
+ if output:
314
+ yield output
315
+ return
316
+ output.append(text)
317
+ used_bytes += encoded_bytes
318
+ if len(output) >= batch_size:
319
+ yield output
320
+ output = []
321
+ if used_bytes >= max_utf8_bytes:
322
+ if output:
323
+ yield output
324
+ return
325
+ if output:
326
+ yield output
327
+
328
+
329
+ @dataclass(frozen=True)
330
+ class CorpusPreparationResult:
331
+ train_manifest: Path
332
+ validation_manifest: Path
333
+ processed_sources: int
334
+ resumed_sources: int
335
+
336
+
337
+ class _SplitWriter:
338
+ def __init__(self, final_path: Path, dtype: np.dtype[Any]) -> None:
339
+ self.final_path = final_path
340
+ self.dtype = dtype
341
+ self.temporary_path = final_path.with_name(f".{final_path.name}.tmp")
342
+ final_path.parent.mkdir(parents=True, exist_ok=True)
343
+ self.temporary_path.unlink(missing_ok=True)
344
+ self.handle = self.temporary_path.open("wb")
345
+ self.digest = hashlib.sha256()
346
+ self.token_count = 0
347
+ self.document_count = 0
348
+
349
+ def append(self, token_ids: list[int]) -> None:
350
+ payload = np.asarray(token_ids, dtype=self.dtype).tobytes()
351
+ self.handle.write(payload)
352
+ self.digest.update(payload)
353
+ self.token_count += len(token_ids)
354
+ self.document_count += 1
355
+
356
+ def finish(self) -> dict[str, Any]:
357
+ self.handle.flush()
358
+ os.fsync(self.handle.fileno())
359
+ self.handle.close()
360
+ self.temporary_path.replace(self.final_path)
361
+ return {
362
+ "token_count": self.token_count,
363
+ "document_count": self.document_count,
364
+ "sha256": self.digest.hexdigest(),
365
+ }
366
+
367
+ def abort(self) -> None:
368
+ if not self.handle.closed:
369
+ self.handle.close()
370
+ self.temporary_path.unlink(missing_ok=True)
371
+
372
+
373
+ def _relative_path(path: Path, root: Path) -> str:
374
+ try:
375
+ return path.relative_to(root).as_posix()
376
+ except ValueError:
377
+ return str(path)
378
+
379
+
380
+ def _completed_source_state(
381
+ state_path: Path,
382
+ *,
383
+ source: Path,
384
+ corpus_source: CorpusSource,
385
+ output_dir: Path,
386
+ tokenizer_sha256: str,
387
+ validation_modulus: int,
388
+ validation_bucket: int,
389
+ ) -> dict[str, Any] | None:
390
+ if not state_path.is_file():
391
+ return None
392
+ try:
393
+ state = _read_json(state_path)
394
+ except ValueError:
395
+ return None
396
+ if (
397
+ state.get("format") != SOURCE_STATE_FORMAT
398
+ or state.get("source_name") != source.name
399
+ or state.get("source_size") != source.stat().st_size
400
+ # States written before multi-source support carry no dataset name.
401
+ or state.get("source_dataset", "fineweb-edu") != corpus_source.name
402
+ or state.get("dataset_revision") != corpus_source.revision
403
+ or state.get("tokenizer_sha256") != tokenizer_sha256
404
+ or state.get("split_hash_person") != SPLIT_HASH_PERSON.decode("ascii")
405
+ or state.get("validation_modulus") != validation_modulus
406
+ or state.get("validation_bucket") != validation_bucket
407
+ ):
408
+ return None
409
+ splits = state.get("splits")
410
+ if not isinstance(splits, dict):
411
+ return None
412
+ try:
413
+ dtype = np.dtype(state.get("dtype"))
414
+ except TypeError:
415
+ return None
416
+ for split in ("train", "validation"):
417
+ shard = splits.get(split)
418
+ if not isinstance(shard, dict) or not isinstance(shard.get("path"), str):
419
+ return None
420
+ path = output_dir / shard["path"]
421
+ expected_bytes = int(shard.get("token_count", -1)) * dtype.itemsize
422
+ expected_sha256 = shard.get("sha256")
423
+ if (
424
+ not path.is_file()
425
+ or path.stat().st_size != expected_bytes
426
+ or not isinstance(expected_sha256, str)
427
+ or _sha256_file(path) != expected_sha256
428
+ ):
429
+ return None
430
+ return state
431
+
432
+
433
+ def _encode_source(
434
+ source: Path,
435
+ *,
436
+ corpus_source: CorpusSource,
437
+ tokenizer_path: Path,
438
+ output_dir: Path,
439
+ batch_size: int,
440
+ validation_modulus: int,
441
+ validation_bucket: int,
442
+ ) -> tuple[dict[str, Any], bool]:
443
+ tokenizer_sha256 = hashlib.sha256(tokenizer_path.read_bytes()).hexdigest()
444
+ # Hub Parquet names are unique and stable. Avoid list indexes so a partial download can be
445
+ # expanded later without invalidating already completed source shards.
446
+ source_key = source.stem
447
+ state_path = output_dir / "state" / f"{source_key}.json"
448
+ resumed = _completed_source_state(
449
+ state_path,
450
+ source=source,
451
+ corpus_source=corpus_source,
452
+ output_dir=output_dir,
453
+ tokenizer_sha256=tokenizer_sha256,
454
+ validation_modulus=validation_modulus,
455
+ validation_bucket=validation_bucket,
456
+ )
457
+ if resumed is not None:
458
+ return resumed, True
459
+
460
+ tokenizer = load_tokenizer(tokenizer_path)
461
+ role_ids = special_token_ids(tokenizer)
462
+ reserved_ids = set(role_ids.values())
463
+ vocab_size = tokenizer.get_vocab_size(with_added_tokens=True)
464
+ dtype = np.dtype("uint16" if vocab_size <= np.iinfo(np.uint16).max else "uint32")
465
+ final_paths = {
466
+ split: output_dir / "shards" / split / f"{source_key}.bin"
467
+ for split in ("train", "validation")
468
+ }
469
+ writers = {split: _SplitWriter(path, dtype) for split, path in final_paths.items()}
470
+ skipped_empty = 0
471
+ skipped_invalid = 0
472
+ skipped_special = 0
473
+ rows_seen = 0
474
+
475
+ try:
476
+ for _path, ids, texts in _iter_parquet_batches(
477
+ [source],
478
+ batch_size=batch_size,
479
+ text_column=corpus_source.text_column,
480
+ id_column=corpus_source.id_column,
481
+ ):
482
+ valid_rows: list[tuple[str, str]] = []
483
+ for document_id, text in zip(ids, texts, strict=True):
484
+ rows_seen += 1
485
+ if not isinstance(document_id, str) or not document_id:
486
+ skipped_invalid += 1
487
+ elif not isinstance(text, str):
488
+ skipped_invalid += 1
489
+ elif not text:
490
+ skipped_empty += 1
491
+ else:
492
+ valid_rows.append((document_id, text))
493
+ if not valid_rows:
494
+ continue
495
+ encodings = tokenizer.encode_batch(
496
+ [text for _document_id, text in valid_rows], add_special_tokens=False
497
+ )
498
+ for (document_id, _text), encoding in zip(valid_rows, encodings, strict=True):
499
+ token_ids = encoding.ids
500
+ if reserved_ids.intersection(token_ids):
501
+ # The new sentinel strings make this practically impossible, but skipping is
502
+ # preferable to losing hours of preprocessing if an exact literal is present.
503
+ skipped_special += 1
504
+ continue
505
+ split = document_split(
506
+ document_id,
507
+ validation_modulus=validation_modulus,
508
+ validation_bucket=validation_bucket,
509
+ )
510
+ writers[split].append([*token_ids, role_ids["eos"]])
511
+ split_metadata = {split: writer.finish() for split, writer in writers.items()}
512
+ except BaseException:
513
+ for writer in writers.values():
514
+ writer.abort()
515
+ raise
516
+
517
+ for split, metadata in split_metadata.items():
518
+ metadata["path"] = _relative_path(final_paths[split], output_dir)
519
+ metadata["source"] = source.name
520
+ state: dict[str, Any] = {
521
+ "format": SOURCE_STATE_FORMAT,
522
+ "source_name": source.name,
523
+ "source_size": source.stat().st_size,
524
+ "source_rows": rows_seen,
525
+ "source_dataset": corpus_source.name,
526
+ "dataset_revision": corpus_source.revision,
527
+ "dtype": dtype.name,
528
+ "vocab_size": vocab_size,
529
+ "mask_token_id": role_ids["mask"],
530
+ "eos_token_id": role_ids["eos"],
531
+ "special_token_ids": role_ids,
532
+ "tokenizer_sha256": tokenizer_sha256,
533
+ "split_hash_person": SPLIT_HASH_PERSON.decode("ascii"),
534
+ "validation_modulus": validation_modulus,
535
+ "validation_bucket": validation_bucket,
536
+ "skipped_empty_documents": skipped_empty,
537
+ "skipped_invalid_documents": skipped_invalid,
538
+ "skipped_special_documents": skipped_special,
539
+ "splits": split_metadata,
540
+ }
541
+ # The marker is written last: its presence commits both split files as one source unit.
542
+ _atomic_json(state_path, state)
543
+ return state, False
544
+
545
+
546
+ def _build_manifest(
547
+ split: Literal["train", "validation"],
548
+ *,
549
+ corpus_source: CorpusSource,
550
+ source_paths: Sequence[Path],
551
+ source_states: Sequence[dict[str, Any]],
552
+ validation_modulus: int,
553
+ validation_bucket: int,
554
+ ) -> dict[str, Any]:
555
+ first = source_states[0]
556
+ compatible_keys = (
557
+ "dtype",
558
+ "vocab_size",
559
+ "mask_token_id",
560
+ "eos_token_id",
561
+ "special_token_ids",
562
+ "tokenizer_sha256",
563
+ )
564
+ for state in source_states[1:]:
565
+ if any(state.get(key) != first.get(key) for key in compatible_keys):
566
+ raise ValueError("source states use incompatible tokenizer or token formats")
567
+ shards = [dict(state["splits"][split]) for state in source_states]
568
+ return {
569
+ "format": PACKED_MANIFEST_FORMAT,
570
+ "split": split,
571
+ "dtype": first["dtype"],
572
+ "token_count": sum(int(shard["token_count"]) for shard in shards),
573
+ "document_count": sum(int(shard["document_count"]) for shard in shards),
574
+ "vocab_size": first["vocab_size"],
575
+ "mask_token_id": first["mask_token_id"],
576
+ "eos_token_id": first["eos_token_id"],
577
+ "special_token_ids": first["special_token_ids"],
578
+ "tokenizer_sha256": first["tokenizer_sha256"],
579
+ "dataset": {
580
+ "repo_id": corpus_source.repo_id,
581
+ "config": corpus_source.config,
582
+ "revision": corpus_source.revision,
583
+ "path_prefix": corpus_source.path_prefix,
584
+ },
585
+ "split_rule": {
586
+ "algorithm": "blake2b-64",
587
+ "person": SPLIT_HASH_PERSON.decode("ascii"),
588
+ "validation_modulus": validation_modulus,
589
+ "validation_bucket": validation_bucket,
590
+ },
591
+ "source_files": [path.name for path in source_paths],
592
+ "skipped_documents": {
593
+ reason: sum(int(state[reason]) for state in source_states)
594
+ for reason in (
595
+ "skipped_empty_documents",
596
+ "skipped_invalid_documents",
597
+ "skipped_special_documents",
598
+ )
599
+ },
600
+ "shards": shards,
601
+ }
602
+
603
+
604
+ def prepare_corpus(
605
+ tokenizer_path: str | Path,
606
+ output_dir: str | Path,
607
+ *,
608
+ corpus_source: CorpusSource,
609
+ source_paths: Iterable[str | Path] | None = None,
610
+ raw_dir: str | Path | None = None,
611
+ batch_size: int = 256,
612
+ validation_modulus: int = 1024,
613
+ validation_bucket: int = 0,
614
+ max_files: int | None = None,
615
+ ) -> CorpusPreparationResult:
616
+ """Convert one pinned source's Parquets into resumable train/validation manifests."""
617
+
618
+ # Validate split arguments before performing any download.
619
+ document_split(
620
+ "argument-validation",
621
+ validation_modulus=validation_modulus,
622
+ validation_bucket=validation_bucket,
623
+ )
624
+ tokenizer = Path(tokenizer_path)
625
+ load_tokenizer(tokenizer)
626
+ destination = Path(output_dir)
627
+ destination.mkdir(parents=True, exist_ok=True)
628
+
629
+ if source_paths is None:
630
+ raw = Path(raw_dir) if raw_dir is not None else destination / "raw"
631
+ try:
632
+ sources = find_local_parquets(raw, corpus_source.path_prefix)
633
+ except FileNotFoundError:
634
+ sources = download_source(corpus_source, raw, max_files=max_files)
635
+ else:
636
+ sources = sorted(Path(path) for path in source_paths)
637
+ if not sources:
638
+ raise ValueError("at least one source Parquet is required")
639
+ missing = [str(path) for path in sources if not path.is_file()]
640
+ if missing:
641
+ raise FileNotFoundError(f"missing source Parquets: {missing}")
642
+ source_keys = [source.stem for source in sources]
643
+ if len(set(source_keys)) != len(source_keys):
644
+ raise ValueError("source Parquet filenames must have unique stems")
645
+
646
+ states: list[dict[str, Any]] = []
647
+ resumed_sources = 0
648
+ for source in sources:
649
+ state, resumed = _encode_source(
650
+ source,
651
+ corpus_source=corpus_source,
652
+ tokenizer_path=tokenizer,
653
+ output_dir=destination,
654
+ batch_size=batch_size,
655
+ validation_modulus=validation_modulus,
656
+ validation_bucket=validation_bucket,
657
+ )
658
+ states.append(state)
659
+ resumed_sources += int(resumed)
660
+
661
+ manifest_paths = {
662
+ "train": destination / "train.manifest.json",
663
+ "validation": destination / "validation.manifest.json",
664
+ }
665
+ for split, manifest_path in manifest_paths.items():
666
+ manifest = _build_manifest(
667
+ split, # type: ignore[arg-type]
668
+ corpus_source=corpus_source,
669
+ source_paths=sources,
670
+ source_states=states,
671
+ validation_modulus=validation_modulus,
672
+ validation_bucket=validation_bucket,
673
+ )
674
+ _atomic_json(manifest_path, manifest)
675
+ return CorpusPreparationResult(
676
+ train_manifest=manifest_paths["train"],
677
+ validation_manifest=manifest_paths["validation"],
678
+ processed_sources=len(sources) - resumed_sources,
679
+ resumed_sources=resumed_sources,
680
+ )
681
+
682
+
683
+ def prepare_fineweb_edu(
684
+ tokenizer_path: str | Path,
685
+ output_dir: str | Path,
686
+ *,
687
+ source_paths: Iterable[str | Path] | None = None,
688
+ raw_dir: str | Path | None = None,
689
+ batch_size: int = 256,
690
+ validation_modulus: int = 1024,
691
+ validation_bucket: int = 0,
692
+ ) -> CorpusPreparationResult:
693
+ """Convert pinned FineWeb-Edu Parquets into manifests (compatibility entry point)."""
694
+
695
+ return prepare_corpus(
696
+ tokenizer_path,
697
+ output_dir,
698
+ corpus_source=CORPUS_SOURCES["fineweb-edu"],
699
+ source_paths=source_paths,
700
+ raw_dir=raw_dir,
701
+ batch_size=batch_size,
702
+ validation_modulus=validation_modulus,
703
+ validation_bucket=validation_bucket,
704
+ )
705
+
706
+
707
+ def parse_token_budget(text: str) -> int:
708
+ """Parse a token count with an optional K/M/B suffix (e.g. ``2.5B``, ``500M``)."""
709
+
710
+ value = text.strip().upper()
711
+ factor = 1
712
+ if value and value[-1] in "KMB":
713
+ factor = {"K": 1_000, "M": 1_000_000, "B": 1_000_000_000}[value[-1]]
714
+ value = value[:-1]
715
+ try:
716
+ tokens = int(float(value) * factor)
717
+ except ValueError as exc:
718
+ raise ValueError(f"invalid token budget {text!r}") from exc
719
+ if tokens <= 0:
720
+ raise ValueError(f"token budget must be positive: {text!r}")
721
+ return tokens
722
+
723
+
724
+ def mix_manifests(
725
+ inputs: Sequence[tuple[Path, int | None]], output_dir: str | Path
726
+ ) -> tuple[Path, Path]:
727
+ """Combine prepared source directories into one mixture manifest pair.
728
+
729
+ Each input contributes whole train shards in manifest order until its token budget
730
+ is met (``None`` takes everything), so realized counts overshoot a budget by at most
731
+ one shard; the overshoot is printed, never silent. Validation includes the
732
+ validation shards of the sources whose train shards were selected. Shard paths in
733
+ the mixed manifests are absolute so the inputs can live anywhere.
734
+ """
735
+
736
+ from diffusion_lm.data import load_packed_manifest
737
+
738
+ if not inputs:
739
+ raise ValueError("at least one input directory is required")
740
+ destination = Path(output_dir)
741
+ destination.mkdir(parents=True, exist_ok=True)
742
+ compatible_keys = (
743
+ "dtype",
744
+ "vocab_size",
745
+ "mask_token_id",
746
+ "eos_token_id",
747
+ "special_token_ids",
748
+ "tokenizer_sha256",
749
+ )
750
+ reference: dict[str, Any] | None = None
751
+ shards: dict[str, list[dict[str, Any]]] = {"train": [], "validation": []}
752
+ components: list[dict[str, Any]] = []
753
+ for input_dir, budget in inputs:
754
+ manifests = {
755
+ split: load_packed_manifest(input_dir / f"{split}.manifest.json")
756
+ for split in ("train", "validation")
757
+ }
758
+ if reference is None:
759
+ reference = manifests["train"]
760
+ elif any(
761
+ manifests["train"].get(key) != reference.get(key) for key in compatible_keys
762
+ ):
763
+ raise ValueError(f"{input_dir} uses an incompatible tokenizer or token format")
764
+
765
+ taken = 0
766
+ selected_sources: set[str] = set()
767
+ skipped = 0
768
+ for shard in manifests["train"]["shards"]:
769
+ if budget is not None and taken >= budget:
770
+ skipped += 1
771
+ continue
772
+ entry = dict(shard)
773
+ entry["path"] = str((input_dir / entry["path"]).resolve())
774
+ shards["train"].append(entry)
775
+ taken += int(entry["token_count"])
776
+ selected_sources.add(str(entry.get("source")))
777
+ for shard in manifests["validation"]["shards"]:
778
+ if str(shard.get("source")) not in selected_sources:
779
+ continue
780
+ entry = dict(shard)
781
+ entry["path"] = str((input_dir / entry["path"]).resolve())
782
+ shards["validation"].append(entry)
783
+ component = {
784
+ "dataset": manifests["train"].get("dataset"),
785
+ "directory": str(Path(input_dir).resolve()),
786
+ "token_budget": budget,
787
+ "train_tokens": taken,
788
+ "skipped_shards": skipped,
789
+ }
790
+ components.append(component)
791
+ print(
792
+ f'{input_dir}: {taken:,} train tokens'
793
+ + (f" (budget {budget:,}, {skipped} shards skipped)" if budget else "")
794
+ )
795
+
796
+ assert reference is not None
797
+ manifest_paths: dict[str, Path] = {}
798
+ for split in ("train", "validation"):
799
+ manifest = {
800
+ "format": PACKED_MANIFEST_FORMAT,
801
+ "split": split,
802
+ "dtype": reference["dtype"],
803
+ "token_count": sum(int(shard["token_count"]) for shard in shards[split]),
804
+ "document_count": sum(int(shard["document_count"]) for shard in shards[split]),
805
+ "vocab_size": reference["vocab_size"],
806
+ "mask_token_id": reference["mask_token_id"],
807
+ "eos_token_id": reference["eos_token_id"],
808
+ "special_token_ids": reference["special_token_ids"],
809
+ "tokenizer_sha256": reference["tokenizer_sha256"],
810
+ "dataset": {"name": "mixture", "components": components},
811
+ "split_rule": reference.get("split_rule"),
812
+ "source_files": [shard.get("source") for shard in shards[split]],
813
+ "shards": shards[split],
814
+ }
815
+ manifest_paths[split] = destination / f"{split}.manifest.json"
816
+ _atomic_json(manifest_paths[split], manifest)
817
+ return manifest_paths["train"], manifest_paths["validation"]
818
+
819
+
820
+ def _parse_mix_input(text: str) -> tuple[Path, int | None]:
821
+ directory, separator, budget = text.partition("=")
822
+ return Path(directory), parse_token_budget(budget) if separator else None
823
+
824
+
825
+ def _build_parser() -> argparse.ArgumentParser:
826
+ parser = argparse.ArgumentParser(description=__doc__)
827
+ commands = parser.add_subparsers(dest="command", required=True)
828
+ source_names = tuple(CORPUS_SOURCES)
829
+
830
+ download = commands.add_parser("download", help="download a pinned source's Parquets")
831
+ download.add_argument("--raw-dir", type=Path, required=True)
832
+ download.add_argument("--source", choices=source_names, default="fineweb-edu")
833
+ download.add_argument("--max-files", type=int)
834
+ download.add_argument("--max-workers", type=int, default=1)
835
+
836
+ tokenizer = commands.add_parser(
837
+ "train-tokenizer", help="train a 32K tokenizer from cached Parquets"
838
+ )
839
+ tokenizer.add_argument("--raw-dir", type=Path, required=True)
840
+ tokenizer.add_argument("--source", choices=source_names, default="fineweb-edu")
841
+ tokenizer.add_argument("--output", type=Path, required=True)
842
+ tokenizer.add_argument("--sample-bytes", type=int, default=1 << 29)
843
+ tokenizer.add_argument("--vocab-size", type=int, default=32_768)
844
+ tokenizer.add_argument("--min-frequency", type=int, default=10)
845
+ tokenizer.add_argument("--max-token-length", type=int, default=64)
846
+ tokenizer.add_argument("--batch-size", type=int, default=512)
847
+
848
+ prepare = commands.add_parser("prepare", help="encode cached Parquets into token shards")
849
+ prepare.add_argument("--raw-dir", type=Path, required=True)
850
+ prepare.add_argument("--source", choices=source_names, default="fineweb-edu")
851
+ prepare.add_argument("--tokenizer", type=Path, required=True)
852
+ prepare.add_argument("--output-dir", type=Path, required=True)
853
+ prepare.add_argument("--batch-size", type=int, default=256)
854
+ prepare.add_argument("--validation-modulus", type=int, default=1024)
855
+ prepare.add_argument("--validation-bucket", type=int, default=0)
856
+
857
+ mix = commands.add_parser(
858
+ "mix", help="combine prepared source directories into one mixture manifest"
859
+ )
860
+ mix.add_argument(
861
+ "--input",
862
+ action="append",
863
+ required=True,
864
+ metavar="DIR[=TOKENS]",
865
+ help="prepared corpus directory with an optional train-token budget (e.g. 3.2B)",
866
+ )
867
+ mix.add_argument("--output-dir", type=Path, required=True)
868
+ return parser
869
+
870
+
871
+ def main() -> None:
872
+ args = _build_parser().parse_args()
873
+ if args.command == "download":
874
+ source = CORPUS_SOURCES[args.source]
875
+ paths = download_source(
876
+ source, args.raw_dir, max_files=args.max_files, max_workers=args.max_workers
877
+ )
878
+ print(f"downloaded {len(paths)} Parquet shards below {args.raw_dir}")
879
+ elif args.command == "train-tokenizer":
880
+ source = CORPUS_SOURCES[args.source]
881
+ paths = find_local_parquets(args.raw_dir, source.path_prefix)
882
+ tokenizer = train_tokenizer_from_iterator(
883
+ iter_tokenizer_text(
884
+ paths,
885
+ max_utf8_bytes=args.sample_bytes,
886
+ batch_size=args.batch_size,
887
+ text_column=source.text_column,
888
+ id_column=source.id_column,
889
+ ),
890
+ args.output,
891
+ vocab_size=args.vocab_size,
892
+ min_frequency=args.min_frequency,
893
+ max_token_length=args.max_token_length,
894
+ )
895
+ print(f"saved {tokenizer.get_vocab_size():,}-token tokenizer to {args.output}")
896
+ elif args.command == "prepare":
897
+ source = CORPUS_SOURCES[args.source]
898
+ paths = find_local_parquets(args.raw_dir, source.path_prefix)
899
+ result = prepare_corpus(
900
+ args.tokenizer,
901
+ args.output_dir,
902
+ corpus_source=source,
903
+ source_paths=paths,
904
+ batch_size=args.batch_size,
905
+ validation_modulus=args.validation_modulus,
906
+ validation_bucket=args.validation_bucket,
907
+ )
908
+ print(
909
+ f"prepared {result.processed_sources} sources "
910
+ f"({result.resumed_sources} resumed); train={result.train_manifest}, "
911
+ f"validation={result.validation_manifest}"
912
+ )
913
+ elif args.command == "mix":
914
+ inputs = [_parse_mix_input(item) for item in args.input]
915
+ train_manifest, validation_manifest = mix_manifests(inputs, args.output_dir)
916
+ print(f"mixed manifests: train={train_manifest}, validation={validation_manifest}")
917
+
918
+
919
+ if __name__ == "__main__":
920
+ main()
src/diffusion_lm/data.py ADDED
@@ -0,0 +1,296 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Memory-mapped packed-token datasets and deterministic sampling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ from bisect import bisect_right
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import numpy as np
12
+ import torch
13
+ from torch import Tensor
14
+ from torch.utils.data import Dataset, Sampler
15
+
16
+ from diffusion_lm.tokenizer import token_metadata_path
17
+
18
+
19
+ PACKED_TOKEN_FORMAT = "mini-diffusion-lm-packed-tokens-v1"
20
+ PACKED_MANIFEST_FORMAT = "mini-diffusion-lm-packed-manifest-v1"
21
+
22
+
23
+ def _read_json(path: Path) -> dict[str, Any]:
24
+ try:
25
+ with path.open("r", encoding="utf-8") as handle:
26
+ value = json.load(handle)
27
+ except json.JSONDecodeError as exc:
28
+ raise ValueError(f"invalid JSON in {path}: {exc}") from exc
29
+ if not isinstance(value, dict):
30
+ raise ValueError(f"expected a JSON object in {path}")
31
+ return value
32
+
33
+
34
+ def load_token_metadata(path: str | Path) -> dict[str, Any]:
35
+ metadata_path = token_metadata_path(path)
36
+ if not metadata_path.is_file():
37
+ raise FileNotFoundError(
38
+ f"token metadata not found: {metadata_path}; encode data with mini-mdlm-tokenizer"
39
+ )
40
+ metadata = _read_json(metadata_path)
41
+ if metadata.get("format") != PACKED_TOKEN_FORMAT:
42
+ raise ValueError(f"unsupported token file metadata in {metadata_path}")
43
+ return metadata
44
+
45
+
46
+ def load_packed_manifest(path: str | Path) -> dict[str, Any]:
47
+ manifest_path = Path(path)
48
+ if not manifest_path.is_file():
49
+ raise FileNotFoundError(f"packed-token manifest not found: {manifest_path}")
50
+ manifest = _read_json(manifest_path)
51
+ if manifest.get("format") != PACKED_MANIFEST_FORMAT:
52
+ raise ValueError(f"unsupported packed-token manifest in {manifest_path}")
53
+ shards = manifest.get("shards")
54
+ if not isinstance(shards, list) or not shards:
55
+ raise ValueError(f"packed-token manifest has no shards: {manifest_path}")
56
+ return manifest
57
+
58
+
59
+ def _validate_dtype(name: object, *, context: Path) -> np.dtype[Any]:
60
+ try:
61
+ dtype = np.dtype(name)
62
+ except TypeError as exc:
63
+ raise ValueError(f"invalid token dtype {name!r} in {context}") from exc
64
+ if dtype not in (np.dtype("uint16"), np.dtype("uint32")):
65
+ raise ValueError(f"unsupported token dtype {dtype.name!r} in {context}")
66
+ return dtype
67
+
68
+
69
+ class PackedTokenDataset(Dataset[Tensor]):
70
+ """Expose deterministic non-overlapping blocks from one packed token file."""
71
+
72
+ def __init__(self, path: str | Path, sequence_length: int) -> None:
73
+ if sequence_length <= 0:
74
+ raise ValueError("sequence_length must be positive")
75
+ self.path = Path(path)
76
+ if not self.path.is_file():
77
+ raise FileNotFoundError(f"packed token file not found: {self.path}")
78
+ self.metadata = load_token_metadata(self.path)
79
+ self.sequence_length = sequence_length
80
+ self._dtype = _validate_dtype(self.metadata.get("dtype"), context=self.path)
81
+ self._tokens = np.memmap(self.path, mode="r", dtype=self._dtype)
82
+ expected_count = int(self.metadata["token_count"])
83
+ if self._tokens.size != expected_count:
84
+ raise ValueError(
85
+ f"metadata says {expected_count} tokens but {self.path} contains "
86
+ f"{self._tokens.size}"
87
+ )
88
+ self._blocks = self._tokens.size // sequence_length
89
+ if self._blocks == 0:
90
+ raise ValueError(
91
+ f"dataset has {self._tokens.size} tokens, fewer than one "
92
+ f"{sequence_length}-token block"
93
+ )
94
+
95
+ def __len__(self) -> int:
96
+ return self._blocks
97
+
98
+ def __getitem__(self, index: int) -> Tensor:
99
+ if index < 0:
100
+ index += self._blocks
101
+ if not 0 <= index < self._blocks:
102
+ raise IndexError(index)
103
+ start = index * self.sequence_length
104
+ block = np.asarray(self._tokens[start : start + self.sequence_length]).astype(
105
+ np.int64, copy=True
106
+ )
107
+ return torch.from_numpy(block)
108
+
109
+
110
+ class ManifestPackedTokenDataset(Dataset[Tensor]):
111
+ """Expose one logical dataset backed by independently memory-mapped token shards.
112
+
113
+ Blocks never cross shard boundaries. At most ``sequence_length - 1`` trailing tokens per shard
114
+ are ignored, keeping source shards independently replaceable and resumable.
115
+ """
116
+
117
+ def __init__(self, path: str | Path, sequence_length: int) -> None:
118
+ if sequence_length <= 0:
119
+ raise ValueError("sequence_length must be positive")
120
+ self.path = Path(path)
121
+ self.metadata = load_packed_manifest(self.path)
122
+ self.sequence_length = sequence_length
123
+ self._dtype = _validate_dtype(self.metadata.get("dtype"), context=self.path)
124
+
125
+ declared_token_count = int(self.metadata.get("token_count", -1))
126
+ declared_document_count = int(self.metadata.get("document_count", -1))
127
+ shard_token_count = 0
128
+ shard_document_count = 0
129
+ self._shard_paths: list[Path] = []
130
+ self._shard_token_counts: list[int] = []
131
+ self._block_ends: list[int] = []
132
+ total_blocks = 0
133
+
134
+ for position, raw_shard in enumerate(self.metadata["shards"]):
135
+ if not isinstance(raw_shard, dict):
136
+ raise ValueError(f"shard {position} in {self.path} is not an object")
137
+ raw_path = raw_shard.get("path")
138
+ if not isinstance(raw_path, str) or not raw_path:
139
+ raise ValueError(f"shard {position} in {self.path} has no path")
140
+ shard_path = Path(raw_path)
141
+ if not shard_path.is_absolute():
142
+ shard_path = self.path.parent / shard_path
143
+ if not shard_path.is_file():
144
+ raise FileNotFoundError(f"packed token shard not found: {shard_path}")
145
+
146
+ token_count = int(raw_shard.get("token_count", -1))
147
+ document_count = int(raw_shard.get("document_count", -1))
148
+ if token_count < 0 or document_count < 0:
149
+ raise ValueError(f"invalid counts for shard {shard_path}")
150
+ expected_bytes = token_count * self._dtype.itemsize
151
+ if shard_path.stat().st_size != expected_bytes:
152
+ raise ValueError(
153
+ f"manifest says {token_count} tokens but {shard_path} has "
154
+ f"{shard_path.stat().st_size} bytes"
155
+ )
156
+
157
+ shard_token_count += token_count
158
+ shard_document_count += document_count
159
+ blocks = token_count // sequence_length
160
+ if blocks:
161
+ self._shard_paths.append(shard_path)
162
+ self._shard_token_counts.append(token_count)
163
+ total_blocks += blocks
164
+ self._block_ends.append(total_blocks)
165
+
166
+ if shard_token_count != declared_token_count:
167
+ raise ValueError(
168
+ f"manifest token_count is {declared_token_count}, shard total is "
169
+ f"{shard_token_count}"
170
+ )
171
+ if shard_document_count != declared_document_count:
172
+ raise ValueError(
173
+ f"manifest document_count is {declared_document_count}, shard total is "
174
+ f"{shard_document_count}"
175
+ )
176
+ if total_blocks == 0:
177
+ raise ValueError(
178
+ f"dataset has no shard containing a full {sequence_length}-token block"
179
+ )
180
+ self._blocks = total_blocks
181
+ self._maps: list[np.memmap[Any, Any] | None] = [None] * len(self._shard_paths)
182
+
183
+ def __len__(self) -> int:
184
+ return self._blocks
185
+
186
+ def _map(self, shard_index: int) -> np.memmap[Any, Any]:
187
+ tokens = self._maps[shard_index]
188
+ if tokens is None:
189
+ tokens = np.memmap(self._shard_paths[shard_index], mode="r", dtype=self._dtype)
190
+ self._maps[shard_index] = tokens
191
+ return tokens
192
+
193
+ def __getitem__(self, index: int) -> Tensor:
194
+ if index < 0:
195
+ index += self._blocks
196
+ if not 0 <= index < self._blocks:
197
+ raise IndexError(index)
198
+ shard_index = bisect_right(self._block_ends, index)
199
+ previous_end = 0 if shard_index == 0 else self._block_ends[shard_index - 1]
200
+ local_block = index - previous_end
201
+ start = local_block * self.sequence_length
202
+ tokens = self._map(shard_index)
203
+ block = np.asarray(tokens[start : start + self.sequence_length]).astype(
204
+ np.int64, copy=True
205
+ )
206
+ return torch.from_numpy(block)
207
+
208
+ def __getstate__(self) -> dict[str, Any]:
209
+ state = self.__dict__.copy()
210
+ # Reopen mappings inside each DataLoader worker rather than pickling file descriptors.
211
+ state["_maps"] = [None] * len(self._shard_paths)
212
+ return state
213
+
214
+
215
+ PackedDataset = PackedTokenDataset | ManifestPackedTokenDataset
216
+
217
+
218
+ def load_packed_dataset(path: str | Path, sequence_length: int) -> PackedDataset:
219
+ """Load a legacy single-file dataset or a manifest-backed sharded dataset."""
220
+
221
+ candidate = Path(path)
222
+ if candidate.suffix == ".json" and candidate.is_file():
223
+ value = _read_json(candidate)
224
+ if value.get("format") == PACKED_MANIFEST_FORMAT:
225
+ return ManifestPackedTokenDataset(candidate, sequence_length)
226
+ return PackedTokenDataset(candidate, sequence_length)
227
+
228
+
229
+ _UINT64_MASK = (1 << 64) - 1
230
+
231
+
232
+ def _splitmix64(value: int) -> int:
233
+ value = (value + 0x9E3779B97F4A7C15) & _UINT64_MASK
234
+ value = ((value ^ (value >> 30)) * 0xBF58476D1CE4E5B9) & _UINT64_MASK
235
+ value = ((value ^ (value >> 27)) * 0x94D049BB133111EB) & _UINT64_MASK
236
+ return value ^ (value >> 31)
237
+
238
+
239
+ def _affine_permutation_parameters(size: int, seed: int, epoch: int) -> tuple[int, int]:
240
+ """Return ``a, b`` for the bijection ``(a*x+b) mod size``."""
241
+
242
+ if size == 1:
243
+ return 0, 0
244
+ mixed = _splitmix64((seed & _UINT64_MASK) ^ _splitmix64(epoch & _UINT64_MASK))
245
+ offset = mixed % size
246
+ multiplier = _splitmix64(mixed) % size
247
+ if multiplier == 0:
248
+ multiplier = 1
249
+ while math.gcd(multiplier, size) != 1:
250
+ multiplier += 1
251
+ if multiplier == size:
252
+ multiplier = 1
253
+ return multiplier, offset
254
+
255
+
256
+ class DeterministicBatchSampler(Sampler[list[int]]):
257
+ """Infinite epoch permutations with exact batch-cursor resume and O(1) memory.
258
+
259
+ Each epoch uses a seeded affine bijection over dataset indexes. Unlike ``randperm().tolist()``,
260
+ memory use is independent of corpus size and a resumed batch can be calculated directly.
261
+ """
262
+
263
+ def __init__(
264
+ self,
265
+ dataset_size: int,
266
+ batch_size: int,
267
+ *,
268
+ seed: int,
269
+ start_batch: int = 0,
270
+ ) -> None:
271
+ if dataset_size <= 0 or batch_size <= 0:
272
+ raise ValueError("dataset_size and batch_size must be positive")
273
+ if start_batch < 0:
274
+ raise ValueError("start_batch must be non-negative")
275
+ self.dataset_size = dataset_size
276
+ self.batch_size = batch_size
277
+ self.seed = seed
278
+ self.start_batch = start_batch
279
+ self.batches_per_epoch = (dataset_size + batch_size - 1) // batch_size
280
+
281
+ def __iter__(self):
282
+ epoch = self.start_batch // self.batches_per_epoch
283
+ batch_in_epoch = self.start_batch % self.batches_per_epoch
284
+ while True:
285
+ multiplier, offset = _affine_permutation_parameters(
286
+ self.dataset_size, self.seed, epoch
287
+ )
288
+ for batch_index in range(batch_in_epoch, self.batches_per_epoch):
289
+ start = batch_index * self.batch_size
290
+ stop = min(start + self.batch_size, self.dataset_size)
291
+ yield [
292
+ (multiplier * position + offset) % self.dataset_size
293
+ for position in range(start, stop)
294
+ ]
295
+ epoch += 1
296
+ batch_in_epoch = 0
src/diffusion_lm/diffusion.py ADDED
@@ -0,0 +1,338 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Absorbing-mask forward corruption, objective, and reverse samplers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from collections.abc import Iterator
7
+ from typing import Callable, Literal, Protocol
8
+
9
+ import torch
10
+ import torch.nn.functional as F
11
+ from torch import Tensor
12
+
13
+
14
+ class Denoiser(Protocol):
15
+ config: object
16
+
17
+ def __call__(
18
+ self,
19
+ input_ids: Tensor,
20
+ attention_mask: Tensor | None = None,
21
+ output_positions: Tensor | None = None,
22
+ attn_mask: Tensor | None = None,
23
+ ) -> Tensor: ...
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class CorruptionBatch:
28
+ noisy_tokens: Tensor
29
+ mask: Tensor
30
+ mask_probability: Tensor
31
+ valid_mask: Tensor
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class LossOutput:
36
+ loss: Tensor
37
+ masked_accuracy: Tensor
38
+ masked_tokens: int
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class UnmaskStep:
43
+ """One observable state of the reverse diffusion process."""
44
+
45
+ step: int
46
+ total_steps: int
47
+ tokens: Tensor
48
+ masked_remaining: int
49
+
50
+
51
+ def sample_mask_probabilities(
52
+ batch_size: int,
53
+ *,
54
+ device: torch.device | str,
55
+ eps: float = 1e-3,
56
+ low_discrepancy: bool = True,
57
+ generator: torch.Generator | None = None,
58
+ ) -> Tensor:
59
+ """Sample linear noise levels in ``[eps, 1]``.
60
+
61
+ A random cyclic shift of an evenly spaced grid preserves uniform marginals
62
+ while covering the complete noise range in every reasonably sized batch.
63
+ """
64
+
65
+ if batch_size <= 0:
66
+ raise ValueError("batch_size must be positive")
67
+ if not 0.0 < eps < 1.0:
68
+ raise ValueError("eps must be in (0, 1)")
69
+
70
+ if low_discrepancy:
71
+ offset = torch.rand((), device=device, generator=generator)
72
+ unit = (offset + torch.arange(batch_size, device=device) / batch_size) % 1.0
73
+ else:
74
+ unit = torch.rand(batch_size, device=device, generator=generator)
75
+ return eps + (1.0 - eps) * unit
76
+
77
+
78
+ def corrupt_tokens(
79
+ clean_tokens: Tensor,
80
+ mask_token_id: int,
81
+ *,
82
+ valid_mask: Tensor | None = None,
83
+ mask_probability: Tensor | None = None,
84
+ eps: float = 1e-3,
85
+ low_discrepancy: bool = True,
86
+ generator: torch.Generator | None = None,
87
+ ) -> CorruptionBatch:
88
+ """Apply the absorbing forward process at one random time per sequence."""
89
+
90
+ if clean_tokens.ndim != 2:
91
+ raise ValueError("clean_tokens must have shape [batch, sequence]")
92
+ batch_size, _ = clean_tokens.shape
93
+ if valid_mask is None:
94
+ valid_mask = torch.ones_like(clean_tokens, dtype=torch.bool)
95
+ elif valid_mask.shape != clean_tokens.shape:
96
+ raise ValueError("valid_mask must match clean_tokens")
97
+ else:
98
+ valid_mask = valid_mask.bool()
99
+
100
+ if mask_probability is None:
101
+ mask_probability = sample_mask_probabilities(
102
+ batch_size,
103
+ device=clean_tokens.device,
104
+ eps=eps,
105
+ low_discrepancy=low_discrepancy,
106
+ generator=generator,
107
+ )
108
+ else:
109
+ mask_probability = torch.as_tensor(
110
+ mask_probability, device=clean_tokens.device, dtype=torch.float32
111
+ )
112
+ if mask_probability.ndim == 0:
113
+ mask_probability = mask_probability.repeat(batch_size)
114
+ if mask_probability.shape != (batch_size,):
115
+ raise ValueError("mask_probability must be scalar or have shape [batch]")
116
+ if bool(((mask_probability <= 0) | (mask_probability > 1)).any()):
117
+ raise ValueError("mask probabilities must be in (0, 1]")
118
+
119
+ random_values = torch.rand(clean_tokens.shape, device=clean_tokens.device, generator=generator)
120
+ mask = (random_values < mask_probability[:, None]) & valid_mask
121
+ noisy_tokens = torch.where(mask, mask_token_id, clean_tokens)
122
+ return CorruptionBatch(noisy_tokens, mask, mask_probability, valid_mask)
123
+
124
+
125
+ def diffusion_cross_entropy(
126
+ logits: Tensor,
127
+ clean_tokens: Tensor,
128
+ corruption: CorruptionBatch,
129
+ ) -> LossOutput:
130
+ """Compute the continuous-time masked-diffusion likelihood bound.
131
+
132
+ ``logits`` may contain all positions as ``[B, L, V]`` or only the masked
133
+ positions as ``[N_masked, V]``. The latter is substantially more memory
134
+ efficient for small models with non-trivial vocabularies.
135
+ """
136
+
137
+ if clean_tokens.shape != corruption.noisy_tokens.shape:
138
+ raise ValueError("clean_tokens must match the corruption batch")
139
+ targets = clean_tokens[corruption.mask]
140
+ if logits.ndim == 3:
141
+ if logits.shape[:2] != clean_tokens.shape:
142
+ raise ValueError("full logits must have shape [batch, sequence, vocab]")
143
+ selected_logits = logits[corruption.mask]
144
+ elif logits.ndim == 2:
145
+ selected_logits = logits
146
+ else:
147
+ raise ValueError("logits must have shape [B, L, V] or [N_masked, V]")
148
+ if selected_logits.shape[0] != targets.numel():
149
+ raise ValueError("selected logits count does not match the number of masked tokens")
150
+
151
+ masked_tokens = int(targets.numel())
152
+ if masked_tokens == 0:
153
+ zero = logits.sum() * 0.0
154
+ return LossOutput(zero, zero.detach(), 0)
155
+
156
+ per_token = F.cross_entropy(selected_logits.float(), targets, reduction="none")
157
+ probabilities = corruption.mask_probability[:, None].expand_as(clean_tokens)
158
+ weights = probabilities[corruption.mask].reciprocal()
159
+ normalizer = corruption.valid_mask.sum().clamp_min(1)
160
+ loss = (per_token * weights).sum() / normalizer
161
+ accuracy = (selected_logits.argmax(dim=-1) == targets).float().mean()
162
+ return LossOutput(loss, accuracy, masked_tokens)
163
+
164
+
165
+ def _sample_categorical(
166
+ logits: Tensor,
167
+ temperature: float,
168
+ generator: torch.Generator | None,
169
+ ) -> tuple[Tensor, Tensor]:
170
+ """Sample with fp64 Gumbel noise and return token ids plus model confidence."""
171
+
172
+ if temperature < 0:
173
+ raise ValueError("temperature must be non-negative")
174
+ log_probs = F.log_softmax(logits.float(), dim=-1)
175
+ if temperature == 0:
176
+ tokens = logits.argmax(dim=-1)
177
+ else:
178
+ # MPS has no float64 kernels. Preserve fp64 categorical sampling by
179
+ # moving only the sampling calculation to CPU on Apple Silicon.
180
+ sampling_device = torch.device("cpu") if logits.device.type == "mps" else logits.device
181
+ if logits.device.type == "mps":
182
+ logits64 = logits.float().cpu().double() / temperature
183
+ else:
184
+ logits64 = logits.double() / temperature
185
+ sampling_generator = generator
186
+ if generator is not None and generator.device != sampling_device:
187
+ sampling_generator = None
188
+ uniform = torch.rand(
189
+ logits64.shape,
190
+ device=sampling_device,
191
+ dtype=torch.float64,
192
+ generator=sampling_generator,
193
+ ).clamp_(1e-12, 1.0 - 1e-12)
194
+ gumbel = -torch.log(-torch.log(uniform))
195
+ tokens = (logits64 + gumbel).argmax(dim=-1).to(logits.device)
196
+ confidence = log_probs.gather(-1, tokens[:, None]).squeeze(-1).exp()
197
+ return tokens, confidence
198
+
199
+
200
+ def iterative_unmask_steps(
201
+ model: Denoiser,
202
+ input_ids: Tensor,
203
+ mask_token_id: int,
204
+ *,
205
+ steps: int = 64,
206
+ temperature: float = 1.0,
207
+ strategy: Literal["ancestral", "confidence", "left_to_right"] = "ancestral",
208
+ blocked_token_ids: tuple[int, ...] = (),
209
+ attn_mask: Tensor | None = None,
210
+ generator: torch.Generator | None = None,
211
+ logits_fn: Callable[[Tensor, Tensor], Tensor] | None = None,
212
+ ) -> Iterator[UnmaskStep]:
213
+ """Yield each state while filling masks and clamping visible prompt tokens.
214
+
215
+ ``ancestral`` implements the absorbing reverse transition from mask rate
216
+ ``t`` to ``s``. ``confidence`` reveals an equal-sized highest-confidence
217
+ group on each pass; it is faster-looking and often useful, but is a heuristic.
218
+ ``left_to_right`` reveals equal-sized position-ordered groups, which keeps
219
+ arithmetic left operands visible before their results are committed.
220
+
221
+ ``logits_fn(tokens, masked)`` overrides how predictions are obtained, so a caller
222
+ holding a key/value cache can score only the masked window instead of the whole
223
+ sequence. The revealing schedule is unchanged either way.
224
+ """
225
+
226
+ if input_ids.ndim != 2:
227
+ raise ValueError("input_ids must have shape [batch, sequence]")
228
+ if steps <= 0:
229
+ raise ValueError("steps must be positive")
230
+ if strategy not in {"ancestral", "confidence", "left_to_right"}:
231
+ raise ValueError("strategy must be ancestral, confidence, or left_to_right")
232
+
233
+ tokens = input_ids.clone()
234
+ batch_size, _ = tokens.shape
235
+ yield UnmaskStep(0, steps, tokens.detach(), int(tokens.eq(mask_token_id).sum()))
236
+
237
+ for step in range(steps):
238
+ masked = tokens.eq(mask_token_id)
239
+ if not bool(masked.any()):
240
+ break
241
+
242
+ # This function is itself a generator, so a decorator would leave the
243
+ # inference context before iteration begins. Scope it around each pass.
244
+ with torch.inference_mode():
245
+ if logits_fn is not None:
246
+ logits = logits_fn(tokens, masked)
247
+ else:
248
+ # Kept as a conditional kwarg so mask-free denoiser doubles stay valid.
249
+ extra = {} if attn_mask is None else {"attn_mask": attn_mask}
250
+ logits = model(tokens, output_positions=masked, **extra)
251
+ if blocked_token_ids:
252
+ logits = logits.clone()
253
+ for token_id in blocked_token_ids:
254
+ logits[:, token_id] = torch.finfo(logits.dtype).min
255
+ predictions, confidence = _sample_categorical(logits, temperature, generator)
256
+
257
+ proposed = tokens.clone()
258
+ proposed[masked] = predictions
259
+ reveal = torch.zeros_like(masked)
260
+ steps_left = steps - step
261
+
262
+ if strategy == "ancestral":
263
+ # Linear t grid: P(unmask from t to s | still masked) = 1 - s/t.
264
+ reveal_probability = 1.0 / steps_left
265
+ reveal = (
266
+ torch.rand(tokens.shape, device=tokens.device, generator=generator)
267
+ < reveal_probability
268
+ ) & masked
269
+ elif strategy == "left_to_right":
270
+ for row in range(batch_size):
271
+ masked_positions = masked[row].nonzero(as_tuple=True)[0]
272
+ remaining = int(masked_positions.numel())
273
+ count = (remaining + steps_left - 1) // steps_left
274
+ if count:
275
+ reveal[row, masked_positions[:count]] = True
276
+ else:
277
+ confidence_grid = torch.full(
278
+ tokens.shape,
279
+ -torch.inf,
280
+ device=tokens.device,
281
+ dtype=confidence.dtype,
282
+ )
283
+ confidence_grid[masked] = confidence
284
+ for row in range(batch_size):
285
+ remaining = int(masked[row].sum())
286
+ count = (remaining + steps_left - 1) // steps_left
287
+ if count:
288
+ positions = confidence_grid[row].topk(count).indices
289
+ reveal[row, positions] = True
290
+
291
+ tokens = torch.where(reveal, proposed, tokens)
292
+
293
+ yield UnmaskStep(
294
+ step + 1,
295
+ steps,
296
+ tokens.detach(),
297
+ int(tokens.eq(mask_token_id).sum()),
298
+ )
299
+
300
+ if bool(tokens.eq(mask_token_id).any()):
301
+ raise RuntimeError(
302
+ "sampler finished with masked positions; this indicates an internal error"
303
+ )
304
+
305
+
306
+ @torch.no_grad()
307
+ def iterative_unmask(
308
+ model: Denoiser,
309
+ input_ids: Tensor,
310
+ mask_token_id: int,
311
+ *,
312
+ steps: int = 64,
313
+ temperature: float = 1.0,
314
+ strategy: Literal["ancestral", "confidence"] = "ancestral",
315
+ blocked_token_ids: tuple[int, ...] = (),
316
+ attn_mask: Tensor | None = None,
317
+ generator: torch.Generator | None = None,
318
+ logits_fn: Callable[[Tensor, Tensor], Tensor] | None = None,
319
+ ) -> Tensor:
320
+ """Return the final state from :func:`iterative_unmask_steps`."""
321
+
322
+ final_state: UnmaskStep | None = None
323
+ for state in iterative_unmask_steps(
324
+ model,
325
+ input_ids,
326
+ mask_token_id,
327
+ steps=steps,
328
+ temperature=temperature,
329
+ strategy=strategy,
330
+ blocked_token_ids=blocked_token_ids,
331
+ attn_mask=attn_mask,
332
+ generator=generator,
333
+ logits_fn=logits_fn,
334
+ ):
335
+ final_state = state
336
+ if final_state is None: # Defensive: the iterator always yields its initial state.
337
+ raise RuntimeError("sampler produced no state")
338
+ return final_state.tokens
src/diffusion_lm/distill.py ADDED
@@ -0,0 +1,328 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Distilled-trace preparation: reasoning traces packed with chained thought slots.
2
+
3
+ Streams a distilled-reasoning dataset (traces carrying a ``<think>...</think>`` region
4
+ followed by a final answer), segments each think region into a chain of thought units, and
5
+ renders every example through the shared slot geometry so a block-diffusion model denoises
6
+ one thought at a time. The number of thoughts varies per example, so slot count stands in
7
+ for how much the model chose to think.
8
+
9
+ Segmentation splits on paragraph breaks first, then on reasoning-marker sentence boundaries
10
+ inside long paragraphs, so a thought is a coherent reasoning move rather than a fixed-size
11
+ cut.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import hashlib
18
+ import json
19
+ import re
20
+ from pathlib import Path
21
+ from typing import Iterator
22
+
23
+ import numpy as np
24
+
25
+ from diffusion_lm.reasoning import (
26
+ ADAPTIVE_SPECIAL_TOKENS,
27
+ ExampleEncoder,
28
+ LayoutSpec,
29
+ REASONING_SPECIAL_TOKENS,
30
+ ReasoningExample,
31
+ size_token_ids,
32
+ _write_packed,
33
+ )
34
+ from diffusion_lm.tokenizer import load_tokenizer, train_tokenizer_from_iterator
35
+
36
+ _THINK_RE = re.compile(r'<think>(.*?)</think>\s*', re.DOTALL)
37
+ _MARKER_BREAK = re.compile(
38
+ r'(?<=[.!?])\s+(?=(?:Wait|Alternatively|Hmm|Okay|Now|But wait|Let me|First|Second|'
39
+ r'Next|Then|Finally|So,|Also|Another|Actually|Therefore|Thus)\b)'
40
+ )
41
+ _LONG_PARAGRAPH_CHARS = 900
42
+
43
+
44
+ def segment_thoughts(think: str) -> list[str]:
45
+ """Split a think region into a chain of coherent thought units."""
46
+
47
+ thoughts: list[str] = []
48
+ for paragraph in re.split(r'\n\s*\n', think):
49
+ paragraph = ' '.join(paragraph.split())
50
+ if not paragraph:
51
+ continue
52
+ if len(paragraph) > _LONG_PARAGRAPH_CHARS:
53
+ thoughts.extend(part.strip() for part in _MARKER_BREAK.split(paragraph) if part.strip())
54
+ else:
55
+ thoughts.append(paragraph)
56
+ return thoughts
57
+
58
+
59
+ def parse_glaive(row: dict[str, object]) -> ReasoningExample | None:
60
+ """Render one glaive ``reasoning-v1`` row into a chained-thought example."""
61
+
62
+ prompt = ' '.join(str(row.get('prompt') or '').split())
63
+ response = str(row.get('response') or '')
64
+ match = _THINK_RE.search(response)
65
+ if not prompt or not match:
66
+ return None
67
+ answer = ' '.join(response[match.end():].split())
68
+ thoughts = segment_thoughts(match.group(1))
69
+ if not answer or not thoughts:
70
+ return None
71
+ return ReasoningExample(prompt, tuple(thoughts), answer, expected_answer='')
72
+
73
+
74
+ PARSERS = {'glaive': parse_glaive}
75
+
76
+
77
+ def _stream_examples(
78
+ dataset: str, split: str, parser_name: str, limit: int
79
+ ) -> Iterator[ReasoningExample]:
80
+ from datasets import load_dataset
81
+
82
+ parser = PARSERS[parser_name]
83
+ kept = 0
84
+ for row in load_dataset(dataset, split=split, streaming=True):
85
+ example = parser(row)
86
+ if example is None:
87
+ continue
88
+ yield example
89
+ kept += 1
90
+ if limit and kept >= limit:
91
+ return
92
+
93
+
94
+ def _is_validation(problem: str, val_fraction: float) -> bool:
95
+ digest = hashlib.sha256(problem.encode('utf-8')).digest()
96
+ return int.from_bytes(digest[:4], 'big') / 2**32 < val_fraction
97
+
98
+
99
+ def _resolve_tokenizer(
100
+ examples: list[ReasoningExample],
101
+ args: argparse.Namespace,
102
+ special_tokens: tuple[str, ...] = REASONING_SPECIAL_TOKENS,
103
+ ):
104
+ from pathlib import Path
105
+
106
+ tokenizer_path = Path(args.tokenizer)
107
+ if tokenizer_path.is_file():
108
+ print(f'reusing tokenizer {tokenizer_path}')
109
+ return load_tokenizer(tokenizer_path), tokenizer_path
110
+
111
+ def _texts() -> Iterator[str]:
112
+ for example in examples:
113
+ yield f'{example.problem}\n{" ".join(example.steps)}\n{example.answer}'
114
+
115
+ tokenizer = train_tokenizer_from_iterator(
116
+ _texts(),
117
+ tokenizer_path,
118
+ vocab_size=args.vocab_size,
119
+ min_frequency=4,
120
+ length=len(examples),
121
+ extra_special_tokens=special_tokens,
122
+ )
123
+ print(f'trained tokenizer {tokenizer_path} (vocab {args.vocab_size}) on {len(examples):,} traces')
124
+ return tokenizer, tokenizer_path
125
+
126
+
127
+ def prepare(args: argparse.Namespace) -> None:
128
+ adaptive = bool(getattr(args, 'sizes', None))
129
+ spec = LayoutSpec(
130
+ seq_len=args.seq_len,
131
+ block=args.block,
132
+ max_slots=args.max_slots,
133
+ sizes=tuple(args.sizes) if adaptive else (),
134
+ )
135
+ output_dir = Path(args.output_dir)
136
+
137
+ # Buffer unique parsed examples so a native tokenizer can be trained in the same
138
+ # streaming pass the corpus is packed from, avoiding a second dataset download.
139
+ examples: list[ReasoningExample] = []
140
+ seen: set[str] = set()
141
+ scanned = 0
142
+ for example in _stream_examples(args.dataset, args.split, args.parser, args.scan_limit):
143
+ scanned += 1
144
+ key = hashlib.sha256(example.problem.encode('utf-8')).hexdigest()
145
+ if key in seen:
146
+ continue
147
+ seen.add(key)
148
+ examples.append(example)
149
+ if args.limit and len(examples) >= args.limit:
150
+ break
151
+ if not examples:
152
+ raise ValueError('no usable examples; check dataset, parser, and seq_len')
153
+ print(f'scanned {scanned:,}, buffered {len(examples):,} unique examples')
154
+
155
+ special_tokens = ADAPTIVE_SPECIAL_TOKENS if adaptive else REASONING_SPECIAL_TOKENS
156
+ tokenizer, tokenizer_path = _resolve_tokenizer(examples, args, special_tokens)
157
+ encoder = ExampleEncoder(tokenizer, spec)
158
+
159
+ if adaptive:
160
+ _pack_adaptive(examples, encoder, spec, tokenizer, tokenizer_path, output_dir, args)
161
+ return
162
+
163
+ split: dict[str, dict[str, list[np.ndarray]]] = {
164
+ 'train': {'flat': [], 'flat_regions': [], 'slotted': [], 'slotted_regions': []},
165
+ 'validation': {'flat': [], 'flat_regions': [], 'slotted': [], 'slotted_regions': []},
166
+ }
167
+ val_prompts: list[dict[str, str]] = []
168
+ dropped = 0
169
+ for example in examples:
170
+ encoded = encoder.encode_example(example)
171
+ if encoded is None:
172
+ dropped += 1
173
+ continue
174
+ is_val = _is_validation(example.problem, args.val_fraction)
175
+ bucket = split['validation' if is_val else 'train']
176
+ bucket['flat'].append(encoded.flat)
177
+ bucket['flat_regions'].append(encoded.flat_regions)
178
+ bucket['slotted'].append(encoded.slotted)
179
+ bucket['slotted_regions'].append(encoded.slotted_regions)
180
+ if is_val and len(val_prompts) < 500:
181
+ val_prompts.append({'problem': example.problem, 'expected_answer': ''})
182
+
183
+ total = sum(len(bucket['flat']) for bucket in split.values())
184
+ if not total:
185
+ raise ValueError('no examples survived encoding; raise seq_len or max_slots')
186
+ print(f'encoded {total:,} examples ({dropped:,} dropped at encode)')
187
+ for split_name, bucket in split.items():
188
+ if not bucket['flat']:
189
+ raise ValueError(f'no examples in the {split_name} split; lower val_fraction or scan more')
190
+ for layout in ('flat', 'slotted'):
191
+ _write_packed(
192
+ output_dir / f'{split_name}-{layout}.bin',
193
+ np.stack(bucket[layout]),
194
+ np.stack(bucket[f'{layout}_regions']),
195
+ layout=layout,
196
+ spec=spec,
197
+ tokenizer_path=tokenizer_path,
198
+ tokenizer=tokenizer,
199
+ )
200
+ print(f'{split_name}: {len(bucket["flat"]):,} examples -> {output_dir}')
201
+
202
+ problems_path = output_dir / 'validation-problems.jsonl'
203
+ with problems_path.open('w', encoding='utf-8') as handle:
204
+ for record in val_prompts:
205
+ handle.write(json.dumps(record, ensure_ascii=False) + '\n')
206
+ print(f'wrote {len(val_prompts):,} validation prompts to {problems_path}')
207
+
208
+
209
+ def _pack_adaptive(
210
+ examples: list[ReasoningExample],
211
+ encoder: ExampleEncoder,
212
+ spec: LayoutSpec,
213
+ tokenizer,
214
+ tokenizer_path: Path,
215
+ output_dir: Path,
216
+ args: argparse.Namespace,
217
+ ) -> None:
218
+ """Encode the adaptive layout and report the block-size distribution it produces."""
219
+
220
+ split: dict[str, dict[str, list[np.ndarray]]] = {
221
+ 'train': {'tokens': [], 'regions': []},
222
+ 'validation': {'tokens': [], 'regions': []},
223
+ }
224
+ val_prompts: list[dict[str, str]] = []
225
+ size_histogram: dict[int, int] = {size: 0 for size in spec.sizes}
226
+ blocks_per_example: list[int] = []
227
+ dropped = 0
228
+ for example in examples:
229
+ encoded = encoder.encode_adaptive(example)
230
+ if encoded is None:
231
+ dropped += 1
232
+ continue
233
+ is_val = _is_validation(example.problem, args.val_fraction)
234
+ bucket = split['validation' if is_val else 'train']
235
+ bucket['tokens'].append(encoded.tokens)
236
+ bucket['regions'].append(encoded.regions)
237
+ blocks_per_example.append(len(encoded.block_sizes))
238
+ for size in encoded.block_sizes:
239
+ size_histogram[size] += 1
240
+ if is_val and len(val_prompts) < 500:
241
+ val_prompts.append({'problem': example.problem, 'expected_answer': ''})
242
+
243
+ total = sum(len(bucket['tokens']) for bucket in split.values())
244
+ if not total:
245
+ raise ValueError('no examples survived encoding; raise seq_len, max_slots, or sizes')
246
+
247
+ total_blocks = sum(size_histogram.values())
248
+ fractions = {
249
+ size: round(count / max(1, total_blocks), 4) for size, count in size_histogram.items()
250
+ }
251
+ sorted_blocks = sorted(blocks_per_example)
252
+
253
+ def percentile(fraction: float) -> int:
254
+ return sorted_blocks[min(len(sorted_blocks) - 1, int(len(sorted_blocks) * fraction))]
255
+
256
+ print(f'encoded {total:,} examples ({dropped:,} dropped at encode)')
257
+ print(f'block-size counts {size_histogram} fractions {fractions}')
258
+ print(
259
+ f'blocks/example p50 {percentile(0.5)} p90 {percentile(0.9)} '
260
+ f'max {sorted_blocks[-1]}'
261
+ )
262
+
263
+ extra_metadata = {
264
+ 'sizes': list(spec.sizes),
265
+ 'size_token_ids': size_token_ids(tokenizer),
266
+ }
267
+ for split_name, bucket in split.items():
268
+ if not bucket['tokens']:
269
+ raise ValueError(f'no examples in the {split_name} split; lower val_fraction')
270
+ _write_packed(
271
+ output_dir / f'{split_name}-adaptive.bin',
272
+ np.stack(bucket['tokens']),
273
+ np.stack(bucket['regions']),
274
+ layout='adaptive',
275
+ spec=spec,
276
+ tokenizer_path=tokenizer_path,
277
+ tokenizer=tokenizer,
278
+ extra_metadata=extra_metadata,
279
+ )
280
+ print(f'{split_name}: {len(bucket["tokens"]):,} examples -> {output_dir}')
281
+
282
+ problems_path = output_dir / 'validation-problems.jsonl'
283
+ with problems_path.open('w', encoding='utf-8') as handle:
284
+ for record in val_prompts:
285
+ handle.write(json.dumps(record, ensure_ascii=False) + '\n')
286
+ print(f'wrote {len(val_prompts):,} validation prompts to {problems_path}')
287
+
288
+
289
+ def _build_parser() -> argparse.ArgumentParser:
290
+ parser = argparse.ArgumentParser(description=__doc__)
291
+ subparsers = parser.add_subparsers(dest='command', required=True)
292
+ prepare_parser = subparsers.add_parser(
293
+ 'prepare', help='stream a distilled-reasoning dataset into chained-thought slots'
294
+ )
295
+ prepare_parser.add_argument('--dataset', default='glaiveai/reasoning-v1-20m')
296
+ prepare_parser.add_argument('--split', default='train')
297
+ prepare_parser.add_argument('--parser', choices=sorted(PARSERS), default='glaive')
298
+ prepare_parser.add_argument(
299
+ '--tokenizer', required=True,
300
+ help='tokenizer path; trained from the buffered traces when the file is absent'
301
+ )
302
+ prepare_parser.add_argument('--vocab-size', type=int, default=16384)
303
+ prepare_parser.add_argument('--output-dir', required=True)
304
+ prepare_parser.add_argument('--seq-len', type=int, default=2048)
305
+ prepare_parser.add_argument('--block', type=int, default=32)
306
+ prepare_parser.add_argument('--max-slots', type=int, default=64)
307
+ prepare_parser.add_argument(
308
+ '--sizes', type=int, nargs='+',
309
+ help='activate the adaptive layout with these ascending block sizes (e.g. 64 256)'
310
+ )
311
+ prepare_parser.add_argument('--val-fraction', type=float, default=0.02)
312
+ prepare_parser.add_argument(
313
+ '--limit', type=int, default=0, help='stop after this many unique kept examples'
314
+ )
315
+ prepare_parser.add_argument(
316
+ '--scan-limit', type=int, default=0, help='stop streaming after this many parsed rows'
317
+ )
318
+ return parser
319
+
320
+
321
+ def main() -> None:
322
+ args = _build_parser().parse_args()
323
+ if args.command == 'prepare':
324
+ prepare(args)
325
+
326
+
327
+ if __name__ == '__main__':
328
+ main()
src/diffusion_lm/flexattn.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pre-LN Transformer encoder that routes self-attention through ``flex_attention``.
2
+
3
+ The block-diffusion masks are structured (block-causal over thought slots plus optional
4
+ key padding), so expressing them as a ``flex_attention`` block mask lets the fused kernel
5
+ skip fully-masked blocks instead of materializing a dense ``[batch * heads, L, L]`` score
6
+ tensor. This keeps attention memory and time near-linear in the trace length, which the
7
+ default ``nn.TransformerEncoder`` math path does not.
8
+
9
+ The layer geometry mirrors ``nn.TransformerEncoderLayer(norm_first=True, activation='gelu')``
10
+ so behaviour matches the default path up to floating-point error; the attention core is
11
+ verified against a dense masked-softmax reference before use.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ import os
18
+
19
+ import torch
20
+ import torch.nn.functional as F
21
+ from torch import Tensor, nn
22
+ from torch.nn.attention.flex_attention import BlockMask, create_block_mask, flex_attention
23
+
24
+ # Compiled dynamic=False is fastest for fixed-shape training. Variable-length generation
25
+ # recompiles per new sequence length, so serving sets MDLM_FLEX_EAGER=1 to run eager and
26
+ # trade steady-state speed for the absence of per-length recompilation stalls.
27
+ _FLEX_EAGER = os.environ.get('MDLM_FLEX_EAGER') == '1'
28
+ _flex_compiled = flex_attention if _FLEX_EAGER else torch.compile(flex_attention, dynamic=False)
29
+
30
+
31
+ def build_block_mask(
32
+ attn_mask: Tensor | None,
33
+ padding_mask: Tensor | None,
34
+ batch_size: int,
35
+ seq_len: int,
36
+ device: torch.device,
37
+ ) -> BlockMask | None:
38
+ """Build a broadcast-over-heads ``BlockMask`` from project boolean masks.
39
+
40
+ ``attn_mask`` follows the src_mask convention (``True`` blocks a key), shaped ``[L, L]``
41
+ or ``[batch, L, L]``. ``padding_mask`` follows the src_key_padding_mask convention
42
+ (``True`` marks padding). Returns ``None`` when neither constrains attention.
43
+ """
44
+
45
+ if attn_mask is None and padding_mask is None:
46
+ return None
47
+
48
+ shared = attn_mask is not None and attn_mask.dim() == 2
49
+
50
+ def mask_mod(b: Tensor, h: Tensor, q_idx: Tensor, kv_idx: Tensor) -> Tensor:
51
+ keep = torch.ones_like(q_idx, dtype=torch.bool)
52
+ if attn_mask is not None:
53
+ blocked = attn_mask[q_idx, kv_idx] if shared else attn_mask[b, q_idx, kv_idx]
54
+ keep = keep & ~blocked
55
+ if padding_mask is not None:
56
+ keep = keep & ~padding_mask[b, kv_idx]
57
+ return keep
58
+
59
+ return create_block_mask(
60
+ mask_mod, batch_size, None, seq_len, seq_len, device=device, _compile=not _FLEX_EAGER
61
+ )
62
+
63
+
64
+ def flex_self_attention(
65
+ query: Tensor, key: Tensor, value: Tensor, block_mask: BlockMask | None
66
+ ) -> Tensor:
67
+ """Multi-head self-attention over ``[batch, heads, L, head_dim]`` tensors."""
68
+
69
+ if block_mask is None:
70
+ return flex_attention(query, key, value)
71
+ return _flex_compiled(query, key, value, block_mask=block_mask)
72
+
73
+
74
+ class FlexEncoderLayer(nn.Module):
75
+ """Pre-LN Transformer block with a ``flex_attention`` self-attention core."""
76
+
77
+ def __init__(self, d_model: int, n_heads: int, d_ff: int, dropout: float) -> None:
78
+ super().__init__()
79
+ if d_model % n_heads != 0:
80
+ raise ValueError("d_model must be divisible by n_heads")
81
+ self.n_heads = n_heads
82
+ self.head_dim = d_model // n_heads
83
+ self.q_proj = nn.Linear(d_model, d_model)
84
+ self.k_proj = nn.Linear(d_model, d_model)
85
+ self.v_proj = nn.Linear(d_model, d_model)
86
+ self.out_proj = nn.Linear(d_model, d_model)
87
+ self.linear1 = nn.Linear(d_model, d_ff)
88
+ self.linear2 = nn.Linear(d_ff, d_model)
89
+ self.norm1 = nn.LayerNorm(d_model)
90
+ self.norm2 = nn.LayerNorm(d_model)
91
+ self.dropout = nn.Dropout(dropout)
92
+
93
+ def _split_heads(self, projected: Tensor) -> Tensor:
94
+ batch_size, seq_len, _ = projected.shape
95
+ return projected.view(batch_size, seq_len, self.n_heads, self.head_dim).transpose(1, 2)
96
+
97
+ def forward(self, hidden: Tensor, block_mask: BlockMask | None) -> Tensor:
98
+ normed = self.norm1(hidden)
99
+ query = self._split_heads(self.q_proj(normed))
100
+ key = self._split_heads(self.k_proj(normed))
101
+ value = self._split_heads(self.v_proj(normed))
102
+ attended = flex_self_attention(query, key, value, block_mask)
103
+ batch_size, _, seq_len, _ = attended.shape
104
+ attended = attended.transpose(1, 2).reshape(batch_size, seq_len, -1)
105
+ hidden = hidden + self.dropout(self.out_proj(attended))
106
+ normed = self.norm2(hidden)
107
+ feed_forward = self.linear2(self.dropout(F.gelu(self.linear1(normed))))
108
+ return hidden + self.dropout(feed_forward)
109
+
110
+
111
+ class FlexEncoder(nn.Module):
112
+ """Stack of :class:`FlexEncoderLayer` blocks with a final layer norm."""
113
+
114
+ def __init__(
115
+ self,
116
+ d_model: int,
117
+ n_heads: int,
118
+ d_ff: int,
119
+ dropout: float,
120
+ n_layers: int,
121
+ activation_checkpointing: bool,
122
+ ) -> None:
123
+ super().__init__()
124
+ self.layers = nn.ModuleList(
125
+ FlexEncoderLayer(d_model, n_heads, d_ff, dropout) for _ in range(n_layers)
126
+ )
127
+ self.norm = nn.LayerNorm(d_model)
128
+ self.activation_checkpointing = activation_checkpointing
129
+
130
+ def init_residual_outputs(self, n_layers: int) -> None:
131
+ residual_std = 0.02 / math.sqrt(2 * n_layers)
132
+ for layer in self.layers:
133
+ nn.init.normal_(layer.out_proj.weight, mean=0.0, std=residual_std)
134
+ nn.init.normal_(layer.linear2.weight, mean=0.0, std=residual_std)
135
+
136
+ def forward(self, hidden: Tensor, block_mask: BlockMask | None) -> Tensor:
137
+ use_checkpoint = (
138
+ self.activation_checkpointing and self.training and torch.is_grad_enabled()
139
+ )
140
+ for layer in self.layers:
141
+ if use_checkpoint:
142
+ hidden = torch.utils.checkpoint.checkpoint(
143
+ layer, hidden, block_mask, use_reentrant=False
144
+ )
145
+ else:
146
+ hidden = layer(hidden, block_mask)
147
+ return self.norm(hidden)
src/diffusion_lm/hf_bridge.py ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HuggingFace Qwen3 backbone served through the project's denoiser forward contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ import torch
8
+ from torch import Tensor, nn
9
+
10
+ from diffusion_lm.config import ModelConfig
11
+
12
+ # The flex template's default 128x128 tiles need ~112 KiB of shared memory at head_dim 128,
13
+ # over Ada's 100 KiB per-block ceiling; halved tiles fit. The backward kernel budgets its
14
+ # tiles separately, hence the M1/N1/M2/N2 entries. Larger tiles are valid on Hopper.
15
+ _FLEX_KERNEL_OPTIONS = {
16
+ 'BLOCK_M': 64,
17
+ 'BLOCK_N': 64,
18
+ 'BLOCK_M1': 32,
19
+ 'BLOCK_N1': 64,
20
+ 'BLOCK_M2': 64,
21
+ 'BLOCK_N2': 32,
22
+ }
23
+
24
+
25
+ class Qwen3Denoiser(nn.Module):
26
+ """Wrap ``Qwen3ForCausalLM`` behind the DiffusionTransformer forward contract.
27
+
28
+ The backbone always receives a 4D attention mask so its stock causal masking never
29
+ engages: block-diffusion objectives need bidirectional attention inside denoising
30
+ windows, and an omitted mask must mean "attend everything", not "causal".
31
+ ``use_flex_attention`` selects how the boolean blocking matrix reaches the backbone — a
32
+ ``BlockMask`` for the flex kernel, which skips fully-masked blocks, or a materialized
33
+ additive mask for SDPA. Hidden states are gathered at ``output_positions`` before the LM
34
+ head so full-vocabulary logits are never materialized for visible tokens.
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ config: ModelConfig,
40
+ *,
41
+ load_pretrained: bool = True,
42
+ dtype: torch.dtype | None = None,
43
+ ) -> None:
44
+ super().__init__()
45
+ try:
46
+ from transformers import Qwen3Config, Qwen3ForCausalLM
47
+ except ImportError as exc:
48
+ raise RuntimeError(
49
+ 'the hf-qwen3 backbone requires transformers; install the [hf] extra'
50
+ ) from exc
51
+ if config.pretrained_path is None:
52
+ raise ValueError('the hf-qwen3 backbone requires pretrained_path')
53
+ self.config = config
54
+ # Generation on Ada can hit the Triton shared-memory ceiling inside transformers' own
55
+ # compiled flex kernel, where our kernel_options do not reach. MDLM_SDPA=1 sidesteps it
56
+ # for serving and evaluation; flex earns its keep in training, not at q_len 1.
57
+ use_flex = config.use_flex_attention and os.environ.get('MDLM_SDPA') != '1'
58
+ attn_implementation = 'flex_attention' if use_flex else 'sdpa'
59
+ if load_pretrained:
60
+ kwargs = {} if dtype is None else {'dtype': dtype}
61
+ self.backbone = Qwen3ForCausalLM.from_pretrained(
62
+ config.pretrained_path, attn_implementation=attn_implementation, **kwargs
63
+ )
64
+ else:
65
+ # Architecture-only construction: weights come from a later load_state_dict,
66
+ # so checkpoint restore never re-reads the base model files.
67
+ backbone_config = Qwen3Config.from_pretrained(
68
+ config.pretrained_path, attn_implementation=attn_implementation
69
+ )
70
+ self.backbone = Qwen3ForCausalLM(backbone_config)
71
+ if dtype is not None:
72
+ self.backbone.to(dtype)
73
+ self._use_flex = use_flex
74
+ self._validate_backbone()
75
+ self.backbone.config.use_cache = False
76
+ if config.activation_checkpointing:
77
+ self.backbone.gradient_checkpointing_enable(
78
+ gradient_checkpointing_kwargs={'use_reentrant': False}
79
+ )
80
+ # A plain attribute keeps the compiled callable out of the module tree, so checkpoint
81
+ # keys are identical whether or not compilation is on. Shapes reaching the backbone are
82
+ # static; the varying count of output positions is gathered after it returns.
83
+ # Serving generates at ever-changing lengths, which makes a compiled backbone
84
+ # recompile per shape; MDLM_NO_COMPILE=1 turns it off without touching the checkpoint.
85
+ compiling = config.compile_backbone and os.environ.get('MDLM_NO_COMPILE') != '1'
86
+ self._compiled_backbone = (
87
+ torch.compile(self.backbone.model.forward, dynamic=False) if compiling else None
88
+ )
89
+ self.register_buffer(
90
+ '_forbidden_output_token_ids',
91
+ torch.tensor(config.forbidden_output_token_ids, dtype=torch.long),
92
+ persistent=False,
93
+ )
94
+
95
+ def _validate_backbone(self) -> None:
96
+ backbone = self.backbone.config
97
+ pairs = (
98
+ ('vocab_size', self.config.vocab_size, backbone.vocab_size),
99
+ ('d_model', self.config.d_model, backbone.hidden_size),
100
+ ('n_layers', self.config.n_layers, backbone.num_hidden_layers),
101
+ ('n_heads', self.config.n_heads, backbone.num_attention_heads),
102
+ ('d_ff', self.config.d_ff, backbone.intermediate_size),
103
+ )
104
+ for name, expected, actual in pairs:
105
+ if expected != actual:
106
+ raise ValueError(f'config {name}={expected} but the backbone has {actual}')
107
+ if self.config.max_seq_len > backbone.max_position_embeddings:
108
+ raise ValueError(
109
+ f'max_seq_len {self.config.max_seq_len} exceeds the backbone context '
110
+ f'{backbone.max_position_embeddings}'
111
+ )
112
+
113
+ def _blocked_matrix(
114
+ self,
115
+ input_ids: Tensor,
116
+ attention_mask: Tensor | None,
117
+ attn_mask: Tensor | None,
118
+ ) -> Tensor:
119
+ """Boolean ``[batch, L, L]`` blocking matrix, ``True`` marking a key to suppress."""
120
+
121
+ batch_size, seq_len = input_ids.shape
122
+ device = input_ids.device
123
+ if attn_mask is None:
124
+ blocked = torch.zeros(
125
+ batch_size, seq_len, seq_len, dtype=torch.bool, device=device
126
+ )
127
+ else:
128
+ if attn_mask.dtype != torch.bool:
129
+ raise ValueError('attn_mask must be boolean with True marking blocked positions')
130
+ if attn_mask.shape == (seq_len, seq_len):
131
+ blocked = attn_mask.unsqueeze(0).expand(batch_size, -1, -1)
132
+ elif attn_mask.shape == (batch_size, seq_len, seq_len):
133
+ blocked = attn_mask
134
+ else:
135
+ raise ValueError('attn_mask must have shape [L, L] or [batch, L, L]')
136
+ if attention_mask is not None:
137
+ if attention_mask.shape != input_ids.shape:
138
+ raise ValueError('attention_mask must match input_ids')
139
+ blocked = blocked | ~attention_mask.bool()[:, None, :]
140
+ return blocked
141
+
142
+ def _additive_mask(self, blocked: Tensor) -> Tensor:
143
+ mask_dtype = self.backbone.model.embed_tokens.weight.dtype
144
+ additive = torch.zeros(blocked.shape, dtype=mask_dtype, device=blocked.device)
145
+ additive = additive.masked_fill(blocked, torch.finfo(mask_dtype).min)
146
+ return additive.unsqueeze(1)
147
+
148
+ def forward_cached(
149
+ self,
150
+ input_ids: Tensor,
151
+ *,
152
+ attn_mask: Tensor,
153
+ past_key_values,
154
+ output_positions: Tensor | None = None,
155
+ ) -> tuple[Tensor, object]:
156
+ """Run only the trailing ``input_ids`` against a populated key/value cache.
157
+
158
+ ``attn_mask`` holds one row per NEW query and one column per position the query may
159
+ see, cache included: ``[batch, query, cached + query]``. Block-causal masking is what
160
+ makes caching sound here — prefix positions never attend forward into a block, so
161
+ their keys and values stay valid while the block's own tokens keep changing.
162
+ """
163
+
164
+ cached = past_key_values.get_seq_length()
165
+ query_len = input_ids.shape[1]
166
+ blocked = attn_mask if attn_mask.dim() == 3 else attn_mask.unsqueeze(0)
167
+ if blocked.shape[-2] != query_len or blocked.shape[-1] != cached + query_len:
168
+ raise ValueError(
169
+ f'attn_mask must be [batch, {query_len}, {cached + query_len}] for a cache of '
170
+ f'{cached} positions, got {tuple(blocked.shape)}'
171
+ )
172
+ rows = blocked.expand(input_ids.shape[0], -1, -1)
173
+ outputs = self.backbone.model(
174
+ input_ids=input_ids,
175
+ attention_mask=self._additive_mask(rows),
176
+ past_key_values=past_key_values,
177
+ use_cache=True,
178
+ **self._backbone_kwargs(),
179
+ )
180
+ hidden = outputs.last_hidden_state
181
+ if output_positions is not None:
182
+ hidden = hidden[output_positions.bool()]
183
+ logits = self.backbone.lm_head(hidden)
184
+ self._forbid(logits)
185
+ return logits, outputs.past_key_values
186
+
187
+ def _backbone_kwargs(self) -> dict:
188
+ """Extra backbone arguments the attention implementation needs.
189
+
190
+ Every path into the backbone has to carry these, cached or not: without the reduced
191
+ tiles the flex template asks Ada for more shared memory than it has and the kernel
192
+ fails to compile at all.
193
+ """
194
+
195
+ if not self._use_flex:
196
+ return {}
197
+ return {'kernel_options': _FLEX_KERNEL_OPTIONS}
198
+
199
+ def new_cache(self):
200
+ """Empty key/value cache for :meth:`forward_cached`, kept here so callers stay
201
+ independent of the transformers cache class."""
202
+
203
+ from transformers import DynamicCache
204
+
205
+ return DynamicCache()
206
+
207
+ def _forbid(self, logits: Tensor) -> None:
208
+ if self._forbidden_output_token_ids.numel():
209
+ logits.index_fill_(
210
+ -1, self._forbidden_output_token_ids, torch.finfo(logits.dtype).min
211
+ )
212
+ if self.config.forbidden_output_from is not None:
213
+ logits[..., self.config.forbidden_output_from :] = torch.finfo(logits.dtype).min
214
+
215
+ def _flex_mask(self, blocked: Tensor):
216
+ """Compress the blocking matrix into a ``BlockMask`` the flex kernel can skip over.
217
+
218
+ ``masking_utils`` forwards any mask reporting a 4D shape untouched, so a ``BlockMask``
219
+ reaches ``flex_attention_forward`` as ``block_mask`` and the backbone never rebuilds a
220
+ causal mask of its own.
221
+ """
222
+
223
+ from diffusion_lm.flexattn import build_block_mask
224
+
225
+ batch_size, seq_len, _ = blocked.shape
226
+ return build_block_mask(blocked, None, batch_size, seq_len, blocked.device)
227
+
228
+ def forward(
229
+ self,
230
+ input_ids: Tensor,
231
+ attention_mask: Tensor | None = None,
232
+ output_positions: Tensor | None = None,
233
+ attn_mask: Tensor | None = None,
234
+ ) -> Tensor:
235
+ if input_ids.ndim != 2:
236
+ raise ValueError('input_ids must have shape [batch, sequence]')
237
+ sequence_length = input_ids.shape[1]
238
+ if sequence_length > self.config.max_seq_len:
239
+ raise ValueError(
240
+ f'sequence length {sequence_length} exceeds max_seq_len '
241
+ f'{self.config.max_seq_len}'
242
+ )
243
+ blocked = self._blocked_matrix(input_ids, attention_mask, attn_mask)
244
+ mask = (
245
+ self._flex_mask(blocked) if self._use_flex else self._additive_mask(blocked)
246
+ )
247
+ call = self._compiled_backbone or self.backbone.model
248
+ hidden = call(
249
+ input_ids=input_ids, attention_mask=mask, use_cache=False,
250
+ **self._backbone_kwargs(),
251
+ ).last_hidden_state
252
+ if output_positions is not None:
253
+ if output_positions.shape != input_ids.shape:
254
+ raise ValueError('output_positions must match input_ids')
255
+ hidden = hidden[output_positions.bool()]
256
+ logits = self.backbone.lm_head(hidden)
257
+ self._forbid(logits)
258
+ return logits
259
+
260
+ @property
261
+ def token_embedding(self) -> nn.Embedding:
262
+ """Embedding module surfaced for the optimizer's 32-bit override (tied to the head)."""
263
+
264
+ return self.backbone.model.embed_tokens
265
+
266
+ @property
267
+ def num_parameters(self) -> int:
268
+ """Count unique trainable parameters (shared embeddings count once)."""
269
+
270
+ return sum(parameter.numel() for parameter in self.parameters() if parameter.requires_grad)
src/diffusion_lm/hybrid.py ADDED
@@ -0,0 +1,1139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Region-aware attention masks, objectives, and samplers for reasoning variants.
2
+
3
+ Three inference modes share one ``DiffusionTransformer``:
4
+
5
+ - ``ar``: prefix-LM. Bidirectional over the problem, causal generation after it.
6
+ - ``diffusion``: full-sequence denoising of the response region in parallel.
7
+ - ``hybrid``: thought slots denoised block-by-block, then the answer decoded
8
+ autoregressively under a bidirectional-prefix mask.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import time
15
+ from dataclasses import dataclass
16
+
17
+ import torch
18
+ import torch.nn.functional as F
19
+ from torch import Tensor
20
+
21
+ from diffusion_lm.diffusion import (
22
+ CorruptionBatch,
23
+ corrupt_tokens,
24
+ iterative_unmask,
25
+ _sample_categorical,
26
+ )
27
+ from diffusion_lm.model import DiffusionTransformer
28
+
29
+
30
+ def prefix_causal_blocked(
31
+ prefix_lens: Tensor, seq_len: int, *, causal_prefix: bool = False
32
+ ) -> Tensor:
33
+ """Blocked-attention mask: bidirectional before ``prefix_len``, causal after.
34
+
35
+ ``allowed[b, i, j] = j < prefix_lens[b] or j <= i``; the returned tensor is the
36
+ inverse, matching the src_mask convention where ``True`` blocks attention.
37
+ ``causal_prefix=True`` degenerates to the plain causal mask — the geometry
38
+ pretrained autoregressive backbones were trained under.
39
+ """
40
+
41
+ positions = torch.arange(seq_len, device=prefix_lens.device)
42
+ causal = positions[None, :, None] >= positions[None, None, :]
43
+ if causal_prefix:
44
+ return (~causal).expand(prefix_lens.shape[0], seq_len, seq_len)
45
+ prefix = positions[None, None, :] < prefix_lens[:, None, None]
46
+ return ~(prefix | causal)
47
+
48
+
49
+ def window_blocked(window_ends: Tensor, seq_len: int) -> Tensor:
50
+ """Blocked-attention mask hiding all keys at or beyond each sample's window end."""
51
+
52
+ positions = torch.arange(seq_len, device=window_ends.device)
53
+ allowed = positions[None, None, :] < window_ends[:, None, None]
54
+ return ~allowed.expand(window_ends.shape[0], seq_len, seq_len)
55
+
56
+
57
+ def slot_causal_blocked(
58
+ problem_len: Tensor, n_slots: Tensor, block: int, seq_len: int
59
+ ) -> Tensor:
60
+ """Block-causal mask over thought slots: each slot attends its prefix slots only.
61
+
62
+ Problem tokens (plus ``<think>``) attend the problem window; tokens of slot k
63
+ attend everything up to slot k's end; positions past the think region attend
64
+ the whole think window. Denoising slot k at inference with clean prefix slots
65
+ is the per-slot ``t -> 0`` limit of the training distribution.
66
+ """
67
+
68
+ device = problem_len.device
69
+ positions = torch.arange(seq_len, device=device)[None, :]
70
+ prefix_end = (problem_len + 1)[:, None]
71
+ think_end = prefix_end + n_slots[:, None] * block
72
+ slot_index = torch.clamp((positions - prefix_end) // block, min=0)
73
+ slot_end = prefix_end + (slot_index + 1) * block
74
+ window = torch.where(positions < prefix_end, prefix_end, slot_end)
75
+ window = torch.where(positions >= think_end, think_end, window)
76
+ allowed = positions[:, None, :] < window[:, :, None]
77
+ return ~allowed
78
+
79
+
80
+ def adaptive_block_mask(
81
+ tokens: Tensor,
82
+ problem_len: Tensor,
83
+ answer_start: Tensor,
84
+ size_ids: Tensor,
85
+ end_think_id: int,
86
+ *,
87
+ causal_prefix: bool = False,
88
+ ) -> Tensor:
89
+ """Block-causal mask over variable-length thought blocks.
90
+
91
+ Block boundaries are read directly from the token stream: every ``<szN>``
92
+ control token and the terminal ``</think>`` mark the start of the next region.
93
+ A thought-content token attends its clean prefix plus its own block
94
+ bidirectionally, never a following block; problem and ``<think>`` positions see
95
+ the problem window only — or, with ``causal_prefix=True``, only their causal
96
+ past, matching a pretrained autoregressive backbone. The returned tensor
97
+ follows the src_mask convention where ``True`` blocks a key.
98
+ """
99
+
100
+ positions = torch.arange(tokens.shape[1], device=tokens.device)
101
+ prefix_end = (problem_len + 1)[:, None]
102
+ in_think = (positions[None, :] >= prefix_end) & (
103
+ positions[None, :] < answer_start[:, None]
104
+ )
105
+ is_size = (tokens.unsqueeze(-1) == size_ids).any(dim=-1)
106
+ boundary = (is_size | (tokens == end_think_id)) & in_think
107
+ return block_mask_from_boundaries(
108
+ boundary, problem_len + 1, answer_start, causal_prefix=causal_prefix
109
+ )
110
+
111
+
112
+ def block_mask_from_boundaries(
113
+ boundary: Tensor,
114
+ prefix_end: Tensor,
115
+ answer_start: Tensor,
116
+ *,
117
+ causal_prefix: bool = False,
118
+ ) -> Tensor:
119
+ """Blocked mask from per-position block-start marks (the adaptive-mask core).
120
+
121
+ ``boundary[b, p]`` is True where a new block starts. A position attends every key
122
+ before the next boundary after it: its own block bidirectionally plus all preceding
123
+ context. Rows before ``prefix_end`` see the prefix window (or their causal past with
124
+ ``causal_prefix=True``); rows at or past ``answer_start`` see up to ``answer_start``.
125
+ """
126
+
127
+ device = boundary.device
128
+ batch_size, seq_len = boundary.shape
129
+ positions = torch.arange(seq_len, device=device)
130
+ prefix_end = prefix_end[:, None]
131
+ answer_start = answer_start[:, None]
132
+
133
+ boundary_index = torch.where(
134
+ boundary, positions[None, :].expand(batch_size, seq_len), seq_len
135
+ )
136
+ reverse_cummin = boundary_index.flip(1).cummin(dim=1).values.flip(1)
137
+ next_boundary = torch.full((batch_size, seq_len), seq_len, device=device)
138
+ next_boundary[:, :-1] = reverse_cummin[:, 1:]
139
+
140
+ if causal_prefix:
141
+ prefix_limit = (positions[None, :] + 1).expand(batch_size, seq_len)
142
+ else:
143
+ prefix_limit = prefix_end.expand(batch_size, seq_len)
144
+ attend_limit = next_boundary
145
+ attend_limit = torch.where(positions[None, :] < prefix_end, prefix_limit, attend_limit)
146
+ attend_limit = torch.where(
147
+ positions[None, :] >= answer_start,
148
+ answer_start.expand(batch_size, seq_len),
149
+ attend_limit,
150
+ )
151
+ allowed = positions[None, None, :] < attend_limit[:, :, None]
152
+ return ~allowed
153
+
154
+
155
+ @dataclass(frozen=True)
156
+ class HybridBatchLoss:
157
+ loss: Tensor
158
+ think_loss: float
159
+ answer_loss: float
160
+ think_accuracy: float
161
+ answer_accuracy: float
162
+ think_samples: int
163
+ answer_samples: int
164
+ # Accuracy of the block-boundary decision, restricted to the control menu, and of the
165
+ # stop-versus-continue half of it on its own. Zero outside the adaptive objective.
166
+ control_accuracy: float = 0.0
167
+ stop_accuracy: float = 0.0
168
+
169
+
170
+ def hybrid_objective(
171
+ model: DiffusionTransformer,
172
+ tokens: Tensor,
173
+ regions: Tensor,
174
+ *,
175
+ block: int,
176
+ think_probability: float,
177
+ mask_eps: float,
178
+ generator: torch.Generator | None = None,
179
+ ) -> HybridBatchLoss:
180
+ """Mixed objective: denoise all thought slots or predict the answer tokens.
181
+
182
+ Each sample is assigned one mode. Think samples corrupt every slot at an
183
+ independent noise level under a block-causal mask, so one forward trains all
184
+ slot conditionals; answer samples see the full clean reasoning prefix
185
+ bidirectionally and the answer causally.
186
+ """
187
+
188
+ device = tokens.device
189
+ batch_size, seq_len = tokens.shape
190
+ problem_len, n_slots, answer_start, answer_end = regions.unbind(dim=1)
191
+ positions = torch.arange(seq_len, device=device)
192
+
193
+ think_sel = (
194
+ torch.rand(batch_size, device=device, generator=generator) < think_probability
195
+ )
196
+ if bool(think_sel.all()):
197
+ think_sel[-1] = False
198
+ if not bool(think_sel.any()):
199
+ think_sel[0] = True
200
+
201
+ prefix_end = problem_len + 1
202
+ think_end = prefix_end + n_slots * block
203
+ think_region = (
204
+ (positions[None, :] >= prefix_end[:, None])
205
+ & (positions[None, :] < think_end[:, None])
206
+ & think_sel[:, None]
207
+ )
208
+ max_slots = int(n_slots.max())
209
+ slot_noise = mask_eps + (1.0 - mask_eps) * torch.rand(
210
+ batch_size, max_slots, device=device, generator=generator
211
+ )
212
+ slot_index = torch.clamp(
213
+ (positions[None, :] - prefix_end[:, None]) // block, min=0, max=max_slots - 1
214
+ )
215
+ token_noise = slot_noise.gather(1, slot_index)
216
+ mask = (
217
+ torch.rand(tokens.shape, device=device, generator=generator) < token_noise
218
+ ) & think_region
219
+ noisy_tokens = torch.where(mask, model.config.mask_token_id, tokens)
220
+
221
+ think_blocked = slot_causal_blocked(problem_len, n_slots, block, seq_len)
222
+ answer_blocked = prefix_causal_blocked(answer_start, seq_len)
223
+ blocked = torch.where(think_sel[:, None, None], think_blocked, answer_blocked)
224
+
225
+ predict_positions = (
226
+ (positions[None, :] >= answer_start[:, None] - 1)
227
+ & (positions[None, :] < answer_end[:, None] - 1)
228
+ & ~think_sel[:, None]
229
+ )
230
+ output_positions = mask | predict_positions
231
+ logits = model(noisy_tokens, output_positions=output_positions, attn_mask=blocked)
232
+
233
+ think_rows = mask[output_positions]
234
+ # Graph-connected zero: forbidden-output columns sit at finfo.min, so a raw sum
235
+ # overflows to -inf in low precision and would poison the scalar via -inf * 0.
236
+ zero = logits.sum().clamp(-1.0, 1.0) * 0.0
237
+
238
+ think_loss = zero
239
+ think_accuracy = 0.0
240
+ if bool(mask.any()):
241
+ think_logits = logits[think_rows]
242
+ think_targets = tokens[mask]
243
+ per_token = F.cross_entropy(think_logits.float(), think_targets, reduction='none')
244
+ weights = token_noise[mask]
245
+ normalizer = think_region.sum().clamp_min(1)
246
+ think_loss = (per_token / weights).sum() / normalizer
247
+ think_accuracy = float(
248
+ (think_logits.argmax(dim=-1) == think_targets).float().mean()
249
+ )
250
+
251
+ answer_loss = zero
252
+ answer_accuracy = 0.0
253
+ if bool(predict_positions.any()):
254
+ answer_logits = logits[~think_rows]
255
+ target_positions = torch.zeros_like(predict_positions)
256
+ target_positions[:, 1:] = predict_positions[:, :-1]
257
+ answer_targets = tokens[target_positions]
258
+ answer_loss = F.cross_entropy(answer_logits.float(), answer_targets)
259
+ answer_accuracy = float(
260
+ (answer_logits.argmax(dim=-1) == answer_targets).float().mean()
261
+ )
262
+
263
+ return HybridBatchLoss(
264
+ loss=think_loss + answer_loss,
265
+ think_loss=float(think_loss),
266
+ answer_loss=float(answer_loss),
267
+ think_accuracy=think_accuracy,
268
+ answer_accuracy=answer_accuracy,
269
+ think_samples=int(think_sel.sum()),
270
+ answer_samples=int((~think_sel).sum()),
271
+ )
272
+
273
+
274
+ def adaptive_hybrid_objective(
275
+ model: DiffusionTransformer,
276
+ tokens: Tensor,
277
+ regions: Tensor,
278
+ *,
279
+ size_ids: Tensor,
280
+ end_think_id: int,
281
+ think_probability: float,
282
+ mask_eps: float,
283
+ causal_prefix: bool = False,
284
+ control_context_noise: float = 0.0,
285
+ generator: torch.Generator | None = None,
286
+ ) -> HybridBatchLoss:
287
+ """Mixed objective for the adaptive layout.
288
+
289
+ Think samples denoise every thought block at an independent noise level under
290
+ the variable-boundary block-causal mask. The remaining samples run a causal
291
+ next-token objective over the whole reasoning-and-answer stream, which is where
292
+ the model learns the ``<szN>`` block-size and ``</think>`` termination decisions.
293
+
294
+ Those causal samples read a pristine think region, while at inference the controller
295
+ reads thoughts the model just wrote, complete with sampling damage.
296
+ ``control_context_noise`` closes that gap by swapping a fraction of their think tokens
297
+ for other tokens drawn from the batch: inputs degrade, targets stay clean.
298
+ """
299
+
300
+ device = tokens.device
301
+ batch_size, seq_len = tokens.shape
302
+ problem_len, _, answer_start, answer_end = regions.unbind(dim=1)
303
+ positions = torch.arange(seq_len, device=device)
304
+
305
+ think_sel = (
306
+ torch.rand(batch_size, device=device, generator=generator) < think_probability
307
+ )
308
+ if bool(think_sel.all()):
309
+ think_sel[-1] = False
310
+ if not bool(think_sel.any()):
311
+ think_sel[0] = True
312
+
313
+ prefix_end = problem_len + 1
314
+ in_think = (positions[None, :] >= prefix_end[:, None]) & (
315
+ positions[None, :] < answer_start[:, None]
316
+ )
317
+ is_size = (tokens.unsqueeze(-1) == size_ids).any(dim=-1)
318
+ boundary = (is_size | (tokens == end_think_id)) & in_think
319
+ think_content = in_think & ~boundary & think_sel[:, None]
320
+
321
+ block_id = torch.cumsum((is_size & in_think).long(), dim=1)
322
+ max_blocks = int(block_id.max().clamp(min=1))
323
+ slot_noise = mask_eps + (1.0 - mask_eps) * torch.rand(
324
+ batch_size, max_blocks, device=device, generator=generator
325
+ )
326
+ gather_index = (block_id - 1).clamp(min=0, max=max_blocks - 1)
327
+ token_noise = slot_noise.gather(1, gather_index)
328
+ mask = (
329
+ torch.rand(tokens.shape, device=device, generator=generator) < token_noise
330
+ ) & think_content
331
+ noisy_tokens = torch.where(mask, model.config.mask_token_id, tokens)
332
+
333
+ if control_context_noise > 0.0:
334
+ # Replacements come from the batch itself so the corrupted context keeps realistic
335
+ # token statistics; boundaries and targets are read from the clean tensor.
336
+ control_content = in_think & ~boundary & ~think_sel[:, None]
337
+ corrupt = (
338
+ torch.rand(tokens.shape, device=device, generator=generator) < control_context_noise
339
+ ) & control_content
340
+ flat = tokens.reshape(-1)
341
+ picks = torch.randint(
342
+ 0, flat.numel(), tokens.shape, device=device, generator=generator
343
+ )
344
+ noisy_tokens = torch.where(corrupt, flat[picks], noisy_tokens)
345
+
346
+ think_blocked = adaptive_block_mask(
347
+ tokens, problem_len, answer_start, size_ids, end_think_id,
348
+ causal_prefix=causal_prefix,
349
+ )
350
+ ar_blocked = prefix_causal_blocked(prefix_end, seq_len, causal_prefix=causal_prefix)
351
+ blocked = torch.where(think_sel[:, None, None], think_blocked, ar_blocked)
352
+
353
+ predict_positions = (
354
+ (positions[None, :] >= problem_len[:, None])
355
+ & (positions[None, :] < answer_end[:, None] - 1)
356
+ & ~think_sel[:, None]
357
+ )
358
+ output_positions = mask | predict_positions
359
+ logits = model(noisy_tokens, output_positions=output_positions, attn_mask=blocked)
360
+
361
+ think_rows = mask[output_positions]
362
+ # Graph-connected zero: forbidden-output columns sit at finfo.min, so a raw sum
363
+ # overflows to -inf in low precision and would poison the scalar via -inf * 0.
364
+ zero = logits.sum().clamp(-1.0, 1.0) * 0.0
365
+
366
+ think_loss = zero
367
+ think_accuracy = 0.0
368
+ if bool(mask.any()):
369
+ think_logits = logits[think_rows]
370
+ think_targets = tokens[mask]
371
+ per_token = F.cross_entropy(think_logits.float(), think_targets, reduction='none')
372
+ weights = token_noise[mask]
373
+ normalizer = think_content.sum().clamp_min(1)
374
+ think_loss = (per_token / weights).sum() / normalizer
375
+ think_accuracy = float(
376
+ (think_logits.argmax(dim=-1) == think_targets).float().mean()
377
+ )
378
+
379
+ answer_loss = zero
380
+ answer_accuracy = 0.0
381
+ control_accuracy = 0.0
382
+ stop_accuracy = 0.0
383
+ if bool(predict_positions.any()):
384
+ answer_logits = logits[~think_rows]
385
+ target_positions = torch.zeros_like(predict_positions)
386
+ target_positions[:, 1:] = predict_positions[:, :-1]
387
+ answer_targets = tokens[target_positions]
388
+ answer_loss = F.cross_entropy(answer_logits.float(), answer_targets)
389
+ control_ids = torch.cat(
390
+ [size_ids, torch.tensor([end_think_id], device=device, dtype=size_ids.dtype)]
391
+ )
392
+ is_control = (answer_targets.unsqueeze(-1) == control_ids).any(dim=-1)
393
+ answer_accuracy = float(
394
+ (answer_logits.argmax(dim=-1) == answer_targets).float().mean()
395
+ )
396
+ if bool(is_control.any()):
397
+ # Scored inside the control menu: an open-vocabulary argmax hides whether the
398
+ # boundary decision is right, since content tokens dominate those logits.
399
+ menu = answer_logits[is_control].index_select(-1, control_ids)
400
+ chosen = control_ids[menu.argmax(dim=-1)]
401
+ targets = answer_targets[is_control]
402
+ control_accuracy = float((chosen == targets).float().mean())
403
+ stop_accuracy = float(
404
+ ((chosen == end_think_id) == (targets == end_think_id)).float().mean()
405
+ )
406
+
407
+ return HybridBatchLoss(
408
+ loss=think_loss + answer_loss,
409
+ think_loss=float(think_loss),
410
+ answer_loss=float(answer_loss),
411
+ think_accuracy=think_accuracy,
412
+ answer_accuracy=answer_accuracy,
413
+ think_samples=int(think_sel.sum()),
414
+ answer_samples=int((~think_sel).sum()),
415
+ control_accuracy=control_accuracy,
416
+ stop_accuracy=stop_accuracy,
417
+ )
418
+
419
+
420
+ def block_size_curriculum(
421
+ step: int | None, *, n_sizes: int, curriculum_steps: int
422
+ ) -> Tensor:
423
+ """Segment-size sampling weights: smallest-size-only ramping linearly to uniform.
424
+
425
+ ``sizes`` are assumed ascending. ``step=None`` (evaluation) and a zero-length
426
+ curriculum both return the uniform end state so losses stay comparable across
427
+ checkpoints.
428
+ """
429
+
430
+ uniform = torch.full((n_sizes,), 1.0 / n_sizes)
431
+ if step is None or curriculum_steps <= 0:
432
+ return uniform
433
+ progress = min(1.0, step / curriculum_steps)
434
+ smallest_only = torch.zeros(n_sizes)
435
+ smallest_only[0] = 1.0
436
+ return smallest_only * (1.0 - progress) + uniform * progress
437
+
438
+
439
+ def block_diffusion_objective(
440
+ model: DiffusionTransformer,
441
+ tokens: Tensor,
442
+ *,
443
+ sizes: tuple[int, ...],
444
+ size_weights: Tensor,
445
+ mask_eps: float,
446
+ ar_probability: float = 0.0,
447
+ generator: torch.Generator | None = None,
448
+ ) -> HybridBatchLoss:
449
+ """Variable-block denoising over plain packed text (the conversion objective).
450
+
451
+ Each sample is tiled with segments whose lengths are drawn from ``sizes`` under
452
+ ``size_weights``; every segment is corrupted at an independent noise level and
453
+ denoised in one forward under the block-causal geometry (own segment bidirectional,
454
+ all preceding segments visible). No control tokens exist in the stream — pretraining
455
+ teaches variable-block denoising only; control decisions are learned in SFT. With
456
+ probability ``ar_probability`` a sample instead runs plain causal next-token loss,
457
+ retaining the autoregressive ability that control and answer decoding rely on.
458
+ Reported as ``think_*`` (denoising) and ``answer_*`` (causal retention) metrics.
459
+ """
460
+
461
+ device = tokens.device
462
+ batch_size, seq_len = tokens.shape
463
+ positions = torch.arange(seq_len, device=device)
464
+
465
+ ar_sel = (
466
+ torch.rand(batch_size, device=device, generator=generator) < ar_probability
467
+ )
468
+ if bool(ar_sel.all()):
469
+ ar_sel[0] = False
470
+ diff_sel = ~ar_sel
471
+
472
+ size_tensor = torch.tensor(sizes, device=device, dtype=torch.long)
473
+ max_segments = -(-seq_len // int(min(sizes)))
474
+ drawn_index = torch.multinomial(
475
+ size_weights.to(device).expand(batch_size, -1),
476
+ max_segments,
477
+ replacement=True,
478
+ generator=generator,
479
+ )
480
+ drawn = size_tensor[drawn_index]
481
+ starts = torch.cumsum(drawn, dim=1) - drawn
482
+ valid = starts < seq_len
483
+ boundary_hits = torch.zeros(batch_size, seq_len, dtype=torch.long, device=device)
484
+ boundary_hits.scatter_add_(1, starts.clamp(max=seq_len - 1), valid.long())
485
+ boundary = boundary_hits > 0
486
+
487
+ segment_id = torch.cumsum(boundary.long(), dim=1) - 1
488
+ segment_noise = mask_eps + (1.0 - mask_eps) * torch.rand(
489
+ batch_size, max_segments, device=device, generator=generator
490
+ )
491
+ token_noise = segment_noise.gather(1, segment_id.clamp(max=max_segments - 1))
492
+ mask = (
493
+ torch.rand(tokens.shape, device=device, generator=generator) < token_noise
494
+ ) & diff_sel[:, None]
495
+ noisy_tokens = torch.where(mask, model.config.mask_token_id, tokens)
496
+
497
+ zeros = torch.zeros(batch_size, dtype=torch.long, device=device)
498
+ full = torch.full((batch_size,), seq_len, dtype=torch.long, device=device)
499
+ block_blocked = block_mask_from_boundaries(boundary, zeros, full)
500
+ causal_blocked = prefix_causal_blocked(zeros, seq_len, causal_prefix=True)
501
+ blocked = torch.where(diff_sel[:, None, None], block_blocked, causal_blocked)
502
+
503
+ predict_positions = (positions[None, :] < seq_len - 1) & ar_sel[:, None]
504
+ output_positions = mask | predict_positions
505
+ logits = model(noisy_tokens, output_positions=output_positions, attn_mask=blocked)
506
+
507
+ think_rows = mask[output_positions]
508
+ # Graph-connected zero: forbidden-output columns sit at finfo.min, so a raw sum
509
+ # overflows to -inf in low precision and would poison the scalar via -inf * 0.
510
+ zero = logits.sum().clamp(-1.0, 1.0) * 0.0
511
+
512
+ think_loss = zero
513
+ think_accuracy = 0.0
514
+ if bool(mask.any()):
515
+ think_logits = logits[think_rows]
516
+ think_targets = tokens[mask]
517
+ per_token = F.cross_entropy(think_logits.float(), think_targets, reduction='none')
518
+ weights = token_noise[mask]
519
+ normalizer = (diff_sel.sum() * seq_len).clamp_min(1)
520
+ think_loss = (per_token / weights).sum() / normalizer
521
+ think_accuracy = float(
522
+ (think_logits.argmax(dim=-1) == think_targets).float().mean()
523
+ )
524
+
525
+ answer_loss = zero
526
+ answer_accuracy = 0.0
527
+ if bool(predict_positions.any()):
528
+ answer_logits = logits[~think_rows]
529
+ target_positions = torch.zeros_like(predict_positions)
530
+ target_positions[:, 1:] = predict_positions[:, :-1]
531
+ answer_targets = tokens[target_positions]
532
+ answer_loss = F.cross_entropy(answer_logits.float(), answer_targets)
533
+ answer_accuracy = float(
534
+ (answer_logits.argmax(dim=-1) == answer_targets).float().mean()
535
+ )
536
+
537
+ return HybridBatchLoss(
538
+ loss=think_loss + answer_loss,
539
+ think_loss=float(think_loss),
540
+ answer_loss=float(answer_loss),
541
+ think_accuracy=think_accuracy,
542
+ answer_accuracy=answer_accuracy,
543
+ think_samples=int(diff_sel.sum()),
544
+ answer_samples=int(ar_sel.sum()),
545
+ )
546
+
547
+
548
+ @dataclass(frozen=True)
549
+ class ARBatchLoss:
550
+ loss: Tensor
551
+ accuracy: float
552
+ token_count: int
553
+
554
+
555
+ def ar_objective(
556
+ model: DiffusionTransformer, tokens: Tensor, regions: Tensor
557
+ ) -> ARBatchLoss:
558
+ """Prefix-LM next-token objective over the think and answer regions."""
559
+
560
+ device = tokens.device
561
+ _, seq_len = tokens.shape
562
+ problem_len, _, _, answer_end = regions.unbind(dim=1)
563
+ positions = torch.arange(seq_len, device=device)
564
+
565
+ blocked = prefix_causal_blocked(problem_len + 1, seq_len)
566
+ predict_positions = (positions[None, :] >= problem_len[:, None]) & (
567
+ positions[None, :] < answer_end[:, None] - 1
568
+ )
569
+ logits = model(tokens, output_positions=predict_positions, attn_mask=blocked)
570
+
571
+ target_positions = torch.zeros_like(predict_positions)
572
+ target_positions[:, 1:] = predict_positions[:, :-1]
573
+ targets = tokens[target_positions]
574
+ loss = F.cross_entropy(logits.float(), targets)
575
+ accuracy = float((logits.argmax(dim=-1) == targets).float().mean())
576
+ return ARBatchLoss(loss=loss, accuracy=accuracy, token_count=int(targets.numel()))
577
+
578
+
579
+ def diffusion_objective(
580
+ model: DiffusionTransformer,
581
+ tokens: Tensor,
582
+ regions: Tensor,
583
+ *,
584
+ mask_eps: float,
585
+ mask_probability: Tensor | None = None,
586
+ generator: torch.Generator | None = None,
587
+ ) -> tuple[Tensor, CorruptionBatch, Tensor]:
588
+ """Whole-response denoising: corrupt everything after the problem, pads included."""
589
+
590
+ device = tokens.device
591
+ _, seq_len = tokens.shape
592
+ problem_len = regions[:, 0]
593
+ positions = torch.arange(seq_len, device=device)
594
+ response_mask = positions[None, :] >= (problem_len[:, None] + 1)
595
+ corruption = corrupt_tokens(
596
+ tokens,
597
+ model.config.mask_token_id,
598
+ valid_mask=response_mask,
599
+ mask_probability=mask_probability,
600
+ eps=mask_eps,
601
+ generator=generator,
602
+ )
603
+ logits = model(corruption.noisy_tokens, output_positions=corruption.mask)
604
+ return logits, corruption, response_mask
605
+
606
+
607
+ @dataclass
608
+ class GenerationResult:
609
+ tokens: list[int]
610
+ think_tokens: list[int]
611
+ answer_tokens: list[int]
612
+ think_seconds: float = 0.0
613
+ answer_seconds: float = 0.0
614
+ forward_passes: int = 0
615
+ slots_used: int = 0
616
+ block_sizes: tuple[int, ...] = ()
617
+ terminated: bool = False
618
+
619
+ @property
620
+ def total_seconds(self) -> float:
621
+ return self.think_seconds + self.answer_seconds
622
+
623
+
624
+ def _apply_repetition_penalty(
625
+ logits: Tensor, token_ids: list[int], penalty: float
626
+ ) -> Tensor:
627
+ """Divide logits of already-emitted tokens by ``penalty`` (CTRL convention)."""
628
+
629
+ if penalty == 1.0 or not token_ids:
630
+ return logits
631
+ index = torch.tensor(sorted(set(token_ids)), device=logits.device)
632
+ selected = logits.index_select(-1, index)
633
+ adjusted = torch.where(selected > 0, selected / penalty, selected * penalty)
634
+ return logits.index_copy(-1, index, adjusted)
635
+
636
+
637
+ def _apply_top_p(logits: Tensor, top_p: float) -> Tensor:
638
+ """Restrict sampling to the smallest set of tokens whose mass reaches ``top_p``."""
639
+
640
+ if top_p >= 1.0:
641
+ return logits
642
+ ordered, indices = torch.sort(logits, descending=True, dim=-1)
643
+ cumulative = ordered.softmax(dim=-1).cumsum(dim=-1)
644
+ remove = cumulative - ordered.softmax(dim=-1) >= top_p
645
+ ordered = ordered.masked_fill(remove, torch.finfo(logits.dtype).min)
646
+ return ordered.gather(-1, indices.argsort(dim=-1))
647
+
648
+
649
+ def _kv_cache_enabled(part: str) -> bool:
650
+ """Key/value caching per part, selected by ``MDLM_KV_CACHE``: ar, block, all or off.
651
+
652
+ Defaults to ``ar``, which measured 101s to 12.6s on the same prompt's answer: one query
653
+ token against an all-visible mask has no downside. The denoising prefix does — caching it
654
+ hands the backbone a dense mask, dropping flex attention onto its score_mod path and losing
655
+ the block skipping the uncached call gets, 10.6s per block against 5.3s on an L40S
656
+ (2026-07-27). It stays off until that path builds a rectangular BlockMask instead.
657
+ """
658
+
659
+ setting = os.environ.get('MDLM_KV_CACHE', 'ar')
660
+ return setting in ('all', '1') or setting == part
661
+
662
+
663
+ def _cached_block_logits(model, blocked: Tensor, prefix_len: int):
664
+ """Score a denoising block against a cached prefix, or ``None`` without cache support.
665
+
666
+ The prefix is encoded once per block; every denoising step then feeds only the block's
667
+ own positions. Its keys and values are dropped between steps because the block's tokens
668
+ keep changing as they are revealed, while the prefix behind them does not.
669
+ """
670
+
671
+ if not hasattr(model, 'forward_cached') or not _kv_cache_enabled('block'):
672
+ return None
673
+
674
+ cache = model.new_cache()
675
+
676
+ def logits_fn(tokens: Tensor, masked: Tensor) -> Tensor:
677
+ if cache.get_seq_length() == 0:
678
+ with torch.inference_mode():
679
+ model.forward_cached(
680
+ tokens[:, :prefix_len],
681
+ attn_mask=blocked[:, :prefix_len, :prefix_len],
682
+ past_key_values=cache,
683
+ )
684
+ cache.crop(prefix_len)
685
+ with torch.inference_mode():
686
+ logits, _ = model.forward_cached(
687
+ tokens[:, prefix_len:],
688
+ attn_mask=blocked[:, prefix_len:, :],
689
+ past_key_values=cache,
690
+ output_positions=masked[:, prefix_len:],
691
+ )
692
+ return logits
693
+
694
+ return logits_fn
695
+
696
+
697
+ def _ar_decode_cached(
698
+ model: DiffusionTransformer,
699
+ sequence: list[int],
700
+ prefix_len: int,
701
+ *,
702
+ causal_prefix: bool,
703
+ eos_id: int,
704
+ max_new_tokens: int,
705
+ temperature: float,
706
+ repetition_penalty: float,
707
+ top_p: float,
708
+ device: torch.device,
709
+ generator: torch.Generator | None,
710
+ ) -> tuple[list[int], int]:
711
+ """Same decoding as :func:`_ar_decode` with the prefix kept in a key/value cache.
712
+
713
+ Masks come from :func:`prefix_causal_blocked` exactly as in the uncached path, sliced to
714
+ the rows of the queries being fed. Priming with an all-visible mask instead would look
715
+ right — the final row is identical, so a single generated token matches — while silently
716
+ computing every earlier position bidirectionally and poisoning the cached keys.
717
+ """
718
+
719
+ generated: list[int] = []
720
+ cache = model.new_cache()
721
+ step_in = torch.tensor([sequence], dtype=torch.long, device=device)
722
+ forwards = 0
723
+ prefix = torch.tensor([prefix_len], device=device)
724
+ for _ in range(max_new_tokens):
725
+ cached = cache.get_seq_length()
726
+ length = cached + step_in.shape[1]
727
+ visible = prefix_causal_blocked(prefix, length, causal_prefix=causal_prefix)[:, cached:, :]
728
+ output_positions = torch.zeros_like(step_in, dtype=torch.bool)
729
+ output_positions[0, -1] = True
730
+ with torch.inference_mode():
731
+ logits, cache = model.forward_cached(
732
+ step_in, attn_mask=visible, past_key_values=cache,
733
+ output_positions=output_positions,
734
+ )
735
+ logits = _apply_repetition_penalty(logits, generated, repetition_penalty)
736
+ logits = _apply_top_p(logits, top_p)
737
+ token, _ = _sample_categorical(logits, temperature, generator)
738
+ forwards += 1
739
+ token_id = int(token.item())
740
+ generated.append(token_id)
741
+ if token_id == eos_id:
742
+ break
743
+ step_in = torch.tensor([[token_id]], dtype=torch.long, device=device)
744
+ return generated, forwards
745
+
746
+
747
+ def _ar_decode(
748
+ model: DiffusionTransformer,
749
+ sequence: list[int],
750
+ prefix_len: int,
751
+ *,
752
+ eos_id: int,
753
+ max_new_tokens: int,
754
+ temperature: float,
755
+ repetition_penalty: float,
756
+ top_p: float,
757
+ device: torch.device,
758
+ generator: torch.Generator | None,
759
+ causal_prefix: bool = False,
760
+ ) -> tuple[list[int], int]:
761
+ """Greedy/temperature decoding under the bidirectional-prefix causal mask.
762
+
763
+ ``repetition_penalty`` (>1 discourages repeats) and ``top_p`` nucleus truncation
764
+ curb the degenerate loops small models fall into under plain temperature sampling.
765
+ """
766
+
767
+ generated: list[int] = []
768
+ forwards = 0
769
+ max_new_tokens = min(max_new_tokens, model.config.max_seq_len - len(sequence))
770
+ if hasattr(model, 'forward_cached') and _kv_cache_enabled('ar'):
771
+ return _ar_decode_cached(
772
+ model, sequence, prefix_len, causal_prefix=causal_prefix, eos_id=eos_id,
773
+ max_new_tokens=max_new_tokens, temperature=temperature,
774
+ repetition_penalty=repetition_penalty, top_p=top_p, device=device,
775
+ generator=generator,
776
+ )
777
+ for _ in range(max_new_tokens):
778
+ current = torch.tensor([sequence + generated], dtype=torch.long, device=device)
779
+ seq_len = current.shape[1]
780
+ prefix = torch.tensor([prefix_len], device=device)
781
+ blocked = prefix_causal_blocked(prefix, seq_len, causal_prefix=causal_prefix)
782
+ output_positions = torch.zeros_like(current, dtype=torch.bool)
783
+ output_positions[0, -1] = True
784
+ with torch.inference_mode():
785
+ logits = model(current, output_positions=output_positions, attn_mask=blocked)
786
+ logits = _apply_repetition_penalty(logits, generated, repetition_penalty)
787
+ logits = _apply_top_p(logits, top_p)
788
+ token, _ = _sample_categorical(logits, temperature, generator)
789
+ forwards += 1
790
+ token_id = int(token.item())
791
+ generated.append(token_id)
792
+ if token_id == eos_id:
793
+ break
794
+ return generated, forwards
795
+
796
+
797
+ @torch.no_grad()
798
+ def generate_hybrid(
799
+ model: DiffusionTransformer,
800
+ prompt_ids: list[int],
801
+ *,
802
+ think_id: int,
803
+ end_think_id: int,
804
+ thought_pad_id: int,
805
+ eos_id: int,
806
+ block: int,
807
+ max_slots: int,
808
+ steps_per_block: int,
809
+ max_answer_tokens: int = 64,
810
+ temperature: float = 0.7,
811
+ repetition_penalty: float = 1.0,
812
+ top_p: float = 1.0,
813
+ strategy: str = 'confidence',
814
+ device: torch.device | str = 'cpu',
815
+ generator: torch.Generator | None = None,
816
+ ) -> GenerationResult:
817
+ """Denoise thought slots sequentially, then decode the answer autoregressively."""
818
+
819
+ device = torch.device(device)
820
+ mask_id = model.config.mask_token_id
821
+ sequence = [*prompt_ids, think_id]
822
+ forwards = 0
823
+ slots_used = 0
824
+ # Each new slot must leave room for itself plus at least a minimal answer.
825
+ slot_budget = model.config.max_seq_len - block - 8
826
+
827
+ think_started = time.perf_counter()
828
+ problem_tensor = torch.tensor([len(prompt_ids)], device=device)
829
+ for slot_index in range(max_slots):
830
+ if len(sequence) > slot_budget:
831
+ break
832
+ window = torch.tensor(
833
+ [sequence + [mask_id] * block], dtype=torch.long, device=device
834
+ )
835
+ blocked = slot_causal_blocked(
836
+ problem_tensor,
837
+ torch.tensor([slot_index + 1], device=device),
838
+ block,
839
+ window.shape[1],
840
+ )
841
+ filled = iterative_unmask(
842
+ model,
843
+ window,
844
+ mask_id,
845
+ steps=steps_per_block,
846
+ temperature=temperature,
847
+ strategy=strategy,
848
+ attn_mask=blocked,
849
+ generator=generator,
850
+ )
851
+ slot = [int(token) for token in filled[0, len(sequence):]]
852
+ forwards += steps_per_block
853
+ slots_used += 1
854
+ sequence.extend(slot)
855
+ if end_think_id in slot:
856
+ break
857
+ if end_think_id not in sequence[len(prompt_ids):]:
858
+ # Match the trained answer geometry when the model never closes its thinking.
859
+ terminal = [end_think_id] + [thought_pad_id] * (block - 1)
860
+ sequence.extend(terminal[: max(1, model.config.max_seq_len - 8 - len(sequence))])
861
+ think_seconds = time.perf_counter() - think_started
862
+ think_tokens = sequence[len(prompt_ids):]
863
+
864
+ answer_started = time.perf_counter()
865
+ answer, answer_forwards = _ar_decode(
866
+ model,
867
+ sequence,
868
+ prefix_len=len(sequence),
869
+ eos_id=eos_id,
870
+ max_new_tokens=max_answer_tokens,
871
+ temperature=temperature,
872
+ repetition_penalty=repetition_penalty,
873
+ top_p=top_p,
874
+ device=device,
875
+ generator=generator,
876
+ )
877
+ answer_seconds = time.perf_counter() - answer_started
878
+ return GenerationResult(
879
+ tokens=sequence + answer,
880
+ think_tokens=think_tokens,
881
+ answer_tokens=answer,
882
+ think_seconds=think_seconds,
883
+ answer_seconds=answer_seconds,
884
+ forward_passes=forwards + answer_forwards,
885
+ slots_used=slots_used,
886
+ )
887
+
888
+
889
+ def _ar_predict_control(
890
+ model: DiffusionTransformer,
891
+ sequence: list[int],
892
+ allowed_ids: list[int],
893
+ *,
894
+ prefix_len: int,
895
+ temperature: float,
896
+ device: torch.device,
897
+ generator: torch.Generator | None,
898
+ causal_prefix: bool = False,
899
+ ) -> int:
900
+ """Predict the next control token, restricted to the allowed size/stop ids."""
901
+
902
+ current = torch.tensor([sequence], dtype=torch.long, device=device)
903
+ seq_len = current.shape[1]
904
+ blocked = prefix_causal_blocked(
905
+ torch.tensor([prefix_len], device=device), seq_len, causal_prefix=causal_prefix
906
+ )
907
+ output_positions = torch.zeros_like(current, dtype=torch.bool)
908
+ output_positions[0, -1] = True
909
+ with torch.inference_mode():
910
+ logits = model(current, output_positions=output_positions, attn_mask=blocked)
911
+ restricted = torch.full_like(logits, torch.finfo(logits.dtype).min)
912
+ index = torch.tensor(allowed_ids, device=logits.device)
913
+ restricted.index_copy_(-1, index, logits.index_select(-1, index))
914
+ token, _ = _sample_categorical(restricted, temperature, generator)
915
+ return int(token.item())
916
+
917
+
918
+ @torch.no_grad()
919
+ def generate_hybrid_adaptive(
920
+ model: DiffusionTransformer,
921
+ prompt_ids: list[int],
922
+ *,
923
+ think_id: int,
924
+ end_think_id: int,
925
+ thought_pad_id: int,
926
+ eos_id: int,
927
+ size_ids: dict[int, int],
928
+ steps_per_block: int,
929
+ max_blocks: int,
930
+ max_answer_tokens: int = 96,
931
+ temperature: float = 0.7,
932
+ control_temperature: float = 0.0,
933
+ repetition_penalty: float = 1.0,
934
+ top_p: float = 1.0,
935
+ strategy: str = 'confidence',
936
+ causal_prefix: bool = False,
937
+ device: torch.device | str = 'cpu',
938
+ generator: torch.Generator | None = None,
939
+ ) -> GenerationResult:
940
+ """Interleave AR block-size decisions with in-block diffusion, then decode.
941
+
942
+ At each boundary the model predicts a ``<szN>`` control token or ``</think>``.
943
+ A size token allocates that many masked positions denoised in parallel under the
944
+ variable-boundary block-causal mask; ``</think>`` ends thinking. The answer is
945
+ then decoded autoregressively with the same repetition and nucleus controls.
946
+ """
947
+
948
+ device = torch.device(device)
949
+ mask_id = model.config.mask_token_id
950
+ size_by_id = {token_id: size for size, token_id in size_ids.items()}
951
+ size_ids_tensor = torch.tensor(sorted(size_ids.values()), device=device)
952
+ control_ids = [*size_by_id.keys(), end_think_id]
953
+ prefix_len = len(prompt_ids) + 1
954
+ problem_tensor = torch.tensor([len(prompt_ids)], device=device)
955
+
956
+ sequence = [*prompt_ids, think_id]
957
+ forwards = 0
958
+ blocks_used = 0
959
+ chosen: list[int] = []
960
+ terminated = False
961
+
962
+ think_started = time.perf_counter()
963
+ for _ in range(max_blocks):
964
+ control = _ar_predict_control(
965
+ model,
966
+ sequence,
967
+ control_ids,
968
+ prefix_len=prefix_len,
969
+ temperature=control_temperature,
970
+ device=device,
971
+ generator=generator,
972
+ causal_prefix=causal_prefix,
973
+ )
974
+ forwards += 1
975
+ if control == end_think_id:
976
+ terminated = True
977
+ break
978
+ size = size_by_id[control]
979
+ if len(sequence) + 1 + size > model.config.max_seq_len - 8:
980
+ break
981
+ sequence.append(control)
982
+ window_prefix = len(sequence)
983
+ window = torch.tensor(
984
+ [sequence + [mask_id] * size], dtype=torch.long, device=device
985
+ )
986
+ blocked = adaptive_block_mask(
987
+ window,
988
+ problem_tensor,
989
+ torch.tensor([window.shape[1]], device=device),
990
+ size_ids_tensor,
991
+ end_think_id,
992
+ causal_prefix=causal_prefix,
993
+ )
994
+ filled = iterative_unmask(
995
+ model,
996
+ window,
997
+ mask_id,
998
+ steps=steps_per_block,
999
+ temperature=temperature,
1000
+ strategy=strategy,
1001
+ attn_mask=blocked,
1002
+ generator=generator,
1003
+ logits_fn=_cached_block_logits(model, blocked, window_prefix),
1004
+ )
1005
+ sequence.extend(int(token) for token in filled[0, window_prefix:])
1006
+ forwards += steps_per_block
1007
+ blocks_used += 1
1008
+ chosen.append(size)
1009
+
1010
+ sequence.append(end_think_id)
1011
+ think_seconds = time.perf_counter() - think_started
1012
+ think_tokens = sequence[len(prompt_ids):]
1013
+
1014
+ answer_started = time.perf_counter()
1015
+ answer, answer_forwards = _ar_decode(
1016
+ model,
1017
+ sequence,
1018
+ prefix_len=prefix_len,
1019
+ eos_id=eos_id,
1020
+ max_new_tokens=max_answer_tokens,
1021
+ temperature=temperature,
1022
+ repetition_penalty=repetition_penalty,
1023
+ top_p=top_p,
1024
+ device=device,
1025
+ generator=generator,
1026
+ causal_prefix=causal_prefix,
1027
+ )
1028
+ answer_seconds = time.perf_counter() - answer_started
1029
+ return GenerationResult(
1030
+ tokens=sequence + answer,
1031
+ think_tokens=think_tokens,
1032
+ answer_tokens=answer,
1033
+ think_seconds=think_seconds,
1034
+ answer_seconds=answer_seconds,
1035
+ forward_passes=forwards + answer_forwards,
1036
+ slots_used=blocks_used,
1037
+ block_sizes=tuple(chosen),
1038
+ terminated=terminated,
1039
+ )
1040
+
1041
+
1042
+ @torch.no_grad()
1043
+ def generate_ar(
1044
+ model: DiffusionTransformer,
1045
+ prompt_ids: list[int],
1046
+ *,
1047
+ think_id: int,
1048
+ end_think_id: int,
1049
+ eos_id: int,
1050
+ max_new_tokens: int = 384,
1051
+ temperature: float = 0.7,
1052
+ device: torch.device | str = 'cpu',
1053
+ generator: torch.Generator | None = None,
1054
+ ) -> GenerationResult:
1055
+ """Classic sequential CoT baseline under the prefix-LM mask."""
1056
+
1057
+ device = torch.device(device)
1058
+ sequence = [*prompt_ids, think_id]
1059
+ started = time.perf_counter()
1060
+ generated, forwards = _ar_decode(
1061
+ model,
1062
+ sequence,
1063
+ prefix_len=len(sequence),
1064
+ eos_id=eos_id,
1065
+ max_new_tokens=max_new_tokens,
1066
+ temperature=temperature,
1067
+ device=device,
1068
+ generator=generator,
1069
+ )
1070
+ elapsed = time.perf_counter() - started
1071
+ if end_think_id in generated:
1072
+ split = generated.index(end_think_id) + 1
1073
+ else:
1074
+ split = len(generated)
1075
+ return GenerationResult(
1076
+ tokens=sequence + generated,
1077
+ think_tokens=generated[:split],
1078
+ answer_tokens=generated[split:],
1079
+ think_seconds=elapsed,
1080
+ answer_seconds=0.0,
1081
+ forward_passes=forwards,
1082
+ )
1083
+
1084
+
1085
+ @torch.no_grad()
1086
+ def generate_diffusion(
1087
+ model: DiffusionTransformer,
1088
+ prompt_ids: list[int],
1089
+ *,
1090
+ think_id: int,
1091
+ end_think_id: int,
1092
+ eos_id: int,
1093
+ response_budget: int,
1094
+ steps: int,
1095
+ temperature: float = 0.7,
1096
+ blocked_token_ids: tuple[int, ...] = (),
1097
+ device: torch.device | str = 'cpu',
1098
+ generator: torch.Generator | None = None,
1099
+ ) -> GenerationResult:
1100
+ """Pure-diffusion baseline: denoise the entire response region at once.
1101
+
1102
+ Blocking the pad token here counters confidence-ordered pad collapse: pads are
1103
+ the easiest predictions, so left unblocked they win every early reveal and
1104
+ squeeze out the actual response text.
1105
+ """
1106
+
1107
+ device = torch.device(device)
1108
+ mask_id = model.config.mask_token_id
1109
+ budget = min(response_budget, model.config.max_seq_len - len(prompt_ids) - 1)
1110
+ sequence = torch.tensor(
1111
+ [[*prompt_ids, think_id] + [mask_id] * budget], dtype=torch.long, device=device
1112
+ )
1113
+ started = time.perf_counter()
1114
+ filled = iterative_unmask(
1115
+ model,
1116
+ sequence,
1117
+ mask_id,
1118
+ steps=steps,
1119
+ temperature=temperature,
1120
+ strategy='confidence',
1121
+ blocked_token_ids=blocked_token_ids,
1122
+ generator=generator,
1123
+ )
1124
+ elapsed = time.perf_counter() - started
1125
+ response = [int(token) for token in filled[0, len(prompt_ids) + 1:]]
1126
+ if eos_id in response:
1127
+ response = response[: response.index(eos_id) + 1]
1128
+ if end_think_id in response:
1129
+ split = response.index(end_think_id) + 1
1130
+ else:
1131
+ split = len(response)
1132
+ return GenerationResult(
1133
+ tokens=[*prompt_ids, think_id] + response,
1134
+ think_tokens=response[:split],
1135
+ answer_tokens=response[split:],
1136
+ think_seconds=elapsed,
1137
+ answer_seconds=0.0,
1138
+ forward_passes=steps,
1139
+ )
src/diffusion_lm/model.py ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GPT-sized bidirectional Transformer used as a masked-token denoiser."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import math
7
+ from pathlib import Path
8
+
9
+ import torch
10
+ from torch import Tensor, nn
11
+ from torch.utils.checkpoint import checkpoint
12
+
13
+ from diffusion_lm.config import ModelConfig, load_config
14
+
15
+
16
+ class DiffusionTransformer(nn.Module):
17
+ """A GPT-like Transformer with the causal mask deliberately removed.
18
+
19
+ The network predicts clean tokens from an input containing absorbing mask
20
+ tokens. Passing ``output_positions`` avoids materializing vocabulary logits
21
+ for already-visible tokens during training.
22
+ """
23
+
24
+ def __init__(self, config: ModelConfig) -> None:
25
+ super().__init__()
26
+ self.config = config
27
+ self.tokenizer_sha256: str | None = None
28
+ self.token_embedding = nn.Embedding(config.vocab_size, config.d_model)
29
+ self.position_embedding = nn.Embedding(config.max_seq_len, config.d_model)
30
+ self.embedding_dropout = nn.Dropout(config.dropout)
31
+
32
+ if config.use_flex_attention:
33
+ from diffusion_lm.flexattn import FlexEncoder
34
+
35
+ self.transformer = FlexEncoder(
36
+ d_model=config.d_model,
37
+ n_heads=config.n_heads,
38
+ d_ff=config.d_ff,
39
+ dropout=config.dropout,
40
+ n_layers=config.n_layers,
41
+ activation_checkpointing=config.activation_checkpointing,
42
+ )
43
+ else:
44
+ layer = nn.TransformerEncoderLayer(
45
+ d_model=config.d_model,
46
+ nhead=config.n_heads,
47
+ dim_feedforward=config.d_ff,
48
+ dropout=config.dropout,
49
+ activation="gelu",
50
+ batch_first=True,
51
+ norm_first=True,
52
+ )
53
+ self.transformer = nn.TransformerEncoder(
54
+ layer,
55
+ num_layers=config.n_layers,
56
+ norm=nn.LayerNorm(config.d_model),
57
+ enable_nested_tensor=False,
58
+ )
59
+ self.lm_head = nn.Linear(config.d_model, config.vocab_size, bias=False)
60
+
61
+ self.apply(self._init_weights)
62
+ self._init_residual_outputs()
63
+ if config.tie_embeddings:
64
+ self.lm_head.weight = self.token_embedding.weight
65
+ self.register_buffer(
66
+ "_forbidden_output_token_ids",
67
+ torch.tensor(config.forbidden_output_token_ids, dtype=torch.long),
68
+ persistent=False,
69
+ )
70
+
71
+ @staticmethod
72
+ def _init_weights(module: nn.Module) -> None:
73
+ if isinstance(module, (nn.Linear, nn.Embedding)):
74
+ nn.init.normal_(module.weight, mean=0.0, std=0.02)
75
+ if isinstance(module, nn.Linear) and module.bias is not None:
76
+ nn.init.zeros_(module.bias)
77
+ elif isinstance(module, nn.LayerNorm):
78
+ nn.init.ones_(module.weight)
79
+ nn.init.zeros_(module.bias)
80
+
81
+ def _init_residual_outputs(self) -> None:
82
+ """Scale residual branch outputs as in GPT-2 for stable deep training."""
83
+
84
+ if self.config.use_flex_attention:
85
+ self.transformer.init_residual_outputs(self.config.n_layers)
86
+ return
87
+ residual_std = 0.02 / math.sqrt(2 * self.config.n_layers)
88
+ for layer in self.transformer.layers:
89
+ nn.init.normal_(layer.self_attn.out_proj.weight, mean=0.0, std=residual_std)
90
+ nn.init.normal_(layer.linear2.weight, mean=0.0, std=residual_std)
91
+
92
+ def _checkpointed_transformer(
93
+ self,
94
+ hidden: Tensor,
95
+ padding_mask: Tensor | None,
96
+ attn_mask: Tensor | None = None,
97
+ ) -> Tensor:
98
+ for layer in self.transformer.layers:
99
+
100
+ def run_layer(layer_input: Tensor, *, current_layer: nn.Module = layer) -> Tensor:
101
+ return current_layer(
102
+ layer_input, src_mask=attn_mask, src_key_padding_mask=padding_mask
103
+ )
104
+
105
+ hidden = checkpoint(run_layer, hidden, use_reentrant=False)
106
+
107
+ if self.transformer.norm is not None:
108
+ hidden = self.transformer.norm(hidden)
109
+ return hidden
110
+
111
+ def _expand_attn_mask(self, attn_mask: Tensor | None, input_ids: Tensor) -> Tensor | None:
112
+ """Broadcast a per-sample boolean blocking mask across attention heads.
113
+
114
+ Accepts ``[L, L]`` shared masks or ``[B, L, L]`` per-sample masks with
115
+ ``True`` marking blocked key positions, matching the src_mask convention.
116
+ """
117
+
118
+ if attn_mask is None:
119
+ return None
120
+ batch_size, sequence_length = input_ids.shape
121
+ if attn_mask.dtype != torch.bool:
122
+ raise ValueError("attn_mask must be boolean with True marking blocked positions")
123
+ if attn_mask.shape == (sequence_length, sequence_length):
124
+ return attn_mask
125
+ if attn_mask.shape != (batch_size, sequence_length, sequence_length):
126
+ raise ValueError("attn_mask must have shape [L, L] or [batch, L, L]")
127
+ return attn_mask.repeat_interleave(self.config.n_heads, dim=0)
128
+
129
+ def encode(
130
+ self,
131
+ input_ids: Tensor,
132
+ attention_mask: Tensor | None = None,
133
+ attn_mask: Tensor | None = None,
134
+ ) -> Tensor:
135
+ """Return contextual token states; ``attn_mask`` restricts attention topology."""
136
+
137
+ if input_ids.ndim != 2:
138
+ raise ValueError("input_ids must have shape [batch, sequence]")
139
+ batch_size, sequence_length = input_ids.shape
140
+ if sequence_length > self.config.max_seq_len:
141
+ raise ValueError(
142
+ f"sequence length {sequence_length} exceeds max_seq_len "
143
+ f"{self.config.max_seq_len}"
144
+ )
145
+ if attention_mask is not None and attention_mask.shape != input_ids.shape:
146
+ raise ValueError("attention_mask must match input_ids")
147
+
148
+ positions = torch.arange(sequence_length, device=input_ids.device)
149
+ hidden = self.token_embedding(input_ids) + self.position_embedding(positions)[None, :, :]
150
+ hidden = self.embedding_dropout(hidden)
151
+
152
+ # TransformerEncoder expects True for padding, the inverse of the common
153
+ # attention-mask convention. src_mask is only supplied by region-aware callers.
154
+ padding_mask = None if attention_mask is None else ~attention_mask.bool()
155
+
156
+ if self.config.use_flex_attention:
157
+ from diffusion_lm.flexattn import build_block_mask
158
+
159
+ if attn_mask is not None and attn_mask.dtype != torch.bool:
160
+ raise ValueError("attn_mask must be boolean with True marking blocked positions")
161
+ block_mask = build_block_mask(
162
+ attn_mask, padding_mask, batch_size, sequence_length, hidden.device
163
+ )
164
+ return self.transformer(hidden, block_mask)
165
+
166
+ expanded_attn_mask = self._expand_attn_mask(attn_mask, input_ids)
167
+ if (
168
+ self.config.activation_checkpointing
169
+ and self.training
170
+ and torch.is_grad_enabled()
171
+ ):
172
+ return self._checkpointed_transformer(hidden, padding_mask, expanded_attn_mask)
173
+ return self.transformer(
174
+ hidden, mask=expanded_attn_mask, src_key_padding_mask=padding_mask
175
+ )
176
+
177
+ def forward(
178
+ self,
179
+ input_ids: Tensor,
180
+ attention_mask: Tensor | None = None,
181
+ output_positions: Tensor | None = None,
182
+ attn_mask: Tensor | None = None,
183
+ ) -> Tensor:
184
+ """Predict vocabulary logits for all tokens or selected positions only."""
185
+
186
+ hidden = self.encode(input_ids, attention_mask=attention_mask, attn_mask=attn_mask)
187
+ if output_positions is not None:
188
+ if output_positions.shape != input_ids.shape:
189
+ raise ValueError("output_positions must match input_ids")
190
+ hidden = hidden[output_positions.bool()]
191
+
192
+ logits = self.lm_head(hidden)
193
+ # Corruption/control tokens are never valid clean-token predictions. EOS
194
+ # deliberately remains available so generation can terminate naturally.
195
+ if self._forbidden_output_token_ids.numel():
196
+ logits.index_fill_(
197
+ -1,
198
+ self._forbidden_output_token_ids,
199
+ torch.finfo(logits.dtype).min,
200
+ )
201
+ return logits
202
+
203
+ @property
204
+ def num_parameters(self) -> int:
205
+ """Count unique trainable parameters (shared embeddings count once)."""
206
+
207
+ return sum(parameter.numel() for parameter in self.parameters() if parameter.requires_grad)
208
+
209
+
210
+ def build_denoiser(
211
+ config: ModelConfig,
212
+ *,
213
+ load_pretrained: bool = True,
214
+ dtype: torch.dtype | None = None,
215
+ ) -> nn.Module:
216
+ """Construct the denoiser a config describes: project transformer or pretrained backbone.
217
+
218
+ ``load_pretrained=False`` builds the architecture only, for callers that immediately
219
+ restore weights from a project checkpoint.
220
+ """
221
+
222
+ if config.backbone == "hf-qwen3":
223
+ from diffusion_lm.hf_bridge import Qwen3Denoiser
224
+
225
+ return Qwen3Denoiser(config, load_pretrained=load_pretrained, dtype=dtype)
226
+ return DiffusionTransformer(config)
227
+
228
+
229
+ def format_parameter_count(count: int) -> str:
230
+ if count >= 1_000_000:
231
+ return f"{count / 1_000_000:.2f}M"
232
+ if count >= 1_000:
233
+ return f"{count / 1_000:.2f}K"
234
+ return str(count)
235
+
236
+
237
+ def main() -> None:
238
+ parser = argparse.ArgumentParser(description="Report the exact model parameter count")
239
+ parser.add_argument("--config", type=Path, required=True, help="experiment YAML")
240
+ args = parser.parse_args()
241
+
242
+ config = load_config(args.config)
243
+ # Parameter inspection should not allocate four gigabytes for the 1B preset.
244
+ with torch.device("meta"):
245
+ model = DiffusionTransformer(config.model)
246
+ print(f"parameters: {model.num_parameters:,} ({format_parameter_count(model.num_parameters)})")
247
+
248
+
249
+ if __name__ == "__main__":
250
+ main()
src/diffusion_lm/playground.py ADDED
@@ -0,0 +1,541 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Local browser playground that streams the token-unmasking process."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import hashlib
7
+ import html
8
+ import secrets
9
+ import threading
10
+ import time
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Literal
14
+
15
+ import torch
16
+ from torch import Tensor
17
+ from tokenizers import Tokenizer
18
+
19
+ from diffusion_lm.diffusion import UnmaskStep, iterative_unmask_steps
20
+ from diffusion_lm.model import DiffusionTransformer, format_parameter_count
21
+ from diffusion_lm.sample import load_model
22
+ from diffusion_lm.tokenizer import load_tokenizer, special_token_id, special_token_ids
23
+ from diffusion_lm.train import resolve_device
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class GenerationSettings:
28
+ prompt: str = ""
29
+ generation_length: int = 64
30
+ steps: int = 64
31
+ temperature: float = 0.8
32
+ strategy: Literal["ancestral", "confidence"] = "confidence"
33
+ seed: int = 1337
34
+
35
+ def __post_init__(self) -> None:
36
+ if self.generation_length <= 0:
37
+ raise ValueError("La longitud debe ser mayor que cero.")
38
+ if not 1 <= self.steps <= 512:
39
+ raise ValueError("Los pasos deben estar entre 1 y 512.")
40
+ if not 0.0 <= self.temperature <= 5.0:
41
+ raise ValueError("La temperatura debe estar entre 0 y 5.")
42
+ if self.strategy not in {"ancestral", "confidence"}:
43
+ raise ValueError("La estrategia debe ser ancestral o confidence.")
44
+ if not 0 <= self.seed < 2**63:
45
+ raise ValueError("La semilla debe estar entre 0 y 2^63-1.")
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class PlaygroundUpdate:
50
+ state: UnmaskStep
51
+ prompt_tokens: int
52
+ partial_text: str
53
+ final_text: str
54
+ token_html: str
55
+ elapsed_seconds: float
56
+ step_seconds: float
57
+
58
+
59
+ def _seed_generation(device: torch.device, seed: int) -> None:
60
+ torch.manual_seed(seed)
61
+ if device.type == "cuda":
62
+ torch.cuda.manual_seed_all(seed)
63
+ elif device.type == "mps" and hasattr(torch.mps, "manual_seed"):
64
+ torch.mps.manual_seed(seed)
65
+
66
+
67
+ def _synchronize(device: torch.device) -> None:
68
+ if device.type == "cuda":
69
+ torch.cuda.synchronize(device)
70
+ elif device.type == "mps":
71
+ torch.mps.synchronize()
72
+
73
+
74
+ def _token_label(tokenizer: Tokenizer, token_id: int, mask_token_id: int) -> str:
75
+ raw = tokenizer.id_to_token(token_id) or f"#{token_id}"
76
+ if token_id == mask_token_id:
77
+ return "MASK"
78
+ return (
79
+ raw.replace("Ġ", "▁")
80
+ .replace("Ċ", "↵")
81
+ .replace("ĉ", "⇥")
82
+ .replace("\n", "↵")
83
+ ) or "∅"
84
+
85
+
86
+ def render_token_grid(
87
+ tokenizer: Tokenizer,
88
+ token_ids: list[int],
89
+ *,
90
+ mask_token_id: int,
91
+ prompt_tokens: int,
92
+ previous_token_ids: list[int] | None,
93
+ ) -> str:
94
+ """Render escaped token chips for a single sample."""
95
+
96
+ chips: list[str] = []
97
+ for position, token_id in enumerate(token_ids):
98
+ if position < prompt_tokens:
99
+ state = "prompt"
100
+ elif token_id == mask_token_id:
101
+ state = "mask"
102
+ elif previous_token_ids is not None and previous_token_ids[position] == mask_token_id:
103
+ state = "new"
104
+ else:
105
+ state = "revealed"
106
+
107
+ raw = tokenizer.id_to_token(token_id) or f"token {token_id}"
108
+ label = html.escape(_token_label(tokenizer, token_id, mask_token_id))
109
+ title = html.escape(f"posición {position} · id {token_id} · {raw}", quote=True)
110
+ chips.append(
111
+ f'<span class="token-chip token-{state}" title="{title}">{label}</span>'
112
+ )
113
+
114
+ return (
115
+ '<section class="token-stage" aria-label="Estado actual de los tokens">'
116
+ '<div class="token-grid">'
117
+ + "".join(chips)
118
+ + "</div>"
119
+ '<div class="token-legend" aria-label="Leyenda">'
120
+ '<span><i class="legend-dot legend-prompt"></i>prompt</span>'
121
+ '<span><i class="legend-dot legend-new"></i>recién revelado</span>'
122
+ '<span><i class="legend-dot legend-mask"></i>máscara</span>'
123
+ "</div></section>"
124
+ )
125
+
126
+
127
+ class PlaygroundEngine:
128
+ """Own one loaded model and serialize interactive generations."""
129
+
130
+ def __init__(self, model: DiffusionTransformer, tokenizer_path: str | Path) -> None:
131
+ self.model = model.eval()
132
+ self.tokenizer_path = Path(tokenizer_path)
133
+ self.tokenizer = load_tokenizer(self.tokenizer_path)
134
+ self._lock = threading.Lock()
135
+ self._validate_tokenizer()
136
+
137
+ @property
138
+ def device(self) -> torch.device:
139
+ return next(self.model.parameters()).device
140
+
141
+ def _validate_tokenizer(self) -> None:
142
+ tokenizer_hash = hashlib.sha256(self.tokenizer_path.read_bytes()).hexdigest()
143
+ model_hash = getattr(self.model, "tokenizer_sha256", None)
144
+ if model_hash is not None and tokenizer_hash != model_hash:
145
+ raise ValueError("El tokenizer no coincide con el usado para entrenar el checkpoint.")
146
+ if self.tokenizer.get_vocab_size(with_added_tokens=True) != self.model.config.vocab_size:
147
+ raise ValueError("El vocabulario del tokenizer no coincide con el checkpoint.")
148
+ if special_token_id(self.tokenizer, "mask") != self.model.config.mask_token_id:
149
+ raise ValueError("El id de [MASK] no coincide con el checkpoint.")
150
+
151
+ def info(self) -> dict[str, str | int | bool]:
152
+ return {
153
+ "parameters": self.model.num_parameters,
154
+ "parameters_human": format_parameter_count(self.model.num_parameters),
155
+ "device": str(self.device),
156
+ "context": self.model.config.max_seq_len,
157
+ "vocab_size": self.model.config.vocab_size,
158
+ "tokenizer": self.tokenizer_path.name,
159
+ "mps_fp64_fallback": self.device.type == "mps",
160
+ }
161
+
162
+ def _prepare(self, settings: GenerationSettings) -> tuple[Tensor, int, tuple[int, ...]]:
163
+ prompt_ids = (
164
+ self.tokenizer.encode(settings.prompt, add_special_tokens=False).ids
165
+ if settings.prompt
166
+ else []
167
+ )
168
+ role_ids = special_token_ids(self.tokenizer)
169
+ reserved_ids = set(role_ids.values())
170
+ encountered = reserved_ids.intersection(prompt_ids)
171
+ if encountered:
172
+ raise ValueError(
173
+ "El prompt contiene tokens especiales reservados. Escribí texto normal sin "
174
+ "los sentinels internos del modelo."
175
+ )
176
+
177
+ total_length = len(prompt_ids) + settings.generation_length
178
+ if total_length > self.model.config.max_seq_len:
179
+ available = self.model.config.max_seq_len - len(prompt_ids)
180
+ raise ValueError(
181
+ f"El prompt usa {len(prompt_ids)} tokens y deja {max(0, available)} para generar; "
182
+ f"solicitaste {settings.generation_length}."
183
+ )
184
+
185
+ input_ids = torch.full(
186
+ (1, total_length),
187
+ self.model.config.mask_token_id,
188
+ dtype=torch.long,
189
+ device=self.device,
190
+ )
191
+ if prompt_ids:
192
+ input_ids[0, : len(prompt_ids)] = torch.tensor(prompt_ids, device=self.device)
193
+
194
+ blocked = tuple(role_ids[role] for role in ("pad", "unk", "bos", "mask"))
195
+ return input_ids, len(prompt_ids), blocked
196
+
197
+ def _decode_final(self, token_ids: list[int], prompt_tokens: int) -> str:
198
+ eos_id = special_token_id(self.tokenizer, "eos")
199
+ if eos_id in token_ids[prompt_tokens:]:
200
+ token_ids = token_ids[: token_ids.index(eos_id, prompt_tokens)]
201
+ return self.tokenizer.decode(token_ids, skip_special_tokens=True)
202
+
203
+ def stream(self, settings: GenerationSettings):
204
+ """Yield one UI update per reverse-diffusion pass."""
205
+
206
+ input_ids, prompt_tokens, blocked = self._prepare(settings)
207
+ with self._lock:
208
+ _seed_generation(self.device, settings.seed)
209
+ started = time.perf_counter()
210
+ # Measures engine work per pass; consumer time between yields is excluded.
211
+ pass_started = started
212
+ previous_ids: list[int] | None = None
213
+ for state in iterative_unmask_steps(
214
+ self.model,
215
+ input_ids,
216
+ self.model.config.mask_token_id,
217
+ steps=settings.steps,
218
+ temperature=settings.temperature,
219
+ strategy=settings.strategy,
220
+ blocked_token_ids=blocked,
221
+ ):
222
+ token_ids = state.tokens[0].detach().cpu().tolist()
223
+ partial_text = self.tokenizer.decode(token_ids, skip_special_tokens=False)
224
+ final_text = (
225
+ self._decode_final(token_ids, prompt_tokens)
226
+ if state.masked_remaining == 0
227
+ else ""
228
+ )
229
+ token_html = render_token_grid(
230
+ self.tokenizer,
231
+ token_ids,
232
+ mask_token_id=self.model.config.mask_token_id,
233
+ prompt_tokens=prompt_tokens,
234
+ previous_token_ids=previous_ids,
235
+ )
236
+ if state.masked_remaining == 0:
237
+ _synchronize(self.device)
238
+ now = time.perf_counter()
239
+ yield PlaygroundUpdate(
240
+ state=state,
241
+ prompt_tokens=prompt_tokens,
242
+ partial_text=partial_text,
243
+ final_text=final_text,
244
+ token_html=token_html,
245
+ elapsed_seconds=now - started,
246
+ step_seconds=now - pass_started,
247
+ )
248
+ previous_ids = token_ids
249
+ pass_started = time.perf_counter()
250
+
251
+
252
+ PLAYGROUND_CSS = """
253
+ :root {
254
+ --playground-accent: #7c3aed;
255
+ --playground-accent-soft: rgba(124, 58, 237, 0.14);
256
+ --playground-teal: #0f766e;
257
+ --playground-border: rgba(100, 116, 139, 0.22);
258
+ }
259
+ .gradio-container { max-width: 1440px !important; }
260
+ .playground-header {
261
+ padding: 22px 24px; border: 1px solid var(--playground-border); border-radius: 18px;
262
+ background: linear-gradient(135deg, rgba(124,58,237,.10), rgba(15,118,110,.06));
263
+ box-shadow: 0 1px 2px rgba(30,41,59,.05), 0 14px 34px rgba(71,85,105,.08);
264
+ }
265
+ .playground-header h1 { margin: 0; font-size: clamp(1.6rem, 3vw, 2.4rem); text-wrap: balance; }
266
+ .playground-header p { margin: 8px 0 0; color: var(--body-text-color-subdued); text-wrap: pretty; }
267
+ .model-strip { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
268
+ .model-pill {
269
+ padding: 7px 10px; border-radius: 999px; border: 1px solid var(--playground-border);
270
+ background: var(--block-background-fill); font-variant-numeric: tabular-nums; font-size: .82rem;
271
+ }
272
+ .control-panel, .output-panel {
273
+ border: 1px solid var(--playground-border) !important; border-radius: 18px !important;
274
+ padding: 16px !important; box-shadow: 0 1px 2px rgba(30,41,59,.04), 0 10px 28px rgba(71,85,105,.06);
275
+ }
276
+ .token-stage { min-height: 220px; display: flex; flex-direction: column; justify-content: space-between; }
277
+ .token-grid { display: flex; flex-wrap: wrap; align-content: flex-start; gap: 7px; padding: 8px 2px 18px; }
278
+ .token-chip {
279
+ display: inline-flex; min-height: 32px; align-items: center; padding: 5px 8px; border-radius: 9px;
280
+ border: 1px solid transparent; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
281
+ font-size: .82rem; font-variant-numeric: tabular-nums; transition: transform 160ms ease-out, opacity 180ms ease-out;
282
+ }
283
+ .token-chip:hover { transform: translateY(-1px); }
284
+ .token-prompt { color: #075985; background: rgba(14,165,233,.12); border-color: rgba(14,165,233,.26); }
285
+ .token-revealed { background: rgba(15,118,110,.10); border-color: rgba(15,118,110,.18); }
286
+ .token-new { color: #5b21b6; background: var(--playground-accent-soft); border-color: rgba(124,58,237,.35); }
287
+ .token-mask { color: var(--body-text-color-subdued); background: rgba(100,116,139,.08); border: 1px dashed rgba(100,116,139,.30); opacity: .7; }
288
+ .token-legend { display: flex; flex-wrap: wrap; gap: 14px; color: var(--body-text-color-subdued); font-size: .78rem; }
289
+ .token-legend span { display: inline-flex; align-items: center; gap: 6px; }
290
+ .legend-dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; }
291
+ .legend-prompt { background: #0ea5e9; } .legend-new { background: #7c3aed; } .legend-mask { background: #94a3b8; }
292
+ #generate-button, #stop-button { min-height: 44px; transition: transform 150ms ease-out; }
293
+ #generate-button:active, #stop-button:active { transform: scale(.98); }
294
+ @media (prefers-reduced-motion: reduce) { .token-chip, #generate-button, #stop-button { transition: none; } }
295
+ """
296
+
297
+
298
+ def _model_header(engine: PlaygroundEngine) -> str:
299
+ info = engine.info()
300
+ warning = (
301
+ " · MPS usa CPU para Gumbel fp64 cuando temperatura > 0"
302
+ if info["mps_fp64_fallback"]
303
+ else ""
304
+ )
305
+ return (
306
+ '<header class="playground-header">'
307
+ "<h1>Mini Diffusion LM Playground</h1>"
308
+ "<p>Observá cómo el modelo transforma máscaras en texto usando contexto bidireccional."
309
+ f"{html.escape(warning)}</p>"
310
+ '<div class="model-strip">'
311
+ f'<span class="model-pill">{info["parameters_human"]} parámetros</span>'
312
+ f'<span class="model-pill">{html.escape(str(info["device"]))}</span>'
313
+ f'<span class="model-pill">contexto {info["context"]}</span>'
314
+ f'<span class="model-pill">vocabulario {info["vocab_size"]}</span>'
315
+ f'<span class="model-pill">{html.escape(str(info["tokenizer"]))}</span>'
316
+ "</div></header>"
317
+ )
318
+
319
+
320
+ def build_playground(engine: PlaygroundEngine):
321
+ """Build a Gradio Blocks app without importing Gradio for base-package users."""
322
+
323
+ try:
324
+ import gradio as gr
325
+ except ImportError as exc: # pragma: no cover - exercised by CLI environments.
326
+ raise RuntimeError(
327
+ 'Falta Gradio. Instalalo con: pip install -e ".[playground]"'
328
+ ) from exc
329
+
330
+ max_context = engine.model.config.max_seq_len
331
+ default_length = min(64, max_context)
332
+ theme = gr.themes.Soft(primary_hue="violet", secondary_hue="teal", neutral_hue="slate")
333
+ with gr.Blocks(
334
+ title="Mini Diffusion LM Playground",
335
+ analytics_enabled=False,
336
+ fill_width=True,
337
+ ) as demo:
338
+ gr.HTML(_model_header(engine))
339
+ with gr.Row():
340
+ with gr.Column(scale=4, elem_classes="control-panel"):
341
+ gr.Markdown("## Configuración")
342
+ prompt = gr.Textbox(
343
+ label="Prompt (opcional)",
344
+ placeholder="Ej.: Once upon a time…",
345
+ lines=6,
346
+ max_lines=10,
347
+ )
348
+ with gr.Row():
349
+ length = gr.Slider(
350
+ minimum=1,
351
+ maximum=max_context,
352
+ value=default_length,
353
+ step=1,
354
+ label="Tokens a generar",
355
+ )
356
+ steps = gr.Slider(
357
+ minimum=1,
358
+ maximum=256,
359
+ value=min(64, max_context),
360
+ step=1,
361
+ label="Pasos de difusión",
362
+ )
363
+ with gr.Accordion("Opciones avanzadas", open=False):
364
+ strategy = gr.Radio(
365
+ choices=[
366
+ ("Confianza · revela los tokens más seguros", "confidence"),
367
+ ("Ancestral · transición probabilística", "ancestral"),
368
+ ],
369
+ value="confidence",
370
+ label="Estrategia",
371
+ )
372
+ temperature = gr.Slider(
373
+ minimum=0.0,
374
+ maximum=2.0,
375
+ value=0.8,
376
+ step=0.05,
377
+ label="Temperatura",
378
+ )
379
+ seed = gr.Number(
380
+ value=0,
381
+ precision=0,
382
+ minimum=0,
383
+ maximum=2**31 - 1,
384
+ label='Semilla (0 = aleatoria en cada generación)',
385
+ )
386
+ with gr.Row():
387
+ generate_button = gr.Button(
388
+ "Generar",
389
+ variant="primary",
390
+ elem_id="generate-button",
391
+ )
392
+ stop_button = gr.Button(
393
+ "Detener",
394
+ variant="stop",
395
+ elem_id="stop-button",
396
+ )
397
+
398
+ with gr.Column(scale=7, elem_classes="output-panel"):
399
+ status = gr.Markdown(
400
+ "### Listo\nConfigurá una muestra y presioná **Generar**."
401
+ )
402
+ token_view = gr.HTML(
403
+ '<div class="token-stage"><p>Los tokens aparecerán acá.</p></div>'
404
+ )
405
+ output = gr.Textbox(
406
+ label="Texto actual",
407
+ lines=7,
408
+ interactive=False,
409
+ )
410
+ metrics = gr.Markdown("`Esperando una generación`", elem_classes="metrics")
411
+
412
+ def stream_generation(
413
+ prompt_value: str,
414
+ length_value: float,
415
+ steps_value: float,
416
+ strategy_value: str,
417
+ temperature_value: float,
418
+ seed_value: float,
419
+ ):
420
+ clicked = time.perf_counter()
421
+ try:
422
+ resolved_seed = int(seed_value) or secrets.randbelow(2**31 - 1) + 1
423
+ settings = GenerationSettings(
424
+ prompt=prompt_value or "",
425
+ generation_length=int(length_value),
426
+ steps=int(steps_value),
427
+ strategy=strategy_value, # type: ignore[arg-type]
428
+ temperature=float(temperature_value),
429
+ seed=resolved_seed,
430
+ )
431
+ for update in engine.stream(settings):
432
+ generated = settings.generation_length - update.state.masked_remaining
433
+ percent = 100.0 * generated / settings.generation_length
434
+ total_seconds = time.perf_counter() - clicked
435
+ average_step = update.elapsed_seconds / max(1, update.state.step)
436
+ tokens_per_second = (
437
+ generated / update.elapsed_seconds if update.elapsed_seconds > 0 else 0.0
438
+ )
439
+ if update.state.masked_remaining == 0:
440
+ status_text = (
441
+ f'### Completado en {total_seconds:.2f} s\n'
442
+ f'{generated} tokens en {update.state.step} pasos · '
443
+ f'{tokens_per_second:.1f} tok/s · '
444
+ f'{average_step * 1000:.0f} ms/paso promedio'
445
+ )
446
+ else:
447
+ status_text = (
448
+ f"### Paso {update.state.step}/{update.state.total_steps}\n"
449
+ f"{update.state.masked_remaining} máscaras restantes · "
450
+ f"{percent:.0f}% revelado"
451
+ )
452
+ visible_text = (
453
+ update.final_text
454
+ if update.state.masked_remaining == 0
455
+ else update.partial_text
456
+ )
457
+ metrics_text = (
458
+ f'`{total_seconds:.2f} s desde el clic` · '
459
+ f'`modelo {update.elapsed_seconds:.2f} s` · '
460
+ f'`paso {update.step_seconds * 1000:.0f} ms` · '
461
+ f'`prom. {average_step * 1000:.0f} ms/paso` · '
462
+ f'`{tokens_per_second:.1f} tok/s` · '
463
+ f'`{update.prompt_tokens} tokens de prompt` · '
464
+ f'`seed {settings.seed}`'
465
+ )
466
+ yield update.token_html, status_text, visible_text, metrics_text
467
+ except ValueError as exc:
468
+ raise gr.Error(str(exc)) from exc
469
+
470
+ generation_event = generate_button.click(
471
+ fn=stream_generation,
472
+ inputs=[prompt, length, steps, strategy, temperature, seed],
473
+ outputs=[token_view, status, output, metrics],
474
+ show_progress="minimal",
475
+ scroll_to_output=False,
476
+ concurrency_limit=1,
477
+ concurrency_id="diffusion-model",
478
+ trigger_mode="once",
479
+ stream_every=0.1,
480
+ api_visibility="private",
481
+ )
482
+ stop_button.click(
483
+ fn=lambda: "### Generación detenida",
484
+ outputs=status,
485
+ cancels=[generation_event],
486
+ queue=False,
487
+ api_visibility="private",
488
+ )
489
+
490
+ demo = demo.queue(max_size=8, default_concurrency_limit=1)
491
+ # Gradio 6 moved presentation arguments from Blocks() to launch().
492
+ demo._mini_diffusion_theme = theme
493
+ return demo
494
+
495
+
496
+ def _build_parser() -> argparse.ArgumentParser:
497
+ parser = argparse.ArgumentParser(description=__doc__)
498
+ parser.add_argument("--checkpoint", type=Path, required=True, help="checkpoint local confiable")
499
+ parser.add_argument("--tokenizer", type=Path, required=True, help="tokenizer usado al entrenar")
500
+ parser.add_argument("--device", default="auto", help="auto, cpu, mps, cuda…")
501
+ parser.add_argument("--host", default="127.0.0.1")
502
+ parser.add_argument("--port", type=int, default=7860)
503
+ parser.add_argument("--no-browser", action="store_true", help="no abrir el navegador")
504
+ return parser
505
+
506
+
507
+ def main() -> None:
508
+ args = _build_parser().parse_args()
509
+ if not args.checkpoint.is_file():
510
+ raise SystemExit(f"Checkpoint inexistente: {args.checkpoint}")
511
+ if not args.tokenizer.is_file():
512
+ raise SystemExit(f"Tokenizer inexistente: {args.tokenizer}")
513
+ if not 1 <= args.port <= 65535:
514
+ raise SystemExit("El puerto debe estar entre 1 y 65535")
515
+
516
+ device = resolve_device(args.device)
517
+ print(f"Cargando {args.checkpoint} en {device}…")
518
+ model = load_model(args.checkpoint, device)
519
+ engine = PlaygroundEngine(model, args.tokenizer)
520
+ demo = build_playground(engine)
521
+ print(f"Playground: http://{args.host}:{args.port}")
522
+ print("Usá únicamente checkpoints locales confiables.")
523
+ demo.launch(
524
+ server_name=args.host,
525
+ server_port=args.port,
526
+ inbrowser=not args.no_browser,
527
+ share=False,
528
+ show_error=True,
529
+ strict_cors=True,
530
+ max_threads=4,
531
+ footer_links=[],
532
+ enable_monitoring=False,
533
+ ssr_mode=False,
534
+ pwa=False,
535
+ theme=demo._mini_diffusion_theme,
536
+ css=PLAYGROUND_CSS,
537
+ )
538
+
539
+
540
+ if __name__ == "__main__":
541
+ main()
src/diffusion_lm/quality.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Programmatic generation quality: prose damage, and a reward over claim recaps.
2
+
3
+ The weights below encode a priority order rather than a tuned optimum: being right first,
4
+ not lying second, readable prose third, and brevity last and only relative to how much the
5
+ problem actually required. They are meant to be calibrated against judged samples before
6
+ driving any policy optimisation, since a reward this cheap is also cheap to game.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+
13
+ WORD = re.compile(r"[A-Za-z']+")
14
+
15
+ # Ordering, not tuning. Lying is charged PER ITEM rather than as a fraction of the record:
16
+ # a reader can work around clumsy prose or a missing line, but a fabricated plate or a
17
+ # superseded amount silently corrupts the report, so one of them has to outweigh several
18
+ # recalled fields. Verbosity is charged per fact carried, so a harder case may run longer.
19
+ WEIGHTS = {
20
+ 'recall': 1.0,
21
+ 'stale': -0.35,
22
+ 'invented': -0.35,
23
+ 'duplication': -0.2,
24
+ 'verbosity': -0.15,
25
+ }
26
+ # Thinking tokens per recalled fact treated as free before the verbosity term bites.
27
+ VERBOSITY_BUDGET = 40.0
28
+ # Lie counts saturate here so a wholly hallucinated report does not dominate the gradient.
29
+ MAX_LIES = 3.0
30
+
31
+
32
+ def prose_damage(text: str) -> dict[str, float]:
33
+ """Adjacent duplicate rate and lexical diversity, the parallel-sampling signatures."""
34
+
35
+ words = [word.lower() for word in WORD.findall(text)]
36
+ pairs = max(1, len(words) - 1)
37
+ duplicates = sum(a == b for a, b in zip(words, words[1:]))
38
+ return {
39
+ 'duplicate_rate': duplicates / pairs,
40
+ 'lexical_diversity': len(set(words)) / max(1, len(words)),
41
+ 'words': len(words),
42
+ }
43
+
44
+
45
+ def reward(report_score: dict[str, object], think_tokens: int, answer_text: str) -> dict:
46
+ """Combine fact fidelity, prose damage and verbosity into one scalar plus its parts.
47
+
48
+ ``report_score`` comes from :func:`diffusion_lm.claims.score_report`. Penalties are
49
+ fractions of the fact count so a long claim and a short one stay comparable.
50
+ """
51
+
52
+ recalled = max(1, int(report_score['recalled']))
53
+ damage = prose_damage(answer_text)
54
+ per_fact = think_tokens / recalled
55
+ parts = {
56
+ 'recall': float(report_score['recall']),
57
+ 'stale': min(MAX_LIES, len(report_score['stale_kept'])),
58
+ 'invented': min(MAX_LIES, len(report_score['invented'])),
59
+ 'duplication': min(1.0, damage['duplicate_rate'] * 20.0),
60
+ 'verbosity': max(0.0, per_fact - VERBOSITY_BUDGET) / VERBOSITY_BUDGET,
61
+ }
62
+ total = sum(WEIGHTS[name] * value for name, value in parts.items())
63
+ return {
64
+ 'total': total,
65
+ 'parts': parts,
66
+ 'think_tokens_per_fact': per_fact,
67
+ 'duplicate_rate': damage['duplicate_rate'],
68
+ 'lexical_diversity': damage['lexical_diversity'],
69
+ }
src/diffusion_lm/reasoning.py ADDED
@@ -0,0 +1,578 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OpenMathInstruct-2 preparation and packed datasets for reasoning experiments.
2
+
3
+ Each example is rendered into two layouts over one shared tokenizer:
4
+
5
+ - ``flat``: problem <think> steps... </think> answer <eos>
6
+ - ``slotted``: problem <think> [fixed-size thought slots, one step each, <tpad>-filled]
7
+ [terminal slot: </think> <tpad>...] answer <eos>
8
+
9
+ The slotted layout gives every thought a fixed-geometry block so a block-diffusion
10
+ model can denoise one thought at a time; the flat layout serves the autoregressive
11
+ and pure-diffusion baselines on identical example sets.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import hashlib
18
+ import json
19
+ import re
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+ from typing import Any, Iterator
23
+
24
+ import numpy as np
25
+ import torch
26
+ from torch import Tensor
27
+ from torch.utils.data import Dataset
28
+
29
+ from diffusion_lm.tokenizer import (
30
+ RoleAwareTokenizer,
31
+ load_tokenizer,
32
+ special_token_ids,
33
+ train_tokenizer_from_iterator,
34
+ )
35
+
36
+ REASONING_PACKED_FORMAT = 'mini-diffusion-lm-reasoning-packed-v1'
37
+
38
+ _REASONING_NAMESPACE = 'mdlm-r-8f4e2a6c'
39
+ THINK_TOKEN = f'<|{_REASONING_NAMESPACE}:think|>'
40
+ END_THINK_TOKEN = f'<|{_REASONING_NAMESPACE}:endthink|>'
41
+ THOUGHT_PAD_TOKEN = f'<|{_REASONING_NAMESPACE}:tpad|>'
42
+ REASONING_SPECIAL_TOKENS = (THINK_TOKEN, END_THINK_TOKEN, THOUGHT_PAD_TOKEN)
43
+
44
+ # Block sizes a thought may occupy in the adaptive layout. The model emits one
45
+ # ``<szN>`` control token per thought to choose the block length; the full menu is
46
+ # baked into the tokenizer so the vocabulary stays stable across experiments even
47
+ # when a run activates only a subset of the sizes.
48
+ BLOCK_SIZES = (32, 64, 128, 256, 512)
49
+ SIZE_TOKENS = tuple(f'<|{_REASONING_NAMESPACE}:sz{size}|>' for size in BLOCK_SIZES)
50
+ ADAPTIVE_SPECIAL_TOKENS = REASONING_SPECIAL_TOKENS + SIZE_TOKENS
51
+
52
+ # Native spellings honored when the tokenizer comes from a pretrained backbone whose vocabulary
53
+ # already carries trained think delimiters (e.g. Qwen3). The namespaced sentinel wins when both
54
+ # spellings exist so project tokenizers keep their exact historical ids.
55
+ _NATIVE_REASONING_TOKENS = {'think': '<think>', 'end_think': '</think>'}
56
+
57
+
58
+ def size_token_ids(
59
+ tokenizer: RoleAwareTokenizer, sizes: tuple[int, ...] | None = None
60
+ ) -> dict[int, int]:
61
+ """Map block sizes to ``<szN>`` control-token ids.
62
+
63
+ Without ``sizes`` the full menu is scanned and absent tokens are skipped, so tokenizers
64
+ trained before a menu extension keep resolving. Explicitly requested sizes must exist.
65
+ """
66
+
67
+ ids: dict[int, int] = {}
68
+ for size, token in zip(BLOCK_SIZES, SIZE_TOKENS):
69
+ if sizes is not None and size not in sizes:
70
+ continue
71
+ token_id = tokenizer.token_to_id(token)
72
+ if token_id is None:
73
+ if sizes is None:
74
+ continue
75
+ raise ValueError(f'tokenizer is missing the size token {token!r}')
76
+ ids[size] = token_id
77
+ if sizes is not None and (missing := set(sizes) - set(ids)):
78
+ raise ValueError(f'sizes {sorted(missing)} are not in the block-size menu {BLOCK_SIZES}')
79
+ if not ids:
80
+ raise ValueError('tokenizer carries no <szN> size tokens')
81
+ return ids
82
+
83
+ _BOILERPLATE_STEP = re.compile(
84
+ r"^let'?s\s+(solve|answer|tackle)\s+(the\s+)?(new\s+)?(question|problem)\b.*$",
85
+ re.IGNORECASE,
86
+ )
87
+ _MATH_SPAN = re.compile(r'\$[^$]*\$')
88
+ _SENTENCE_BREAK = re.compile(r'(?<=[.!?])\s+(?=[A-Z\d(])')
89
+ _BOXED = re.compile(r'\\boxed\{([^{}]*)\}')
90
+
91
+
92
+ def reasoning_token_ids(tokenizer: RoleAwareTokenizer) -> dict[str, int]:
93
+ """Resolve the reasoning control-token ids, failing on non-reasoning tokenizers.
94
+
95
+ Pretrained-backbone tokenizers resolve ``think``/``end_think`` through their native
96
+ trained spellings when the namespaced sentinels are absent.
97
+ """
98
+
99
+ ids: dict[str, int] = {}
100
+ for name, token in (
101
+ ('think', THINK_TOKEN),
102
+ ('end_think', END_THINK_TOKEN),
103
+ ('thought_pad', THOUGHT_PAD_TOKEN),
104
+ ):
105
+ token_id = tokenizer.token_to_id(token)
106
+ if token_id is None and name in _NATIVE_REASONING_TOKENS:
107
+ token_id = tokenizer.token_to_id(_NATIVE_REASONING_TOKENS[name])
108
+ if token_id is None:
109
+ raise ValueError(f'tokenizer is missing the reasoning token {token!r}')
110
+ ids[name] = token_id
111
+ return ids
112
+
113
+
114
+ def extract_boxed_answer(text: str) -> str | None:
115
+ matches = _BOXED.findall(text)
116
+ if not matches:
117
+ return None
118
+ return matches[-1].strip()
119
+
120
+
121
+ def _normalize_answer(answer: str) -> str:
122
+ cleaned = answer.strip().replace(',', '').replace('$', '').rstrip('.')
123
+ try:
124
+ value = float(cleaned)
125
+ except ValueError:
126
+ return cleaned
127
+ return str(int(value)) if value == int(value) else str(value)
128
+
129
+
130
+ def split_solution_steps(solution: str) -> list[str]:
131
+ """Split a natural-language CoT solution into sentence-level steps."""
132
+
133
+ protected: list[str] = []
134
+
135
+ def _protect(match: re.Match[str]) -> str:
136
+ protected.append(match.group(0))
137
+ return f'\x00{len(protected) - 1}\x00'
138
+
139
+ masked = _MATH_SPAN.sub(_protect, solution)
140
+ parts: list[str] = []
141
+ for line in masked.splitlines():
142
+ line = line.strip()
143
+ if not line:
144
+ continue
145
+ parts.extend(piece.strip() for piece in _SENTENCE_BREAK.split(line) if piece.strip())
146
+
147
+ def _restore(text: str) -> str:
148
+ return re.sub(r'\x00(\d+)\x00', lambda m: protected[int(m.group(1))], text)
149
+
150
+ steps = [_restore(part) for part in parts]
151
+ return [step for step in steps if not _BOILERPLATE_STEP.match(step)]
152
+
153
+
154
+ @dataclass(frozen=True)
155
+ class ReasoningExample:
156
+ problem: str
157
+ steps: tuple[str, ...]
158
+ answer: str
159
+ expected_answer: str
160
+
161
+
162
+ def parse_example(row: dict[str, Any]) -> ReasoningExample | None:
163
+ """Clean one dataset row into problem, thought steps, and an answer sentence."""
164
+
165
+ problem = ' '.join(str(row['problem']).split())
166
+ expected = _normalize_answer(str(row['expected_answer']))
167
+ steps = split_solution_steps(str(row['generated_solution']))
168
+ answer_index = None
169
+ for index in range(len(steps) - 1, -1, -1):
170
+ if _BOXED.search(steps[index]):
171
+ answer_index = index
172
+ break
173
+ if answer_index is None or not problem or not expected:
174
+ return None
175
+ answer = steps[answer_index]
176
+ thoughts = tuple(steps[:answer_index])
177
+ if not thoughts:
178
+ return None
179
+ boxed = extract_boxed_answer(answer)
180
+ if boxed is None or _normalize_answer(boxed) != expected:
181
+ return None
182
+ return ReasoningExample(problem, thoughts, answer, expected)
183
+
184
+
185
+ @dataclass(frozen=True)
186
+ class LayoutSpec:
187
+ seq_len: int
188
+ block: int
189
+ max_slots: int
190
+ sizes: tuple[int, ...] = ()
191
+
192
+ def __post_init__(self) -> None:
193
+ if self.seq_len <= 0 or self.block <= 1 or self.max_slots <= 1:
194
+ raise ValueError('seq_len, block, and max_slots must be meaningfully positive')
195
+ sizes = tuple(self.sizes)
196
+ if sizes:
197
+ if any(size not in BLOCK_SIZES for size in sizes):
198
+ raise ValueError(f'adaptive sizes must be drawn from {BLOCK_SIZES}')
199
+ if list(sizes) != sorted(set(sizes)):
200
+ raise ValueError('adaptive sizes must be unique and ascending')
201
+ object.__setattr__(self, 'sizes', sizes)
202
+
203
+
204
+ @dataclass(frozen=True)
205
+ class EncodedExample:
206
+ flat: np.ndarray
207
+ flat_regions: np.ndarray
208
+ slotted: np.ndarray
209
+ slotted_regions: np.ndarray
210
+
211
+
212
+ @dataclass(frozen=True)
213
+ class EncodedAdaptive:
214
+ tokens: np.ndarray
215
+ regions: np.ndarray
216
+ block_sizes: tuple[int, ...]
217
+
218
+
219
+ class ExampleEncoder:
220
+ """Render cleaned examples into the flat and slotted token layouts."""
221
+
222
+ def __init__(self, tokenizer: RoleAwareTokenizer, spec: LayoutSpec) -> None:
223
+ self.tokenizer = tokenizer
224
+ self.spec = spec
225
+ roles = special_token_ids(tokenizer)
226
+ reasoning = reasoning_token_ids(tokenizer)
227
+ self.pad_id = roles['pad']
228
+ self.eos_id = roles['eos']
229
+ self.think_id = reasoning['think']
230
+ self.end_think_id = reasoning['end_think']
231
+ self.tpad_id = reasoning['thought_pad']
232
+ self.size_ids = size_token_ids(tokenizer, spec.sizes) if spec.sizes else {}
233
+ vocab_size = tokenizer.get_vocab_size(with_added_tokens=True)
234
+ self.token_dtype = np.dtype(
235
+ 'uint16' if vocab_size <= np.iinfo(np.uint16).max else 'uint32'
236
+ )
237
+
238
+ def _encode(self, text: str) -> list[int]:
239
+ return self.tokenizer.encode(text, add_special_tokens=False).ids
240
+
241
+ def encode_adaptive(self, example: ReasoningExample) -> EncodedAdaptive | None:
242
+ """Render an example into the adaptive layout with per-thought size tokens.
243
+
244
+ Each thought is placed in the smallest active block that fits it; thoughts
245
+ longer than the largest block continue across consecutive maximum blocks.
246
+ The ``<szN>`` control token precedes every block, giving the model an
247
+ autoregressive target for the block-length decision, and the remainder of
248
+ each block is filled with ``<tpad>``.
249
+ """
250
+
251
+ spec = self.spec
252
+ sizes = spec.sizes
253
+ if not sizes:
254
+ raise ValueError('encode_adaptive requires a LayoutSpec with sizes set')
255
+ max_size = sizes[-1]
256
+ problem_ids = self._encode(example.problem)
257
+ answer_ids = self._encode(example.answer)
258
+ if not answer_ids or not problem_ids:
259
+ return None
260
+
261
+ blocks: list[tuple[int, list[int]]] = []
262
+ for step in example.steps:
263
+ ids = self._encode(step)
264
+ for start in range(0, len(ids), max_size):
265
+ chunk = ids[start : start + max_size]
266
+ size = next(candidate for candidate in sizes if candidate >= len(chunk))
267
+ blocks.append((size, chunk))
268
+ # An empty chain is a legitimate example: it teaches the controller to answer without
269
+ # thinking, which a conversation needs on every trivial turn.
270
+ if len(blocks) > spec.max_slots:
271
+ return None
272
+
273
+ tokens = [*problem_ids, self.think_id]
274
+ for size, chunk in blocks:
275
+ tokens.append(self.size_ids[size])
276
+ tokens.extend(chunk)
277
+ tokens.extend([self.tpad_id] * (size - len(chunk)))
278
+ tokens.append(self.end_think_id)
279
+ answer_start = len(tokens)
280
+ tokens.extend(answer_ids)
281
+ tokens.append(self.eos_id)
282
+ answer_end = len(tokens)
283
+ if answer_end > spec.seq_len:
284
+ return None
285
+ tokens.extend([self.pad_id] * (spec.seq_len - len(tokens)))
286
+
287
+ regions = np.asarray(
288
+ [len(problem_ids), len(blocks), answer_start, answer_end], dtype=np.int32
289
+ )
290
+ return EncodedAdaptive(
291
+ tokens=np.asarray(tokens, dtype=self.token_dtype),
292
+ regions=regions,
293
+ block_sizes=tuple(size for size, _ in blocks),
294
+ )
295
+
296
+ def encode_example(self, example: ReasoningExample) -> EncodedExample | None:
297
+ spec = self.spec
298
+ problem_ids = self._encode(example.problem)
299
+ step_ids = [self._encode(step) for step in example.steps]
300
+ answer_ids = self._encode(example.answer)
301
+
302
+ if not answer_ids or not problem_ids:
303
+ return None
304
+
305
+ # A thought that exceeds one slot continues into the next, so long steps
306
+ # cost extra slots instead of dropping the example.
307
+ chunked: list[list[int]] = []
308
+ for ids in step_ids:
309
+ for start in range(0, len(ids), spec.block):
310
+ chunked.append(ids[start : start + spec.block])
311
+ step_ids = chunked
312
+ if len(step_ids) > spec.max_slots - 1:
313
+ return None
314
+
315
+ slots = len(step_ids) + 1
316
+ slotted_len = len(problem_ids) + 1 + slots * spec.block + len(answer_ids) + 1
317
+ if slotted_len > spec.seq_len:
318
+ return None
319
+
320
+ slotted = [*problem_ids, self.think_id]
321
+ for ids in step_ids:
322
+ slotted.extend(ids)
323
+ slotted.extend([self.tpad_id] * (spec.block - len(ids)))
324
+ slotted.append(self.end_think_id)
325
+ slotted.extend([self.tpad_id] * (spec.block - 1))
326
+ answer_start_slotted = len(slotted)
327
+ slotted.extend(answer_ids)
328
+ slotted.append(self.eos_id)
329
+ answer_end_slotted = len(slotted)
330
+ slotted.extend([self.pad_id] * (spec.seq_len - len(slotted)))
331
+
332
+ flat_think_ids = self._encode(' '.join(example.steps))
333
+ flat = [*problem_ids, self.think_id, *flat_think_ids, self.end_think_id]
334
+ answer_start_flat = len(flat)
335
+ flat.extend(answer_ids)
336
+ flat.append(self.eos_id)
337
+ answer_end_flat = len(flat)
338
+ if answer_end_flat > spec.seq_len:
339
+ return None
340
+ flat.extend([self.pad_id] * (spec.seq_len - len(flat)))
341
+
342
+ def _regions(problem_len: int, n_slots: int, start: int, end: int) -> np.ndarray:
343
+ return np.asarray([problem_len, n_slots, start, end], dtype=np.int32)
344
+
345
+ return EncodedExample(
346
+ flat=np.asarray(flat, dtype=self.token_dtype),
347
+ flat_regions=_regions(len(problem_ids), 0, answer_start_flat, answer_end_flat),
348
+ slotted=np.asarray(slotted, dtype=self.token_dtype),
349
+ slotted_regions=_regions(
350
+ len(problem_ids), slots, answer_start_slotted, answer_end_slotted
351
+ ),
352
+ )
353
+
354
+
355
+ def regions_path(token_path: str | Path) -> Path:
356
+ path = Path(token_path)
357
+ return path.with_suffix('.regions.npy')
358
+
359
+
360
+ class ReasoningTokenDataset(Dataset[tuple[Tensor, Tensor]]):
361
+ """Fixed-length reasoning sequences with per-example region annotations.
362
+
363
+ Regions hold ``[problem_len, n_slots, answer_start, answer_end]``. ``n_slots``
364
+ is zero for the flat layout and counts thought slots including the terminal
365
+ ``</think>`` slot for the slotted layout.
366
+ """
367
+
368
+ def __init__(self, path: str | Path) -> None:
369
+ self.path = Path(path)
370
+ metadata_path = self.path.with_suffix(self.path.suffix + '.json')
371
+ if not metadata_path.is_file():
372
+ raise FileNotFoundError(f'reasoning metadata not found: {metadata_path}')
373
+ with metadata_path.open('r', encoding='utf-8') as handle:
374
+ self.metadata = json.load(handle)
375
+ if self.metadata.get('format') != REASONING_PACKED_FORMAT:
376
+ raise ValueError(f'unsupported reasoning data format in {metadata_path}')
377
+ self.seq_len = int(self.metadata['seq_len'])
378
+ count = int(self.metadata['example_count'])
379
+ # Datasets packed before the dtype key existed are always uint16.
380
+ self.token_dtype = np.dtype(self.metadata.get('dtype', 'uint16'))
381
+ self._tokens = np.memmap(
382
+ self.path, mode='r', dtype=self.token_dtype, shape=(count, self.seq_len)
383
+ )
384
+ self._regions = np.load(regions_path(self.path))
385
+ if self._regions.shape != (count, 4):
386
+ raise ValueError(f'regions shape {self._regions.shape} does not match example count')
387
+
388
+ def __len__(self) -> int:
389
+ return int(self._tokens.shape[0])
390
+
391
+ def __getitem__(self, index: int) -> tuple[Tensor, Tensor]:
392
+ tokens = torch.from_numpy(np.asarray(self._tokens[index], dtype=np.int64))
393
+ regions = torch.from_numpy(np.asarray(self._regions[index], dtype=np.int64))
394
+ return tokens, regions
395
+
396
+ def __getstate__(self) -> dict[str, Any]:
397
+ state = self.__dict__.copy()
398
+ state['_tokens'] = None
399
+ return state
400
+
401
+ def __setstate__(self, state: dict[str, Any]) -> None:
402
+ self.__dict__.update(state)
403
+ count = int(self.metadata['example_count'])
404
+ self._tokens = np.memmap(
405
+ self.path, mode='r', dtype=self.token_dtype, shape=(count, self.seq_len)
406
+ )
407
+
408
+
409
+ def _write_packed(
410
+ path: Path,
411
+ tokens: np.ndarray,
412
+ regions: np.ndarray,
413
+ *,
414
+ layout: str,
415
+ spec: LayoutSpec,
416
+ tokenizer_path: Path,
417
+ tokenizer: RoleAwareTokenizer,
418
+ extra_metadata: dict[str, Any] | None = None,
419
+ ) -> None:
420
+ path.parent.mkdir(parents=True, exist_ok=True)
421
+ tokens.tofile(path)
422
+ np.save(regions_path(path), regions)
423
+ if tokens.dtype not in (np.dtype('uint16'), np.dtype('uint32')):
424
+ raise ValueError(f'packed reasoning tokens must be uint16 or uint32, got {tokens.dtype}')
425
+ metadata = {
426
+ 'format': REASONING_PACKED_FORMAT,
427
+ 'layout': layout,
428
+ 'seq_len': spec.seq_len,
429
+ 'block': spec.block,
430
+ 'max_slots': spec.max_slots,
431
+ 'dtype': tokens.dtype.name,
432
+ 'example_count': int(tokens.shape[0]),
433
+ 'vocab_size': tokenizer.get_vocab_size(with_added_tokens=True),
434
+ 'special_token_ids': special_token_ids(tokenizer),
435
+ 'reasoning_token_ids': reasoning_token_ids(tokenizer),
436
+ 'tokenizer_sha256': hashlib.sha256(tokenizer_path.read_bytes()).hexdigest(),
437
+ }
438
+ if extra_metadata:
439
+ metadata.update(extra_metadata)
440
+ metadata_path = path.with_suffix(path.suffix + '.json')
441
+ with metadata_path.open('w', encoding='utf-8') as handle:
442
+ json.dump(metadata, handle, indent=2)
443
+ handle.write('\n')
444
+
445
+
446
+ def _iter_rows(parquet_paths: list[Path], sources: set[str]) -> Iterator[dict[str, Any]]:
447
+ import pyarrow.parquet as pq
448
+
449
+ for path in parquet_paths:
450
+ table = pq.read_table(
451
+ path, columns=['problem', 'generated_solution', 'expected_answer', 'problem_source']
452
+ )
453
+ for row in table.to_pylist():
454
+ if row['problem_source'] in sources:
455
+ yield row
456
+
457
+
458
+ def _is_validation(problem: str, val_fraction: float) -> bool:
459
+ digest = hashlib.sha256(problem.encode('utf-8')).digest()
460
+ return int.from_bytes(digest[:4], 'big') / 2**32 < val_fraction
461
+
462
+
463
+ def prepare(args: argparse.Namespace) -> None:
464
+ sources = set(args.sources.split(','))
465
+ parquet_paths = [Path(path) for path in args.parquet]
466
+ spec = LayoutSpec(seq_len=args.seq_len, block=args.block, max_slots=args.max_slots)
467
+ output_dir = Path(args.output_dir)
468
+ tokenizer_path = Path(args.tokenizer)
469
+
470
+ examples: list[ReasoningExample] = []
471
+ dropped_parse = 0
472
+ seen: set[str] = set()
473
+ for row in _iter_rows(parquet_paths, sources):
474
+ example = parse_example(row)
475
+ if example is None:
476
+ dropped_parse += 1
477
+ continue
478
+ key = hashlib.sha256(
479
+ (example.problem + '\x1f' + ' '.join(example.steps)).encode('utf-8')
480
+ ).hexdigest()
481
+ if key in seen:
482
+ continue
483
+ seen.add(key)
484
+ examples.append(example)
485
+ print(f'parsed {len(examples):,} unique examples ({dropped_parse:,} dropped at parse)')
486
+
487
+ if tokenizer_path.is_file():
488
+ tokenizer = load_tokenizer(tokenizer_path)
489
+ reasoning_token_ids(tokenizer)
490
+ print(f'reusing tokenizer {tokenizer_path}')
491
+ else:
492
+ def _texts() -> Iterator[str]:
493
+ for example in examples:
494
+ yield f'{example.problem}\n{" ".join(example.steps)}\n{example.answer}'
495
+
496
+ tokenizer = train_tokenizer_from_iterator(
497
+ _texts(),
498
+ tokenizer_path,
499
+ vocab_size=args.vocab_size,
500
+ min_frequency=4,
501
+ length=len(examples),
502
+ extra_special_tokens=REASONING_SPECIAL_TOKENS,
503
+ )
504
+ print(f'trained tokenizer {tokenizer_path}')
505
+
506
+ encoder = ExampleEncoder(tokenizer, spec)
507
+ split: dict[str, dict[str, list[np.ndarray]]] = {
508
+ 'train': {'flat': [], 'flat_regions': [], 'slotted': [], 'slotted_regions': []},
509
+ 'validation': {'flat': [], 'flat_regions': [], 'slotted': [], 'slotted_regions': []},
510
+ }
511
+ val_problems: list[dict[str, str]] = []
512
+ dropped_encode = 0
513
+ for example in examples:
514
+ encoded = encoder.encode_example(example)
515
+ if encoded is None:
516
+ dropped_encode += 1
517
+ continue
518
+ is_val = _is_validation(example.problem, args.val_fraction)
519
+ bucket = split['validation' if is_val else 'train']
520
+ bucket['flat'].append(encoded.flat)
521
+ bucket['flat_regions'].append(encoded.flat_regions)
522
+ bucket['slotted'].append(encoded.slotted)
523
+ bucket['slotted_regions'].append(encoded.slotted_regions)
524
+ if is_val:
525
+ val_problems.append(
526
+ {'problem': example.problem, 'expected_answer': example.expected_answer}
527
+ )
528
+
529
+ total = sum(len(bucket['flat']) for bucket in split.values())
530
+ print(f'encoded {total:,} examples ({dropped_encode:,} dropped at encode)')
531
+ for split_name, bucket in split.items():
532
+ if not bucket['flat']:
533
+ raise ValueError(f'no examples in the {split_name} split; adjust filters')
534
+ for layout in ('flat', 'slotted'):
535
+ _write_packed(
536
+ output_dir / f'{split_name}-{layout}.bin',
537
+ np.stack(bucket[layout]),
538
+ np.stack(bucket[f'{layout}_regions']),
539
+ layout=layout,
540
+ spec=spec,
541
+ tokenizer_path=tokenizer_path,
542
+ tokenizer=tokenizer,
543
+ )
544
+ print(f'{split_name}: {len(bucket["flat"]):,} examples -> {output_dir}')
545
+
546
+ problems_path = output_dir / 'validation-problems.jsonl'
547
+ with problems_path.open('w', encoding='utf-8') as handle:
548
+ for record in val_problems:
549
+ handle.write(json.dumps(record, ensure_ascii=False) + '\n')
550
+ print(f'wrote {len(val_problems):,} validation problems to {problems_path}')
551
+
552
+
553
+ def _build_parser() -> argparse.ArgumentParser:
554
+ parser = argparse.ArgumentParser(description=__doc__)
555
+ subparsers = parser.add_subparsers(dest='command', required=True)
556
+ prepare_parser = subparsers.add_parser(
557
+ 'prepare', help='filter, tokenize, and pack OpenMathInstruct-2 parquet shards'
558
+ )
559
+ prepare_parser.add_argument('--parquet', type=Path, nargs='+', required=True)
560
+ prepare_parser.add_argument('--output-dir', type=Path, required=True)
561
+ prepare_parser.add_argument('--tokenizer', type=Path, required=True)
562
+ prepare_parser.add_argument('--vocab-size', type=int, default=8192)
563
+ prepare_parser.add_argument('--seq-len', type=int, default=512)
564
+ prepare_parser.add_argument('--block', type=int, default=32)
565
+ prepare_parser.add_argument('--max-slots', type=int, default=11)
566
+ prepare_parser.add_argument('--val-fraction', type=float, default=0.02)
567
+ prepare_parser.add_argument('--sources', default='gsm8k,augmented_gsm8k')
568
+ return parser
569
+
570
+
571
+ def main() -> None:
572
+ args = _build_parser().parse_args()
573
+ if args.command == 'prepare':
574
+ prepare(args)
575
+
576
+
577
+ if __name__ == '__main__':
578
+ main()
src/diffusion_lm/reasoning_playground.py ADDED
@@ -0,0 +1,1226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Interactive playground for the reasoning variants.
2
+
3
+ Loads any ``mini-diffusion-lm-reasoning-inference-v1`` checkpoint and solves
4
+ user-provided problems with the sampler matching its training objective,
5
+ streaming thought slots as they are denoised and answer tokens as they are
6
+ decoded, together with per-phase wall-clock metrics.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import html
13
+ import json
14
+ import time
15
+ from pathlib import Path
16
+
17
+ import torch
18
+
19
+ from diffusion_lm.claims import (
20
+ IM_END,
21
+ SYSTEM,
22
+ chat_prefix,
23
+ ledger_line,
24
+ ledger_notes,
25
+ merge_notes,
26
+ window_start,
27
+ )
28
+ from diffusion_lm.config import ModelConfig
29
+ from diffusion_lm.diffusion import iterative_unmask_steps, _sample_categorical
30
+ from diffusion_lm.flexattn import _FLEX_EAGER
31
+ from diffusion_lm.hybrid import (
32
+ _apply_repetition_penalty,
33
+ _apply_top_p,
34
+ _ar_predict_control,
35
+ _kv_cache_enabled,
36
+ adaptive_block_mask,
37
+ block_mask_from_boundaries,
38
+ block_size_curriculum,
39
+ prefix_causal_blocked,
40
+ slot_causal_blocked,
41
+ )
42
+ from diffusion_lm.model import build_denoiser
43
+ from diffusion_lm.reasoning import extract_boxed_answer, reasoning_token_ids, size_token_ids
44
+ from diffusion_lm.tokenizer import load_tokenizer, special_token_ids
45
+ from diffusion_lm.train import resolve_device
46
+
47
+
48
+ def _discover_checkpoints(root: Path) -> dict[str, Path]:
49
+ found: dict[str, Path] = {}
50
+ for path in sorted(root.glob('*/inference-latest.pt')):
51
+ found[path.parent.name] = path
52
+ return found
53
+
54
+
55
+ class ReasoningEngine:
56
+ """Load one reasoning checkpoint and stream problem solutions."""
57
+
58
+ def __init__(self, checkpoint_path: Path, tokenizer_path: Path, device: torch.device) -> None:
59
+ payload = torch.load(checkpoint_path, map_location='cpu', weights_only=False)
60
+ if payload.get('format') != 'mini-diffusion-lm-reasoning-inference-v1':
61
+ raise ValueError(f'unsupported checkpoint format in {checkpoint_path}')
62
+ self.config = payload['config']
63
+ self.objective = self.config['reasoning']['objective']
64
+ model_config = ModelConfig(**self.config['model'])
65
+ self.model = build_denoiser(model_config, load_pretrained=False)
66
+ self.model.load_state_dict(payload['model'])
67
+ dtype = (
68
+ torch.bfloat16
69
+ if model_config.backbone != 'project' and device.type == 'cuda'
70
+ else torch.float32
71
+ )
72
+ self.model.to(device=device, dtype=dtype).eval()
73
+ self.device = device
74
+ self.step = payload.get('step')
75
+ self.tokenizer = load_tokenizer(tokenizer_path)
76
+ self.roles = special_token_ids(self.tokenizer)
77
+ self.reasoning_ids = reasoning_token_ids(self.tokenizer)
78
+ self.adaptive = bool(self.config['reasoning'].get('adaptive'))
79
+ self.causal_prefix = bool(self.config['reasoning'].get('causal_prefix', False))
80
+ trained_sizes = tuple(self.config['reasoning'].get('sizes') or ()) or None
81
+ self.size_ids = size_token_ids(self.tokenizer, trained_sizes) if self.adaptive else {}
82
+ self.im_end_id = self.tokenizer.token_to_id(IM_END)
83
+ self.chat_ready = (
84
+ self.objective == 'hybrid' and self.adaptive and self.im_end_id is not None
85
+ )
86
+ self.block = 32
87
+ self.max_slots = 40
88
+ self.max_blocks = 48
89
+ self.log_lines: list[str] = []
90
+
91
+ def _decode(self, ids: list[int]) -> str:
92
+ return self.tokenizer.decode(ids, skip_special_tokens=True)
93
+
94
+ def _slot_texts(self, think_ids: list[int]) -> list[str]:
95
+ end_id = self.reasoning_ids['end_think']
96
+ pad_id = self.reasoning_ids['thought_pad']
97
+ slots = [
98
+ think_ids[start : start + self.block]
99
+ for start in range(0, len(think_ids), self.block)
100
+ ]
101
+ texts = []
102
+ for slot in slots:
103
+ content = [t for t in slot if t not in (pad_id, end_id)]
104
+ texts.append(self._decode(content).strip())
105
+ return [text for text in texts if text]
106
+
107
+ def _block_contents(self, think_ids: list[int]) -> list[tuple[int, str]]:
108
+ """Decode variable-size blocks by walking the ``<szN>`` tokens in the stream."""
109
+
110
+ size_by_id = {token_id: size for size, token_id in self.size_ids.items()}
111
+ end_id = self.reasoning_ids['end_think']
112
+ pad_id = self.reasoning_ids['thought_pad']
113
+ blocks = []
114
+ cut = 0
115
+ while cut < len(think_ids):
116
+ size = size_by_id.get(think_ids[cut])
117
+ if size is None:
118
+ cut += 1
119
+ continue
120
+ block = think_ids[cut + 1 : cut + 1 + size]
121
+ content = [t for t in block if t not in (pad_id, end_id)]
122
+ blocks.append((size, self._decode(content).strip()))
123
+ cut += 1 + size
124
+ return blocks
125
+
126
+ def _adaptive_block_texts(self, think_ids: list[int]) -> list[str]:
127
+ return [
128
+ f'({size}) {text}'.strip() for size, text in self._block_contents(think_ids)
129
+ ]
130
+
131
+ def _token_glyph(self, token_id: int) -> str:
132
+ """Render one in-flight token: masks and structural tokens get visible glyphs."""
133
+
134
+ if token_id == self.model.config.mask_token_id:
135
+ return '▒'
136
+ if token_id == self.reasoning_ids['thought_pad']:
137
+ return '·'
138
+ if token_id == self.reasoning_ids['end_think']:
139
+ return ' ⏹'
140
+ return self.tokenizer.decode([token_id], skip_special_tokens=False)
141
+
142
+ def _active_slot_text(self, slot_tokens: list[int]) -> str:
143
+ return ''.join(self._token_glyph(token) for token in slot_tokens)
144
+
145
+ @torch.no_grad()
146
+ def stream_solve(
147
+ self,
148
+ problem: str,
149
+ *,
150
+ temperature: float,
151
+ steps_per_block: int,
152
+ diffusion_steps: int,
153
+ max_answer_tokens: int = 200,
154
+ repetition_penalty: float = 1.0,
155
+ top_p: float = 1.0,
156
+ seed: int = 0,
157
+ strategy: str = 'ancestral',
158
+ step_delay: float = 0.0,
159
+ ):
160
+ """Yield ``(thoughts, answer, status)`` snapshots while solving.
161
+
162
+ ``step_delay`` throttles each denoising step (and each AR token) so the
163
+ reveal order is watchable in real time.
164
+ """
165
+
166
+ generator = torch.Generator(device=self.device.type)
167
+ generator.manual_seed(seed if seed else int(time.time_ns() % 2**31))
168
+ prompt_ids = self.tokenizer.encode(problem.strip(), add_special_tokens=False).ids
169
+ mask_id = self.model.config.mask_token_id
170
+ think_id = self.reasoning_ids['think']
171
+ end_think_id = self.reasoning_ids['end_think']
172
+ eos_id = self.roles['eos']
173
+
174
+ if self.objective == 'hybrid' and self.adaptive:
175
+ yield from self._stream_adaptive(
176
+ prompt_ids,
177
+ stop_ids=(eos_id,),
178
+ temperature=temperature,
179
+ steps_per_block=steps_per_block,
180
+ max_answer_tokens=max_answer_tokens,
181
+ repetition_penalty=repetition_penalty,
182
+ top_p=top_p,
183
+ strategy=strategy,
184
+ step_delay=step_delay,
185
+ generator=generator,
186
+ )
187
+ return
188
+
189
+ if self.objective == 'block_diffusion':
190
+ # Plain-text continuation under the conversion geometry: each new block is
191
+ # fully masked and denoised bidirectionally over its causal past, with block
192
+ # sizes drawn from the same curriculum weights the checkpoint trained under.
193
+ reasoning_cfg = self.config['reasoning']
194
+ sizes = tuple(reasoning_cfg.get('sizes') or (self.block,))
195
+ weights = block_size_curriculum(
196
+ self.step,
197
+ n_sizes=len(sizes),
198
+ curriculum_steps=int(reasoning_cfg.get('curriculum_steps') or 0),
199
+ ).to(self.device)
200
+ size_tensor = torch.tensor(sizes, device=self.device)
201
+ budget = min(384, self.model.config.max_seq_len - len(prompt_ids) - 1)
202
+ self.log_lines = [
203
+ f'prompt_tokens={len(prompt_ids)} temp={temperature} '
204
+ f'steps/block={steps_per_block} sizes={list(sizes)} '
205
+ f'weights={[round(w, 3) for w in weights.tolist()]} '
206
+ f'strategy={strategy} flex_eager={_FLEX_EAGER}'
207
+ ]
208
+ sequence = list(prompt_ids)
209
+ prompt_len = len(prompt_ids)
210
+ boundary_starts: list[int] = []
211
+ block_texts: list[str] = []
212
+ generated: list[int] = []
213
+ compute = 0.0
214
+ finished = False
215
+ while not finished and len(generated) < budget:
216
+ drawn = int(
217
+ size_tensor[torch.multinomial(weights, 1, generator=generator)].item()
218
+ )
219
+ block = min(drawn, budget - len(generated))
220
+ prefix_len = len(sequence)
221
+ seq_len = prefix_len + block
222
+ boundary_starts.append(prefix_len)
223
+ boundary = torch.zeros(1, seq_len, dtype=torch.bool, device=self.device)
224
+ boundary[0, 0] = True
225
+ for start in boundary_starts:
226
+ boundary[0, start] = True
227
+ blocked = block_mask_from_boundaries(
228
+ boundary,
229
+ torch.tensor([prompt_len], device=self.device),
230
+ torch.tensor([seq_len], device=self.device),
231
+ causal_prefix=self.causal_prefix,
232
+ )
233
+ current = torch.tensor(
234
+ [sequence + [mask_id] * block], dtype=torch.long, device=self.device
235
+ )
236
+ block_ids: list[int] = []
237
+ tick = time.perf_counter()
238
+ for state in iterative_unmask_steps(
239
+ self.model,
240
+ current,
241
+ mask_id,
242
+ steps=steps_per_block,
243
+ temperature=temperature,
244
+ strategy=strategy,
245
+ blocked_token_ids=(self.roles['pad'],),
246
+ attn_mask=blocked,
247
+ generator=generator,
248
+ ):
249
+ compute += time.perf_counter() - tick
250
+ block_ids = [int(t) for t in state.tokens[0, prefix_len:]]
251
+ if step_delay or state.masked_remaining == 0:
252
+ yield (
253
+ [*block_texts, self._active_slot_text(block_ids)],
254
+ self._decode(generated),
255
+ f'bloque {len(block_texts) + 1} ({block} tok) · '
256
+ f'denoising {state.step}/{state.total_steps}',
257
+ )
258
+ if step_delay and state.masked_remaining:
259
+ time.sleep(step_delay)
260
+ tick = time.perf_counter()
261
+ if eos_id in block_ids:
262
+ block_ids = block_ids[: block_ids.index(eos_id)]
263
+ finished = True
264
+ sequence.extend(block_ids)
265
+ generated.extend(block_ids)
266
+ block_texts.append(f'({block}) {self._decode(block_ids).strip()}'.strip())
267
+ self.log_lines.append(
268
+ f'block {len(block_texts)}: size={block} · {compute:.2f}s cum'
269
+ )
270
+ rate = len(generated) / compute if compute > 0 else 0.0
271
+ self.log_lines.append(
272
+ f'TOTAL: {len(generated)} tok · blocks={len(block_texts)} · '
273
+ f'{compute:.2f}s ({rate:.0f} tok/s)'
274
+ )
275
+ yield block_texts, self._decode(generated), (
276
+ f'listo · {len(block_texts)} bloques · {len(generated)} tok en '
277
+ f'{compute:.2f}s ({rate:.0f} tok/s) '
278
+ f'(checkpoint CPT: continúa texto, no piensa)'
279
+ )
280
+ return
281
+
282
+ if self.objective == 'hybrid':
283
+ self.log_lines = [
284
+ f'prompt_tokens={len(prompt_ids)} temp={temperature} '
285
+ f'steps/slot={steps_per_block} rep_penalty={repetition_penalty} '
286
+ f'top_p={top_p} max_slots={self.max_slots} strategy={strategy} '
287
+ f'flex_eager={_FLEX_EAGER}'
288
+ ]
289
+ sequence = [*prompt_ids, think_id]
290
+ think_ids: list[int] = []
291
+ think_compute = 0.0
292
+ slot_budget = self.model.config.max_seq_len - self.block - 8
293
+ problem_tensor = torch.tensor([len(prompt_ids)], device=self.device)
294
+ for slot_index in range(self.max_slots):
295
+ if len(sequence) > slot_budget:
296
+ break
297
+ slot_wall_start = time.perf_counter()
298
+ window = torch.tensor(
299
+ [sequence + [mask_id] * self.block], dtype=torch.long, device=self.device
300
+ )
301
+ blocked = slot_causal_blocked(
302
+ problem_tensor,
303
+ torch.tensor([slot_index + 1], device=self.device),
304
+ self.block,
305
+ window.shape[1],
306
+ )
307
+ slot: list[int] = []
308
+ tick = time.perf_counter()
309
+ for state in iterative_unmask_steps(
310
+ self.model,
311
+ window,
312
+ mask_id,
313
+ steps=steps_per_block,
314
+ temperature=temperature,
315
+ strategy=strategy,
316
+ attn_mask=blocked,
317
+ generator=generator,
318
+ ):
319
+ think_compute += time.perf_counter() - tick
320
+ slot = [int(t) for t in state.tokens[0, len(sequence):]]
321
+ revealed = len(think_ids) + self.block - state.masked_remaining
322
+ rate = revealed / think_compute if think_compute > 0 else 0.0
323
+ yield (
324
+ self._slot_texts(think_ids)
325
+ + [self._active_slot_text(slot)],
326
+ '',
327
+ f'denoising slot {slot_index + 1} · paso '
328
+ f'{state.step}/{state.total_steps} · {rate:.0f} tok/s',
329
+ )
330
+ if step_delay and state.step < state.total_steps:
331
+ time.sleep(step_delay)
332
+ tick = time.perf_counter()
333
+ sequence.extend(slot)
334
+ think_ids.extend(slot)
335
+ slot_content = [t for t in slot if t not in (
336
+ self.reasoning_ids['thought_pad'], end_think_id
337
+ )]
338
+ self.log_lines.append(
339
+ f' T{slot_index + 1}: {len(slot_content)} tok · '
340
+ f'{time.perf_counter() - slot_wall_start:.2f}s wall'
341
+ + (' · </think>' if end_think_id in slot else '')
342
+ )
343
+ if end_think_id in slot:
344
+ break
345
+ else:
346
+ sequence.extend(
347
+ [end_think_id] + [self.reasoning_ids['thought_pad']] * (self.block - 1)
348
+ )
349
+
350
+ answer_ids: list[int] = []
351
+ prefix_len = len(sequence)
352
+ answer_compute = 0.0
353
+ tick = time.perf_counter()
354
+ for _ in range(max_answer_tokens):
355
+ current = torch.tensor(
356
+ [sequence + answer_ids], dtype=torch.long, device=self.device
357
+ )
358
+ blocked = prefix_causal_blocked(
359
+ torch.tensor([prefix_len], device=self.device), current.shape[1]
360
+ )
361
+ output_positions = torch.zeros_like(current, dtype=torch.bool)
362
+ output_positions[0, -1] = True
363
+ logits = self.model(
364
+ current, output_positions=output_positions, attn_mask=blocked
365
+ )
366
+ logits = _apply_repetition_penalty(logits, answer_ids, repetition_penalty)
367
+ logits = _apply_top_p(logits, top_p)
368
+ token, _ = _sample_categorical(logits, temperature, generator)
369
+ token_id = int(token.item())
370
+ answer_ids.append(token_id)
371
+ answer_compute += time.perf_counter() - tick
372
+ if step_delay or len(answer_ids) % 4 == 0 or token_id == eos_id:
373
+ rate = len(answer_ids) / answer_compute if answer_compute > 0 else 0.0
374
+ yield (
375
+ self._slot_texts(think_ids),
376
+ self._decode(answer_ids),
377
+ f'respondiendo (AR, token {len(answer_ids)}) · {rate:.0f} tok/s',
378
+ )
379
+ if step_delay and token_id != eos_id:
380
+ time.sleep(step_delay / 3)
381
+ if token_id == eos_id:
382
+ break
383
+ tick = time.perf_counter()
384
+ think_tokens = len(think_ids)
385
+ answer_tokens = len(answer_ids)
386
+ total_compute = think_compute + answer_compute
387
+ think_rate = think_tokens / think_compute if think_compute > 0 else 0.0
388
+ answer_rate = answer_tokens / answer_compute if answer_compute > 0 else 0.0
389
+ total_rate = (
390
+ (think_tokens + answer_tokens) / total_compute if total_compute > 0 else 0.0
391
+ )
392
+ status = (
393
+ f'listo · pensar: {think_tokens} tok en {think_compute:.2f}s '
394
+ f'({think_rate:.0f} tok/s) · responder: {answer_tokens} tok en '
395
+ f'{answer_compute:.2f}s ({answer_rate:.0f} tok/s) · total: '
396
+ f'{think_tokens + answer_tokens} tok en {total_compute:.2f}s '
397
+ f'({total_rate:.0f} tok/s)'
398
+ )
399
+ self.log_lines.append(
400
+ f' answer: {answer_tokens} tok · {answer_compute:.2f}s wall '
401
+ f'({answer_rate:.0f} tok/s) · terminated={end_think_id in think_ids}'
402
+ )
403
+ self.log_lines.append(
404
+ f'TOTAL: {think_tokens + answer_tokens} tok · {total_compute:.2f}s '
405
+ f'({total_rate:.0f} tok/s)'
406
+ )
407
+ yield self._slot_texts(think_ids), self._decode(answer_ids), status
408
+ return
409
+
410
+ if self.objective == 'lm':
411
+ # Plain causal continuation for pretraining checkpoints: no think token,
412
+ # no masking; the prompt is simply extended left to right.
413
+ sequence = list(prompt_ids)
414
+ generated: list[int] = []
415
+ prefix_len = len(sequence)
416
+ budget = self.model.config.max_seq_len - prefix_len - 1
417
+ compute = 0.0
418
+ tick = time.perf_counter()
419
+ for _ in range(min(budget, 384)):
420
+ current = torch.tensor(
421
+ [sequence + generated], dtype=torch.long, device=self.device
422
+ )
423
+ blocked = prefix_causal_blocked(
424
+ torch.tensor([prefix_len], device=self.device), current.shape[1]
425
+ )
426
+ output_positions = torch.zeros_like(current, dtype=torch.bool)
427
+ output_positions[0, -1] = True
428
+ logits = self.model(
429
+ current, output_positions=output_positions, attn_mask=blocked
430
+ )
431
+ token, _ = _sample_categorical(logits, temperature, generator)
432
+ token_id = int(token.item())
433
+ generated.append(token_id)
434
+ compute += time.perf_counter() - tick
435
+ if step_delay or token_id == eos_id or len(generated) % 8 == 0:
436
+ rate = len(generated) / compute if compute > 0 else 0.0
437
+ yield (
438
+ [],
439
+ self._decode(generated),
440
+ f'continuando (token {len(generated)}) · {rate:.0f} tok/s',
441
+ )
442
+ if step_delay and token_id != eos_id:
443
+ time.sleep(step_delay / 3)
444
+ if token_id == eos_id:
445
+ break
446
+ tick = time.perf_counter()
447
+ rate = len(generated) / compute if compute > 0 else 0.0
448
+ yield [], self._decode(generated), (
449
+ f'listo · total: {len(generated)} tok en {compute:.2f}s ({rate:.0f} tok/s) '
450
+ f'(checkpoint base: continua texto, no piensa)'
451
+ )
452
+ return
453
+
454
+ if self.objective == 'ar':
455
+ sequence = [*prompt_ids, think_id]
456
+ generated: list[int] = []
457
+ prefix_len = len(sequence)
458
+ budget = self.model.config.max_seq_len - prefix_len - 1
459
+ compute = 0.0
460
+ tick = time.perf_counter()
461
+ for _ in range(min(budget, 384)):
462
+ current = torch.tensor(
463
+ [sequence + generated], dtype=torch.long, device=self.device
464
+ )
465
+ blocked = prefix_causal_blocked(
466
+ torch.tensor([prefix_len], device=self.device), current.shape[1]
467
+ )
468
+ output_positions = torch.zeros_like(current, dtype=torch.bool)
469
+ output_positions[0, -1] = True
470
+ logits = self.model(
471
+ current, output_positions=output_positions, attn_mask=blocked
472
+ )
473
+ token, _ = _sample_categorical(logits, temperature, generator)
474
+ token_id = int(token.item())
475
+ generated.append(token_id)
476
+ compute += time.perf_counter() - tick
477
+ if step_delay or token_id == eos_id or len(generated) % 8 == 0:
478
+ thoughts, answer = self._split_flat(generated)
479
+ rate = len(generated) / compute if compute > 0 else 0.0
480
+ yield thoughts, answer, (
481
+ f'generando (token {len(generated)}) · {rate:.0f} tok/s'
482
+ )
483
+ if step_delay and token_id != eos_id:
484
+ time.sleep(step_delay / 3)
485
+ if token_id == eos_id:
486
+ break
487
+ tick = time.perf_counter()
488
+ thoughts, answer = self._split_flat(generated)
489
+ rate = len(generated) / compute if compute > 0 else 0.0
490
+ yield thoughts, answer, (
491
+ f'listo · total: {len(generated)} tok en {compute:.2f}s ({rate:.0f} tok/s)'
492
+ )
493
+ return
494
+
495
+ budget = min(352, self.model.config.max_seq_len - len(prompt_ids) - 1)
496
+ sequence_tensor = torch.tensor(
497
+ [[*prompt_ids, think_id] + [mask_id] * budget], dtype=torch.long, device=self.device
498
+ )
499
+ final_ids: list[int] = []
500
+ compute = 0.0
501
+ tick = time.perf_counter()
502
+ for state in iterative_unmask_steps(
503
+ self.model,
504
+ sequence_tensor,
505
+ mask_id,
506
+ steps=diffusion_steps,
507
+ temperature=temperature,
508
+ strategy=strategy,
509
+ blocked_token_ids=(self.roles['pad'],),
510
+ generator=generator,
511
+ ):
512
+ compute += time.perf_counter() - tick
513
+ final_ids = [int(t) for t in state.tokens[0, len(prompt_ids) + 1:]]
514
+ if step_delay or state.step % 8 == 0 or state.masked_remaining == 0:
515
+ if step_delay:
516
+ thoughts = [self._active_slot_text(final_ids)]
517
+ answer = ''
518
+ else:
519
+ visible = [t for t in final_ids if t != mask_id]
520
+ thoughts, answer = self._split_flat(visible)
521
+ revealed = len(final_ids) - state.masked_remaining
522
+ rate = revealed / compute if compute > 0 else 0.0
523
+ yield (
524
+ thoughts,
525
+ answer,
526
+ f'denoising {state.step}/{state.total_steps} · {rate:.0f} tok/s',
527
+ )
528
+ if step_delay and state.masked_remaining:
529
+ time.sleep(step_delay)
530
+ tick = time.perf_counter()
531
+ if eos_id in final_ids:
532
+ final_ids = final_ids[: final_ids.index(eos_id) + 1]
533
+ thoughts, answer = self._split_flat(final_ids)
534
+ revealed = len(final_ids)
535
+ rate = revealed / compute if compute > 0 else 0.0
536
+ yield thoughts, answer, (
537
+ f'listo · total: {revealed} tok en {compute:.2f}s ({rate:.0f} tok/s)'
538
+ )
539
+
540
+ @torch.no_grad()
541
+ def stream_chat(
542
+ self,
543
+ prefix_text: str,
544
+ *,
545
+ temperature: float,
546
+ steps_per_block: int,
547
+ max_answer_tokens: int = 224,
548
+ repetition_penalty: float = 1.0,
549
+ top_p: float = 1.0,
550
+ seed: int = 0,
551
+ strategy: str = 'ancestral',
552
+ step_delay: float = 0.0,
553
+ blocks_out: list[tuple[int, str]] | None = None,
554
+ ):
555
+ """Yield ``(thoughts, answer, status)`` snapshots for one ChatML assistant turn.
556
+
557
+ ``prefix_text`` is the rendered conversation ending right after the assistant
558
+ header, encoded verbatim: training saw the trailing newline, so no stripping.
559
+ The answer stops at ``<|im_end|>`` as well as the eos token. ``blocks_out``
560
+ receives the turn's ``(size, content)`` blocks; a per-call sink rather than
561
+ engine state, so concurrent runs on the same engine cannot leak into each other.
562
+ """
563
+
564
+ if not self.chat_ready:
565
+ raise ValueError('checkpoint is not an adaptive hybrid over a ChatML tokenizer')
566
+ generator = torch.Generator(device=self.device.type)
567
+ generator.manual_seed(seed if seed else int(time.time_ns() % 2**31))
568
+ prompt_ids = self.tokenizer.encode(prefix_text, add_special_tokens=False).ids
569
+ if len(prompt_ids) >= self.model.config.max_seq_len - 48:
570
+ message = (
571
+ f'contexto lleno: prefijo de {len(prompt_ids)} tokens con ventana de '
572
+ f'{self.model.config.max_seq_len} — bajá los mensajes visibles o reiniciá'
573
+ )
574
+ self.log_lines = [message]
575
+ yield [], '', message
576
+ return
577
+ yield from self._stream_adaptive(
578
+ prompt_ids,
579
+ stop_ids=(self.im_end_id, self.roles['eos']),
580
+ temperature=temperature,
581
+ steps_per_block=steps_per_block,
582
+ max_answer_tokens=max_answer_tokens,
583
+ repetition_penalty=repetition_penalty,
584
+ top_p=top_p,
585
+ strategy=strategy,
586
+ step_delay=step_delay,
587
+ generator=generator,
588
+ blocks_out=blocks_out,
589
+ )
590
+
591
+ @torch.no_grad()
592
+ def _stream_adaptive(
593
+ self,
594
+ prompt_ids: list[int],
595
+ *,
596
+ stop_ids: tuple[int, ...],
597
+ temperature: float,
598
+ steps_per_block: int,
599
+ max_answer_tokens: int,
600
+ repetition_penalty: float,
601
+ top_p: float,
602
+ strategy: str,
603
+ step_delay: float,
604
+ generator: torch.Generator,
605
+ blocks_out: list[tuple[int, str]] | None = None,
606
+ ):
607
+ """Stream the adaptive control/denoise/answer loop shared by solve and chat.
608
+
609
+ The answer phase reuses the prefix through the model's key/value cache when the
610
+ backbone provides one and ``MDLM_KV_CACHE`` selects the ar part, matching the
611
+ batch decoder in :mod:`diffusion_lm.hybrid`.
612
+ """
613
+
614
+ mask_id = self.model.config.mask_token_id
615
+ think_id = self.reasoning_ids['think']
616
+ end_think_id = self.reasoning_ids['end_think']
617
+ size_by_id = {token_id: size for size, token_id in self.size_ids.items()}
618
+ size_ids_tensor = torch.tensor(sorted(self.size_ids.values()), device=self.device)
619
+ control_ids = [*size_by_id.keys(), end_think_id]
620
+ cached = hasattr(self.model, 'forward_cached') and _kv_cache_enabled('ar')
621
+ self.log_lines = [
622
+ f'prompt_tokens={len(prompt_ids)} temp={temperature} '
623
+ f'steps/block={steps_per_block} rep_penalty={repetition_penalty} '
624
+ f'top_p={top_p} max_blocks={self.max_blocks} '
625
+ f'sizes={sorted(size_by_id.values())} strategy={strategy} '
626
+ f'flex_eager={_FLEX_EAGER} kv_cache={"ar" if cached else "off"}'
627
+ ]
628
+ sequence = [*prompt_ids, think_id]
629
+ prefix_len = len(prompt_ids) + 1
630
+ problem_tensor = torch.tensor([len(prompt_ids)], device=self.device)
631
+ think_compute = 0.0
632
+ terminated = False
633
+ for block_index in range(self.max_blocks):
634
+ tick = time.perf_counter()
635
+ control = _ar_predict_control(
636
+ self.model,
637
+ sequence,
638
+ control_ids,
639
+ prefix_len=prefix_len,
640
+ temperature=0.0,
641
+ device=self.device,
642
+ generator=generator,
643
+ causal_prefix=self.causal_prefix,
644
+ )
645
+ think_compute += time.perf_counter() - tick
646
+ if control == end_think_id:
647
+ terminated = True
648
+ self.log_lines.append(f' control {block_index + 1}: </think>')
649
+ break
650
+ size = size_by_id[control]
651
+ if len(sequence) + 1 + size > self.model.config.max_seq_len - 8:
652
+ self.log_lines.append(
653
+ f' control {block_index + 1}: <sz{size}> · sin contexto, corto acá'
654
+ )
655
+ break
656
+ sequence.append(control)
657
+ window_prefix = len(sequence)
658
+ block_wall_start = time.perf_counter()
659
+ window = torch.tensor(
660
+ [sequence + [mask_id] * size], dtype=torch.long, device=self.device
661
+ )
662
+ blocked = adaptive_block_mask(
663
+ window,
664
+ problem_tensor,
665
+ torch.tensor([window.shape[1]], device=self.device),
666
+ size_ids_tensor,
667
+ end_think_id,
668
+ causal_prefix=self.causal_prefix,
669
+ )
670
+ block: list[int] = []
671
+ tick = time.perf_counter()
672
+ for state in iterative_unmask_steps(
673
+ self.model,
674
+ window,
675
+ mask_id,
676
+ steps=steps_per_block,
677
+ temperature=temperature,
678
+ strategy=strategy,
679
+ attn_mask=blocked,
680
+ generator=generator,
681
+ ):
682
+ think_compute += time.perf_counter() - tick
683
+ block = [int(t) for t in state.tokens[0, window_prefix:]]
684
+ yield (
685
+ self._adaptive_block_texts(sequence[prefix_len:])
686
+ + [f'({size}) ' + self._active_slot_text(block)],
687
+ '',
688
+ f'denoising bloque {block_index + 1} (tamaño {size}) · paso '
689
+ f'{state.step}/{state.total_steps}',
690
+ )
691
+ if step_delay and state.step < state.total_steps:
692
+ time.sleep(step_delay)
693
+ tick = time.perf_counter()
694
+ sequence.extend(block)
695
+ self.log_lines.append(
696
+ f' B{block_index + 1}: <sz{size}> · '
697
+ f'{time.perf_counter() - block_wall_start:.2f}s wall'
698
+ )
699
+ sequence.append(end_think_id)
700
+ think_ids = sequence[prefix_len:]
701
+ if blocks_out is not None:
702
+ blocks_out.extend(self._block_contents(think_ids))
703
+
704
+ answer_ids: list[int] = []
705
+ answer_compute = 0.0
706
+ prefix_tensor = torch.tensor([prefix_len], device=self.device)
707
+ answer_budget = min(
708
+ max_answer_tokens, self.model.config.max_seq_len - len(sequence)
709
+ )
710
+ cache = self.model.new_cache() if cached else None
711
+ step_in = torch.tensor([sequence], dtype=torch.long, device=self.device)
712
+ tick = time.perf_counter()
713
+ for _ in range(max(0, answer_budget)):
714
+ if cached:
715
+ seen = cache.get_seq_length()
716
+ blocked = prefix_causal_blocked(
717
+ prefix_tensor, seen + step_in.shape[1], causal_prefix=self.causal_prefix
718
+ )[:, seen:, :]
719
+ output_positions = torch.zeros_like(step_in, dtype=torch.bool)
720
+ output_positions[0, -1] = True
721
+ logits, cache = self.model.forward_cached(
722
+ step_in, attn_mask=blocked, past_key_values=cache,
723
+ output_positions=output_positions,
724
+ )
725
+ else:
726
+ current = torch.tensor(
727
+ [sequence + answer_ids], dtype=torch.long, device=self.device
728
+ )
729
+ blocked = prefix_causal_blocked(
730
+ prefix_tensor, current.shape[1], causal_prefix=self.causal_prefix
731
+ )
732
+ output_positions = torch.zeros_like(current, dtype=torch.bool)
733
+ output_positions[0, -1] = True
734
+ logits = self.model(
735
+ current, output_positions=output_positions, attn_mask=blocked
736
+ )
737
+ logits = _apply_repetition_penalty(logits, answer_ids, repetition_penalty)
738
+ logits = _apply_top_p(logits, top_p)
739
+ token, _ = _sample_categorical(logits, temperature, generator)
740
+ token_id = int(token.item())
741
+ answer_ids.append(token_id)
742
+ step_in = torch.tensor([[token_id]], dtype=torch.long, device=self.device)
743
+ answer_compute += time.perf_counter() - tick
744
+ done = token_id in stop_ids
745
+ if step_delay or len(answer_ids) % 4 == 0 or done:
746
+ rate = len(answer_ids) / answer_compute if answer_compute > 0 else 0.0
747
+ yield (
748
+ self._adaptive_block_texts(think_ids),
749
+ self._decode(answer_ids),
750
+ f'respondiendo (AR, token {len(answer_ids)}) · {rate:.0f} tok/s',
751
+ )
752
+ if step_delay and not done:
753
+ time.sleep(step_delay / 3)
754
+ if done:
755
+ break
756
+ tick = time.perf_counter()
757
+
758
+ sizes_chosen = [
759
+ size_by_id[t] for t in think_ids if t in size_by_id
760
+ ]
761
+ total_compute = think_compute + answer_compute
762
+ status = (
763
+ f'listo · bloques: {sizes_chosen} · terminated={terminated} · '
764
+ f'pensar {think_compute:.2f}s · responder {len(answer_ids)} tok en '
765
+ f'{answer_compute:.2f}s · total {total_compute:.2f}s'
766
+ )
767
+ self.log_lines.append(
768
+ f' answer: {len(answer_ids)} tok · {answer_compute:.2f}s wall'
769
+ )
770
+ self.log_lines.append(
771
+ f'TOTAL: sizes={sizes_chosen} terminated={terminated} · '
772
+ f'{total_compute:.2f}s'
773
+ )
774
+ yield self._adaptive_block_texts(think_ids), self._decode(answer_ids), status
775
+
776
+ def _split_flat(self, generated: list[int]) -> tuple[list[str], str]:
777
+ end_think_id = self.reasoning_ids['end_think']
778
+ if end_think_id in generated:
779
+ split = generated.index(end_think_id)
780
+ think, answer = generated[:split], generated[split + 1:]
781
+ else:
782
+ think, answer = generated, []
783
+ return [self._decode(think).strip()], self._decode(answer)
784
+
785
+
786
+ class HFCausalEngine:
787
+ """Serve a Hugging Face causal LM behind the same streaming interface.
788
+
789
+ Thinking-mode outputs (Qwen3-style ``<think>...</think>`` prefixes) are routed to the
790
+ thoughts panel; tokens after the closing tag stream as the answer. Models without a
791
+ ``</think>`` vocabulary entry stream everything as the answer.
792
+ """
793
+
794
+ def __init__(self, model_path: str, device: torch.device) -> None:
795
+ from transformers import AutoModelForCausalLM, AutoTokenizer
796
+
797
+ self.tokenizer = AutoTokenizer.from_pretrained(model_path)
798
+ dtype = torch.bfloat16 if device.type == 'cuda' else torch.float32
799
+ self.model = AutoModelForCausalLM.from_pretrained(model_path, dtype=dtype)
800
+ self.model.to(device).eval()
801
+ self.device = device
802
+ self.objective = 'hf-ar'
803
+ self.step = '-'
804
+ eos = self.model.generation_config.eos_token_id
805
+ self.eos_ids = set(eos if isinstance(eos, (list, tuple)) else [eos])
806
+ end_think = self.tokenizer.convert_tokens_to_ids('</think>')
807
+ unk = self.tokenizer.unk_token_id
808
+ self.end_think_id = end_think if isinstance(end_think, int) and end_think != unk else None
809
+ self.max_new_tokens = 1024
810
+ self.log_lines: list[str] = []
811
+
812
+ def _decode(self, ids: list[int]) -> str:
813
+ # Qwen3 registers <think>/</think> as regular added tokens, so
814
+ # skip_special_tokens leaves them in the decoded text.
815
+ text = self.tokenizer.decode(ids, skip_special_tokens=True)
816
+ return text.replace('<think>', '').replace('</think>', '').strip()
817
+
818
+ def _split(self, generated: list[int], split_at: int | None) -> tuple[list[str], str]:
819
+ if self.end_think_id is None:
820
+ return [], self._decode(generated)
821
+ if split_at is None:
822
+ thoughts = self._decode(generated)
823
+ return ([thoughts] if thoughts else []), ''
824
+ thoughts = self._decode(generated[: split_at - 1])
825
+ return ([thoughts] if thoughts else []), self._decode(generated[split_at:])
826
+
827
+ @torch.no_grad()
828
+ def stream_solve(
829
+ self,
830
+ problem: str,
831
+ *,
832
+ temperature: float,
833
+ steps_per_block: int,
834
+ diffusion_steps: int,
835
+ max_answer_tokens: int = 200,
836
+ repetition_penalty: float = 1.0,
837
+ top_p: float = 1.0,
838
+ seed: int = 0,
839
+ strategy: str = 'ancestral',
840
+ step_delay: float = 0.0,
841
+ ):
842
+ """Yield ``(thoughts, answer, status)`` snapshots while decoding token by token.
843
+
844
+ Diffusion-only knobs (``steps_per_block``, ``diffusion_steps``, ``strategy``) are
845
+ accepted for interface parity and ignored.
846
+ """
847
+
848
+ del steps_per_block, diffusion_steps, strategy, max_answer_tokens
849
+ generator = torch.Generator(device=self.device.type)
850
+ generator.manual_seed(seed if seed else int(time.time_ns() % 2**31))
851
+ prompt = self.tokenizer.apply_chat_template(
852
+ [{'role': 'user', 'content': problem.strip()}],
853
+ tokenize=False,
854
+ add_generation_prompt=True,
855
+ )
856
+ input_ids = self.tokenizer(prompt, return_tensors='pt').input_ids.to(self.device)
857
+ self.log_lines = [
858
+ f'hf-ar · prompt_tokens={input_ids.shape[1]} temp={temperature} top_p={top_p} '
859
+ f'rep_penalty={repetition_penalty} max_new_tokens={self.max_new_tokens} '
860
+ f'(Qwen3 sugerido: temp 0.6 · top_p 0.95 · rep 1.0)'
861
+ ]
862
+ generated: list[int] = []
863
+ past_key_values = None
864
+ current = input_ids
865
+ split_at: int | None = None
866
+ compute = 0.0
867
+ tick = time.perf_counter()
868
+ for _ in range(self.max_new_tokens):
869
+ output = self.model(input_ids=current, past_key_values=past_key_values, use_cache=True)
870
+ past_key_values = output.past_key_values
871
+ logits = output.logits[:, -1, :].float()
872
+ logits = _apply_repetition_penalty(logits, generated, repetition_penalty)
873
+ logits = _apply_top_p(logits, top_p)
874
+ token, _ = _sample_categorical(logits, temperature, generator)
875
+ token_id = int(token.item())
876
+ generated.append(token_id)
877
+ compute += time.perf_counter() - tick
878
+ if split_at is None and token_id == self.end_think_id:
879
+ split_at = len(generated)
880
+ self.log_lines.append(f' </think> en token {split_at}')
881
+ done = token_id in self.eos_ids
882
+ if step_delay or done or len(generated) % 4 == 0:
883
+ thoughts, answer = self._split(generated, split_at)
884
+ rate = len(generated) / compute if compute > 0 else 0.0
885
+ thinking = self.end_think_id is not None and split_at is None
886
+ phase = 'pensando' if thinking else 'respondiendo'
887
+ yield thoughts, answer, f'{phase} (AR, token {len(generated)}) · {rate:.0f} tok/s'
888
+ if step_delay and not done:
889
+ time.sleep(step_delay / 3)
890
+ if done:
891
+ break
892
+ current = token.view(1, 1)
893
+ tick = time.perf_counter()
894
+ thoughts, answer = self._split(generated, split_at)
895
+ rate = len(generated) / compute if compute > 0 else 0.0
896
+ terminated = bool(generated) and generated[-1] in self.eos_ids
897
+ self.log_lines.append(
898
+ f'TOTAL: {len(generated)} tok · {compute:.2f}s ({rate:.0f} tok/s) · '
899
+ f'terminated={terminated}'
900
+ )
901
+ yield thoughts, answer, (
902
+ f'listo · total: {len(generated)} tok en {compute:.2f}s ({rate:.0f} tok/s) · '
903
+ f'terminated={terminated}'
904
+ )
905
+
906
+
907
+ def _render_thoughts(slots: list[str]) -> str:
908
+ if not slots:
909
+ return '<em>sin pensamientos todavía</em>'
910
+ chips = []
911
+ for index, text in enumerate(slots, start=1):
912
+ chips.append(
913
+ f'<div style="margin:6px 0;padding:8px 12px;border-radius:10px;'
914
+ f'background:rgba(124,58,237,.10);border:1px solid rgba(124,58,237,.25);">'
915
+ f'<b>T{index}</b> · {html.escape(text)}</div>'
916
+ )
917
+ return ''.join(chips)
918
+
919
+
920
+ def _chat_display(messages: list[dict[str, str]], partial: str = '') -> list[dict[str, str]]:
921
+ display = [{'role': m['role'], 'content': m['content']} for m in messages]
922
+ if partial:
923
+ display.append({'role': 'assistant', 'content': partial})
924
+ return display
925
+
926
+
927
+ def _chat_ledger(messages: list[dict[str, str]], keep: int) -> str:
928
+ return ledger_line(merge_notes(ledger_notes(messages, keep)))
929
+
930
+
931
+ def build_app(engines: dict[str, 'ReasoningEngine | HFCausalEngine']):
932
+ import gradio as gr
933
+
934
+ first = next(iter(engines))
935
+
936
+ def solve(
937
+ checkpoint_name, problem, temperature, steps_per_block, diffusion_steps,
938
+ repetition_penalty, top_p, seed, strategy, slowmo_ms,
939
+ ):
940
+ engine = engines[checkpoint_name]
941
+ if not problem.strip():
942
+ yield '<em>escribí un problema primero</em>', '', 'esperando problema', ''
943
+ return
944
+ for slots, answer, status in engine.stream_solve(
945
+ problem,
946
+ temperature=float(temperature),
947
+ steps_per_block=int(steps_per_block),
948
+ diffusion_steps=int(diffusion_steps),
949
+ repetition_penalty=float(repetition_penalty),
950
+ top_p=float(top_p),
951
+ seed=int(seed),
952
+ strategy=str(strategy),
953
+ step_delay=float(slowmo_ms) / 1000.0,
954
+ ):
955
+ boxed = extract_boxed_answer(answer or '')
956
+ answer_display = answer + (f'\n\n**→ respuesta extraída: {boxed}**' if boxed else '')
957
+ yield _render_thoughts(slots), answer_display, status, '\n'.join(engine.log_lines)
958
+
959
+ def chat_send(
960
+ checkpoint_name, user_text, messages, system_text, keep_last, temperature,
961
+ steps_per_block, repetition_penalty, top_p, max_answer_tokens, seed, slowmo_ms,
962
+ datalog,
963
+ ):
964
+ engine = engines[checkpoint_name] if checkpoint_name in engines else None
965
+ messages = list(messages or [])
966
+ datalog = list(datalog or [])
967
+ keep = int(keep_last)
968
+ idle = _chat_display(messages), messages, '<em>sin pensamientos todavía</em>'
969
+ if engine is None or not getattr(engine, 'chat_ready', False):
970
+ yield (*idle, _chat_ledger(messages, keep),
971
+ 'elegí un checkpoint adaptativo con tokenizer ChatML', '', gr.skip(),
972
+ datalog, gr.skip())
973
+ return
974
+ user_text = (user_text or '').strip()
975
+ if not user_text:
976
+ yield (*idle, _chat_ledger(messages, keep), 'escribí un mensaje primero',
977
+ '', gr.skip(), datalog, gr.skip())
978
+ return
979
+ messages.append({'role': 'user', 'content': user_text})
980
+ notes = ledger_notes(messages, keep)
981
+ merged = merge_notes(notes)
982
+ ledger = ledger_line(merged)
983
+ start = window_start(messages, keep)
984
+ system = (system_text or '').strip() or SYSTEM
985
+ prefix = chat_prefix(
986
+ [{'role': m['role'], 'content': m['content']} for m in messages[start:]],
987
+ system=system,
988
+ extra=ledger,
989
+ )
990
+ history_snapshot = [
991
+ {'index': index, 'visible': index >= start, **message}
992
+ for index, message in enumerate(messages)
993
+ ]
994
+ answer = ''
995
+ status = ''
996
+ blocks: list[tuple[int, str]] = []
997
+ thoughts_html = '<em>sin pensamientos todavía</em>'
998
+ for slots, answer, status in engine.stream_chat(
999
+ prefix,
1000
+ temperature=float(temperature),
1001
+ steps_per_block=int(steps_per_block),
1002
+ max_answer_tokens=int(max_answer_tokens),
1003
+ repetition_penalty=float(repetition_penalty),
1004
+ top_p=float(top_p),
1005
+ seed=int(seed),
1006
+ step_delay=float(slowmo_ms) / 1000.0,
1007
+ blocks_out=blocks,
1008
+ ):
1009
+ thoughts_html = _render_thoughts(slots)
1010
+ yield (_chat_display(messages, answer), messages, thoughts_html, ledger,
1011
+ status, '\n'.join(engine.log_lines), '', datalog, gr.skip())
1012
+ note = '; '.join(text for _, text in blocks if text)
1013
+ if answer.strip() or note:
1014
+ messages.append({'role': 'assistant', 'content': answer.strip(), 'note': note})
1015
+ restored_input = ''
1016
+ else:
1017
+ # The turn produced nothing (context-full guard): undo the user message and
1018
+ # put its text back in the box so it can be resent after lowering the window.
1019
+ messages.pop()
1020
+ restored_input = user_text
1021
+ datalog.append({
1022
+ 'turn': sum(1 for m in messages if m['role'] == 'user') + bool(restored_input),
1023
+ 'checkpoint': checkpoint_name,
1024
+ 'settings': {
1025
+ 'temperature': float(temperature), 'steps_per_block': int(steps_per_block),
1026
+ 'repetition_penalty': float(repetition_penalty), 'top_p': float(top_p),
1027
+ 'max_answer_tokens': int(max_answer_tokens), 'seed': int(seed),
1028
+ 'keep_last': keep, 'system': system,
1029
+ },
1030
+ 'history_at_send': history_snapshot,
1031
+ 'ledger': {'notes': notes, 'merged': merged, 'line': ledger},
1032
+ 'prefix_sent': prefix,
1033
+ 'prefix_tokens': len(
1034
+ engine.tokenizer.encode(prefix, add_special_tokens=False).ids
1035
+ ),
1036
+ 'thinking_blocks': [
1037
+ {'size': size, 'content': text} for size, text in blocks
1038
+ ],
1039
+ 'answer': answer.strip(),
1040
+ 'status': status,
1041
+ 'engine_log': list(engine.log_lines),
1042
+ })
1043
+ yield (_chat_display(messages), messages, thoughts_html,
1044
+ _chat_ledger(messages, keep), status, '\n'.join(engine.log_lines),
1045
+ restored_input, datalog,
1046
+ json.dumps(datalog, indent=2, ensure_ascii=False))
1047
+
1048
+ def chat_reset():
1049
+ return ([], [], '<em>sin pensamientos todavía</em>', '', 'conversación reiniciada',
1050
+ '', '', [], '')
1051
+
1052
+ chat_names = [name for name, engine in engines.items()
1053
+ if getattr(engine, 'chat_ready', False)]
1054
+ chat_default = next((n for n in chat_names if 'chat' in n), chat_names[0] if chat_names else None)
1055
+
1056
+ with gr.Blocks(title='Reasoning playground') as app:
1057
+ names = {
1058
+ name: f'{name} · {engines[name].objective} · step {engines[name].step}'
1059
+ for name in engines
1060
+ }
1061
+ gr.Markdown('# Reasoning playground')
1062
+ with gr.Tab('chat'):
1063
+ chat_state = gr.State([])
1064
+ chat_datalog_state = gr.State([])
1065
+ with gr.Row():
1066
+ with gr.Column(scale=2):
1067
+ chat_checkpoint = gr.Dropdown(
1068
+ choices=chat_names, value=chat_default, label='checkpoint',
1069
+ info='adaptativos sobre tokenizer ChatML; solo los entrenados '
1070
+ 'en chat (chat-sft) conocen este formato',
1071
+ )
1072
+ chat_system = gr.Textbox(label='system', value=SYSTEM, lines=3)
1073
+ chat_keep = gr.Slider(
1074
+ 0, 12, value=4, step=2,
1075
+ label='mensajes visibles (0 = historial completo; lo anterior '
1076
+ 'sobrevive solo en el ledger)',
1077
+ )
1078
+ chat_temperature = gr.Slider(0.0, 1.2, value=0.8, step=0.05,
1079
+ label='temperatura')
1080
+ chat_steps = gr.Slider(
1081
+ 1, 64, value=16, step=1,
1082
+ label='pasos de denoising por bloque (32 es el óptimo medido)',
1083
+ )
1084
+ chat_rep = gr.Slider(1.0, 2.0, value=1.4, step=0.05,
1085
+ label='penalización de repetición (respuesta)')
1086
+ chat_top_p = gr.Slider(0.1, 1.0, value=0.92, step=0.02,
1087
+ label='top-p (respuesta)')
1088
+ chat_max_answer = gr.Slider(32, 512, value=224, step=16,
1089
+ label='tokens máximos de respuesta')
1090
+ chat_slowmo = gr.Slider(0, 600, value=0, step=20,
1091
+ label='cámara lenta (ms por paso de denoising)')
1092
+ chat_seed = gr.Number(value=0, label='seed (0 = aleatoria)', precision=0)
1093
+ chat_clear = gr.Button('reiniciar conversación')
1094
+ with gr.Column(scale=3):
1095
+ chatbot = gr.Chatbot(label='conversación', height=420)
1096
+ chat_input = gr.Textbox(
1097
+ label='mensaje', lines=2,
1098
+ placeholder='p.ej. My policy is PL-48291. — o cualquier pregunta',
1099
+ )
1100
+ chat_go = gr.Button('enviar', variant='primary')
1101
+ chat_status = gr.Markdown('esperando mensaje')
1102
+ chat_thoughts = gr.HTML(label='pensamientos del turno')
1103
+ chat_ledger_box = gr.Textbox(
1104
+ label='ledger (notas del modelo fuera de la ventana visible)',
1105
+ interactive=False,
1106
+ )
1107
+ chat_logs = gr.Textbox(label='logs', lines=8, max_lines=20,
1108
+ interactive=False)
1109
+ with gr.Accordion('datalog — todo lo que viajó, por turno', open=False):
1110
+ chat_datalog_box = gr.Textbox(
1111
+ label='sesión completa en JSON: settings, historial con ventana, '
1112
+ 'ledger, prefijo exacto, bloques de thinking, respuesta y logs',
1113
+ lines=18, max_lines=40, interactive=False, buttons=['copy'],
1114
+ )
1115
+ chat_inputs = [
1116
+ chat_checkpoint, chat_input, chat_state, chat_system, chat_keep,
1117
+ chat_temperature, chat_steps, chat_rep, chat_top_p, chat_max_answer,
1118
+ chat_seed, chat_slowmo, chat_datalog_state,
1119
+ ]
1120
+ chat_outputs = [
1121
+ chatbot, chat_state, chat_thoughts, chat_ledger_box, chat_status,
1122
+ chat_logs, chat_input, chat_datalog_state, chat_datalog_box,
1123
+ ]
1124
+ chat_go.click(chat_send, inputs=chat_inputs, outputs=chat_outputs)
1125
+ chat_input.submit(chat_send, inputs=chat_inputs, outputs=chat_outputs)
1126
+ chat_clear.click(chat_reset, inputs=[], outputs=chat_outputs)
1127
+ with gr.Tab('resolver'):
1128
+ with gr.Row():
1129
+ with gr.Column(scale=2):
1130
+ checkpoint = gr.Dropdown(
1131
+ choices=list(engines), value=first, label='checkpoint',
1132
+ info=' | '.join(names.values()),
1133
+ )
1134
+ problem = gr.Textbox(
1135
+ label='problema / instrucción',
1136
+ lines=4,
1137
+ placeholder=(
1138
+ 'hybrid: Write a short story. It should feature: Dialogue. '
1139
+ 'Use the words: dragon, cake, brave.\n'
1140
+ 'lm (base): cualquier texto a continuar, p.ej. '
1141
+ '"Tom was a happy boy who"'
1142
+ ),
1143
+ )
1144
+ temperature = gr.Slider(0.0, 1.2, value=0.8, step=0.05,
1145
+ label='temperatura')
1146
+ steps_per_block = gr.Slider(
1147
+ 1, 64, value=16, step=1,
1148
+ label='pasos de denoising por slot/bloque (hybrid)'
1149
+ )
1150
+ diffusion_steps = gr.Slider(
1151
+ 8, 256, value=64, step=8, label='pasos totales (diffusion)'
1152
+ )
1153
+ repetition_penalty = gr.Slider(
1154
+ 1.0, 2.0, value=1.4, step=0.05,
1155
+ label='penalización de repetición (respuesta)'
1156
+ )
1157
+ top_p = gr.Slider(
1158
+ 0.1, 1.0, value=0.92, step=0.02, label='top-p (respuesta)'
1159
+ )
1160
+ strategy = gr.Dropdown(
1161
+ choices=['ancestral', 'confidence', 'left_to_right'],
1162
+ value='ancestral',
1163
+ label='orden de revelado (denoising; ancestral es el único que rinde)',
1164
+ )
1165
+ slowmo_ms = gr.Slider(
1166
+ 0, 600, value=0, step=20,
1167
+ label='cámara lenta (ms por paso de denoising, 0 = tiempo real)',
1168
+ )
1169
+ seed = gr.Number(value=0, label='seed (0 = aleatoria)', precision=0)
1170
+ go = gr.Button('resolver', variant='primary')
1171
+ with gr.Column(scale=3):
1172
+ status = gr.Markdown('esperando problema')
1173
+ thoughts = gr.HTML(label='pensamientos')
1174
+ answer = gr.Markdown(label='respuesta')
1175
+ logs = gr.Textbox(
1176
+ label='logs (copiá y pegá)', lines=12, max_lines=30,
1177
+ interactive=False,
1178
+ )
1179
+ go.click(
1180
+ solve,
1181
+ inputs=[
1182
+ checkpoint, problem, temperature, steps_per_block, diffusion_steps,
1183
+ repetition_penalty, top_p, seed, strategy, slowmo_ms,
1184
+ ],
1185
+ outputs=[thoughts, answer, status, logs],
1186
+ )
1187
+ return app
1188
+
1189
+
1190
+ def main() -> None:
1191
+ parser = argparse.ArgumentParser(description=__doc__)
1192
+ parser.add_argument('--outputs-dir', type=Path, default=Path('outputs'))
1193
+ parser.add_argument('--prefix', default='reasoning-')
1194
+ parser.add_argument('--tokenizer', type=Path, required=True)
1195
+ parser.add_argument('--device', default='auto')
1196
+ parser.add_argument('--host', default='127.0.0.1')
1197
+ parser.add_argument('--port', type=int, default=7999)
1198
+ parser.add_argument(
1199
+ '--hf-model', action='append', default=[], metavar='NAME=PATH',
1200
+ help='serve a Hugging Face causal LM (local path or repo id) alongside the checkpoints',
1201
+ )
1202
+ args = parser.parse_args()
1203
+
1204
+ device = resolve_device(args.device)
1205
+ checkpoints = _discover_checkpoints(args.outputs_dir)
1206
+ checkpoints = {
1207
+ name: path for name, path in checkpoints.items() if name.startswith(args.prefix)
1208
+ }
1209
+ if not checkpoints and not args.hf_model:
1210
+ raise SystemExit(f'no reasoning checkpoints under {args.outputs_dir}')
1211
+ engines: dict[str, ReasoningEngine | HFCausalEngine] = {
1212
+ name: ReasoningEngine(path, args.tokenizer, device)
1213
+ for name, path in checkpoints.items()
1214
+ }
1215
+ for spec in args.hf_model:
1216
+ name, _, path = spec.partition('=')
1217
+ if not name or not path:
1218
+ raise SystemExit(f'--hf-model expects NAME=PATH, got {spec!r}')
1219
+ engines[name] = HFCausalEngine(path, device)
1220
+ print(f'loaded: {", ".join(engines)} on {device}')
1221
+ app = build_app(engines)
1222
+ app.queue().launch(server_name=args.host, server_port=args.port)
1223
+
1224
+
1225
+ if __name__ == '__main__':
1226
+ main()
src/diffusion_lm/reasoning_train.py ADDED
@@ -0,0 +1,600 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Training loop for the reasoning variants: ar, diffusion, and hybrid objectives."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import hashlib
7
+ import json
8
+ import time
9
+ from dataclasses import dataclass, asdict
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import torch
14
+ import yaml
15
+ from torch.utils.data import DataLoader
16
+
17
+ import torch.nn.functional as F
18
+
19
+ from diffusion_lm.config import ModelConfig, TrainingConfig
20
+ from diffusion_lm.data import DeterministicBatchSampler, load_packed_dataset
21
+ from diffusion_lm.diffusion import diffusion_cross_entropy
22
+ from diffusion_lm.hybrid import (
23
+ adaptive_hybrid_objective,
24
+ ar_objective,
25
+ block_diffusion_objective,
26
+ block_size_curriculum,
27
+ diffusion_objective,
28
+ hybrid_objective,
29
+ )
30
+ from diffusion_lm.model import DiffusionTransformer, build_denoiser, format_parameter_count
31
+ from diffusion_lm.reasoning import ReasoningTokenDataset
32
+ from diffusion_lm.tokenizer import load_tokenizer
33
+ from diffusion_lm.train import (
34
+ _inference_state_dict,
35
+ autocast_context,
36
+ build_optimizer,
37
+ capture_rng_state,
38
+ configure_cuda_backends,
39
+ create_grad_scaler,
40
+ learning_rate,
41
+ resolve_device,
42
+ resolve_precision,
43
+ restore_rng_state,
44
+ seed_everything,
45
+ )
46
+
47
+ REASONING_CHECKPOINT_FORMAT = 'mini-diffusion-lm-reasoning-checkpoint-v1'
48
+ OBJECTIVES = ('ar', 'diffusion', 'hybrid', 'lm', 'block_diffusion')
49
+ # Objectives trained on continuous packed text (manifest datasets, no region annotations).
50
+ PACKED_OBJECTIVES = ('lm', 'block_diffusion')
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class ReasoningConfig:
55
+ """Objective selection and hybrid-mode hyperparameters."""
56
+
57
+ objective: str
58
+ think_probability: float = 0.65
59
+ adaptive: bool = False
60
+ sizes: tuple[int, ...] = ()
61
+ causal_prefix: bool = False
62
+ curriculum_steps: int = 0
63
+ ar_probability: float = 0.0
64
+ # Fraction of think tokens corrupted in the causal samples, so the controller learns its
65
+ # decisions from damaged context instead of only from pristine traces.
66
+ control_context_noise: float = 0.0
67
+
68
+ def __post_init__(self) -> None:
69
+ if self.objective not in OBJECTIVES:
70
+ raise ValueError(f'objective must be one of {OBJECTIVES}')
71
+ if not 0.0 < self.think_probability < 1.0:
72
+ raise ValueError('think_probability must be in (0, 1)')
73
+ if self.adaptive and self.objective != 'hybrid':
74
+ raise ValueError('adaptive block sizing is only defined for the hybrid objective')
75
+ if not isinstance(self.causal_prefix, bool):
76
+ raise ValueError('causal_prefix must be a boolean')
77
+ if self.curriculum_steps < 0:
78
+ raise ValueError('curriculum_steps must be non-negative')
79
+ if not 0.0 <= self.ar_probability < 1.0:
80
+ raise ValueError('ar_probability must be in [0, 1)')
81
+ if not 0.0 <= self.control_context_noise < 1.0:
82
+ raise ValueError('control_context_noise must be in [0, 1)')
83
+ sizes = tuple(self.sizes)
84
+ if self.objective == 'block_diffusion':
85
+ if not sizes:
86
+ raise ValueError('block_diffusion requires a non-empty sizes menu')
87
+ if list(sizes) != sorted(set(sizes)) or sizes[0] <= 0:
88
+ raise ValueError('sizes must be unique, ascending, and positive')
89
+ object.__setattr__(self, 'sizes', sizes)
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class ReasoningExperiment:
94
+ model: ModelConfig
95
+ training: TrainingConfig
96
+ reasoning: ReasoningConfig
97
+
98
+ def to_dict(self) -> dict[str, Any]:
99
+ return asdict(self)
100
+
101
+
102
+ def load_reasoning_config(path: str | Path) -> ReasoningExperiment:
103
+ with Path(path).open('r', encoding='utf-8') as handle:
104
+ raw = yaml.safe_load(handle)
105
+ for section in ('model', 'training', 'reasoning'):
106
+ if section not in raw:
107
+ raise ValueError(f'config must contain a top-level {section} mapping')
108
+ return ReasoningExperiment(
109
+ model=ModelConfig(**raw['model']),
110
+ training=TrainingConfig(**raw['training']),
111
+ reasoning=ReasoningConfig(**raw['reasoning']),
112
+ )
113
+
114
+
115
+ def _validate_inputs(experiment: ReasoningExperiment, datasets: list) -> None:
116
+ tokenizer = load_tokenizer(experiment.training.tokenizer)
117
+ actual_vocab = tokenizer.get_vocab_size(with_added_tokens=True)
118
+ if experiment.model.backbone == 'project':
119
+ if actual_vocab != experiment.model.vocab_size:
120
+ raise ValueError(
121
+ f'config vocab_size is {experiment.model.vocab_size}, '
122
+ f'tokenizer has {actual_vocab}'
123
+ )
124
+ elif actual_vocab > experiment.model.vocab_size:
125
+ # Pretrained embedding matrices may carry unused tail rows beyond the tokenizer.
126
+ raise ValueError(
127
+ f'tokenizer has {actual_vocab} tokens, beyond the {experiment.model.vocab_size} '
128
+ f'embedding rows of the pretrained backbone'
129
+ )
130
+ tokenizer_hash = hashlib.sha256(
131
+ Path(experiment.training.tokenizer).read_bytes()
132
+ ).hexdigest()
133
+ for dataset in datasets:
134
+ if dataset.metadata['tokenizer_sha256'] != tokenizer_hash:
135
+ raise ValueError(f'{dataset.path} was encoded with a different tokenizer file')
136
+ if int(dataset.metadata['vocab_size']) != actual_vocab:
137
+ raise ValueError(f'{dataset.path} was encoded with a different vocabulary size')
138
+ if 'layout' not in dataset.metadata:
139
+ continue
140
+ if experiment.reasoning.objective == 'hybrid':
141
+ expected_layout = 'adaptive' if experiment.reasoning.adaptive else 'slotted'
142
+ else:
143
+ expected_layout = 'flat'
144
+ if dataset.metadata['layout'] != expected_layout:
145
+ raise ValueError(
146
+ f'{dataset.path} has layout {dataset.metadata["layout"]!r}; the '
147
+ f'{experiment.reasoning.objective} objective requires {expected_layout!r}'
148
+ )
149
+ if dataset.seq_len != experiment.model.max_seq_len:
150
+ raise ValueError(f'{dataset.path} sequence length does not match max_seq_len')
151
+
152
+
153
+ def _lm_objective(
154
+ model: DiffusionTransformer, tokens: torch.Tensor
155
+ ) -> tuple[torch.Tensor, dict[str, float]]:
156
+ """Plain causal-LM pretraining over continuous packed text."""
157
+
158
+ seq_len = tokens.shape[1]
159
+ causal_blocked = torch.triu(
160
+ torch.ones(seq_len, seq_len, dtype=torch.bool, device=tokens.device), diagonal=1
161
+ )
162
+ output_positions = torch.ones_like(tokens, dtype=torch.bool)
163
+ output_positions[:, -1] = False
164
+ logits = model(tokens, output_positions=output_positions, attn_mask=causal_blocked)
165
+ targets = tokens[:, 1:].reshape(-1)
166
+ loss = F.cross_entropy(logits.float(), targets)
167
+ accuracy = float((logits.argmax(dim=-1) == targets).float().mean())
168
+ return loss, {'accuracy': accuracy}
169
+
170
+
171
+ def _objective_step(
172
+ model: DiffusionTransformer,
173
+ tokens: torch.Tensor,
174
+ regions: torch.Tensor | None,
175
+ experiment: ReasoningExperiment,
176
+ *,
177
+ eval_mask_level: float | None = None,
178
+ step: int | None = None,
179
+ ) -> tuple[torch.Tensor, dict[str, float]]:
180
+ objective = experiment.reasoning.objective
181
+ if objective == 'lm':
182
+ return _lm_objective(model, tokens)
183
+ if objective == 'block_diffusion':
184
+ size_weights = block_size_curriculum(
185
+ step,
186
+ n_sizes=len(experiment.reasoning.sizes),
187
+ curriculum_steps=experiment.reasoning.curriculum_steps,
188
+ )
189
+ output = block_diffusion_objective(
190
+ model,
191
+ tokens,
192
+ sizes=experiment.reasoning.sizes,
193
+ size_weights=size_weights,
194
+ mask_eps=experiment.training.mask_eps,
195
+ ar_probability=experiment.reasoning.ar_probability,
196
+ )
197
+ return output.loss, {
198
+ 'think_loss': output.think_loss,
199
+ 'answer_loss': output.answer_loss,
200
+ 'think_accuracy': output.think_accuracy,
201
+ 'answer_accuracy': output.answer_accuracy,
202
+ }
203
+ if objective == 'hybrid':
204
+ if experiment.reasoning.adaptive:
205
+ size_ids = torch.tensor(model.adaptive_size_ids, device=tokens.device)
206
+ output = adaptive_hybrid_objective(
207
+ model,
208
+ tokens,
209
+ regions,
210
+ size_ids=size_ids,
211
+ end_think_id=model.adaptive_end_think_id,
212
+ think_probability=experiment.reasoning.think_probability,
213
+ mask_eps=experiment.training.mask_eps,
214
+ causal_prefix=experiment.reasoning.causal_prefix,
215
+ control_context_noise=experiment.reasoning.control_context_noise,
216
+ )
217
+ else:
218
+ output = hybrid_objective(
219
+ model,
220
+ tokens,
221
+ regions,
222
+ block=int(model.reasoning_block),
223
+ think_probability=experiment.reasoning.think_probability,
224
+ mask_eps=experiment.training.mask_eps,
225
+ )
226
+ metrics = {
227
+ 'think_loss': output.think_loss,
228
+ 'answer_loss': output.answer_loss,
229
+ 'think_accuracy': output.think_accuracy,
230
+ 'answer_accuracy': output.answer_accuracy,
231
+ }
232
+ if experiment.reasoning.adaptive:
233
+ metrics['control_accuracy'] = output.control_accuracy
234
+ metrics['stop_accuracy'] = output.stop_accuracy
235
+ return output.loss, metrics
236
+ if objective == 'ar':
237
+ output = ar_objective(model, tokens, regions)
238
+ return output.loss, {'accuracy': output.accuracy}
239
+ mask_probability = None
240
+ if eval_mask_level is not None:
241
+ mask_probability = torch.full(
242
+ (tokens.shape[0],), eval_mask_level, device=tokens.device, dtype=torch.float32
243
+ )
244
+ logits, corruption, _ = diffusion_objective(
245
+ model,
246
+ tokens,
247
+ regions,
248
+ mask_eps=experiment.training.mask_eps,
249
+ mask_probability=mask_probability,
250
+ )
251
+ output = diffusion_cross_entropy(logits, tokens, corruption)
252
+ return output.loss, {'masked_accuracy': float(output.masked_accuracy)}
253
+
254
+
255
+ @torch.no_grad()
256
+ def _evaluate(
257
+ model: DiffusionTransformer,
258
+ loader: DataLoader,
259
+ experiment: ReasoningExperiment,
260
+ device: torch.device,
261
+ precision: str,
262
+ ) -> dict[str, float]:
263
+ state = capture_rng_state(device)
264
+ was_training = model.training
265
+ try:
266
+ seed_everything(0, device)
267
+ model.eval()
268
+ totals: dict[str, float] = {}
269
+ batches = 0
270
+ max_batches = experiment.training.eval_batches
271
+ for batch_index, batch in enumerate(loader):
272
+ if batch_index >= max_batches:
273
+ break
274
+ if experiment.reasoning.objective in PACKED_OBJECTIVES:
275
+ tokens, regions = batch, None
276
+ else:
277
+ tokens, regions = batch
278
+ regions = regions.to(device, non_blocking=True)
279
+ tokens = tokens.to(device, non_blocking=True)
280
+ level = experiment.training.mask_eps + (1.0 - experiment.training.mask_eps) * (
281
+ (batch_index + 0.5) / max_batches
282
+ )
283
+ with autocast_context(device, precision):
284
+ loss, metrics = _objective_step(
285
+ model, tokens, regions, experiment, eval_mask_level=level
286
+ )
287
+ totals['loss'] = totals.get('loss', 0.0) + float(loss)
288
+ for key, value in metrics.items():
289
+ totals[key] = totals.get(key, 0.0) + value
290
+ batches += 1
291
+ return {key: value / max(1, batches) for key, value in totals.items()}
292
+ finally:
293
+ restore_rng_state(state)
294
+ model.train(was_training)
295
+
296
+
297
+ def _save_checkpoint(
298
+ output_dir: Path,
299
+ model: DiffusionTransformer,
300
+ optimizer: torch.optim.Optimizer,
301
+ scaler: Any,
302
+ experiment: ReasoningExperiment,
303
+ step: int,
304
+ micro_batches_seen: int,
305
+ data_generator: torch.Generator,
306
+ ) -> Path:
307
+ output_dir.mkdir(parents=True, exist_ok=True)
308
+ payload = {
309
+ 'format': REASONING_CHECKPOINT_FORMAT,
310
+ 'step': step,
311
+ 'micro_batches_seen': micro_batches_seen,
312
+ 'config': experiment.to_dict(),
313
+ 'tokenizer_sha256': hashlib.sha256(
314
+ Path(experiment.training.tokenizer).read_bytes()
315
+ ).hexdigest(),
316
+ 'rng_state': capture_rng_state(next(model.parameters()).device),
317
+ 'data_generator_state': data_generator.get_state(),
318
+ 'model': model.state_dict(),
319
+ 'optimizer': optimizer.state_dict(),
320
+ 'scaler': scaler.state_dict(),
321
+ }
322
+ path = output_dir / 'latest.pt'
323
+ temporary = output_dir / '.checkpoint.tmp'
324
+ torch.save(payload, temporary)
325
+ temporary.replace(path)
326
+
327
+ inference_payload = {
328
+ 'format': 'mini-diffusion-lm-reasoning-inference-v1',
329
+ 'step': step,
330
+ 'config': experiment.to_dict(),
331
+ 'tokenizer_sha256': payload['tokenizer_sha256'],
332
+ 'model': _inference_state_dict(model),
333
+ }
334
+ inference_temporary = output_dir / '.inference.tmp'
335
+ torch.save(inference_payload, inference_temporary)
336
+ inference_temporary.replace(output_dir / 'inference-latest.pt')
337
+ return path
338
+
339
+
340
+ def _model_architecture(config: ModelConfig) -> dict[str, Any]:
341
+ """Config payload compared on resume; pretrained_path is machine-local and may move."""
342
+
343
+ fields = asdict(config)
344
+ fields.pop('pretrained_path', None)
345
+ return fields
346
+
347
+
348
+ def _load_init_weights(model: DiffusionTransformer, path: str | Path) -> None:
349
+ """Initialize model weights from any project checkpoint, ignoring optimizer state.
350
+
351
+ Enables the pretrain-AR-then-adapt recipe: architectures must match tensor-wise,
352
+ while objective-level settings (forbidden outputs, objective) may differ.
353
+ """
354
+
355
+ checkpoint = torch.load(path, map_location='cpu', weights_only=False)
356
+ state = checkpoint.get('model')
357
+ if state is None:
358
+ raise ValueError(f'{path} does not contain model weights')
359
+ model.load_state_dict(state)
360
+
361
+
362
+ def train(
363
+ experiment: ReasoningExperiment,
364
+ resume: str | Path | None = None,
365
+ max_run_steps: int | None = None,
366
+ init_weights: str | Path | None = None,
367
+ ) -> Path:
368
+ config = experiment.training
369
+ device = resolve_device(config.device)
370
+ seed_everything(config.seed, device)
371
+ configure_cuda_backends(device, config.require_fused_attention)
372
+ precision = resolve_precision(config.precision, device)
373
+
374
+ is_packed = experiment.reasoning.objective in PACKED_OBJECTIVES
375
+
376
+ def _open_dataset(path: str):
377
+ if is_packed:
378
+ return load_packed_dataset(path, experiment.model.max_seq_len)
379
+ return ReasoningTokenDataset(path)
380
+
381
+ train_dataset = _open_dataset(config.train_data)
382
+ datasets = [train_dataset]
383
+ val_loader = None
384
+ if config.val_data is not None:
385
+ val_dataset = _open_dataset(config.val_data)
386
+ datasets.append(val_dataset)
387
+ val_loader = DataLoader(
388
+ val_dataset,
389
+ batch_size=config.batch_size,
390
+ shuffle=False,
391
+ num_workers=config.num_workers,
392
+ pin_memory=device.type == 'cuda',
393
+ )
394
+ _validate_inputs(experiment, datasets)
395
+
396
+ data_generator = torch.Generator().manual_seed(config.seed)
397
+ batch_sampler = DeterministicBatchSampler(
398
+ len(train_dataset), config.batch_size, seed=config.seed
399
+ )
400
+ train_loader = DataLoader(
401
+ train_dataset,
402
+ batch_sampler=batch_sampler,
403
+ num_workers=config.num_workers,
404
+ pin_memory=device.type == 'cuda',
405
+ generator=data_generator,
406
+ )
407
+
408
+ load_pretrained = resume is None and init_weights is None
409
+ # fp32 master weights regardless of the checkpoint's serialized dtype (transformers 5
410
+ # defaults to 'auto'/bf16); compute precision comes from autocast like every project run.
411
+ model = build_denoiser(
412
+ experiment.model, load_pretrained=load_pretrained, dtype=torch.float32
413
+ ).to(device)
414
+ if not is_packed:
415
+ model.reasoning_block = int(train_dataset.metadata['block'])
416
+ if experiment.reasoning.adaptive:
417
+ metadata = train_dataset.metadata
418
+ if 'size_token_ids' not in metadata:
419
+ raise ValueError(f'{config.train_data} lacks size_token_ids; rebuild it adaptively')
420
+ model.adaptive_size_ids = tuple(sorted(int(v) for v in metadata['size_token_ids'].values()))
421
+ model.adaptive_end_think_id = int(metadata['reasoning_token_ids']['end_think'])
422
+ if init_weights is not None:
423
+ if resume is not None:
424
+ raise ValueError('init_weights and resume are mutually exclusive')
425
+ _load_init_weights(model, init_weights)
426
+ print(json.dumps({'event': 'init_weights', 'path': str(init_weights)}))
427
+ optimizer = build_optimizer(model, config)
428
+ scaler = create_grad_scaler(device.type == 'cuda' and precision == 'float16')
429
+
430
+ start_step = 0
431
+ micro_batches_seen = 0
432
+ if resume is not None:
433
+ checkpoint = torch.load(resume, map_location='cpu', weights_only=False)
434
+ if checkpoint.get('format') != REASONING_CHECKPOINT_FORMAT:
435
+ raise ValueError('unsupported checkpoint format')
436
+ if _model_architecture(
437
+ ModelConfig(**checkpoint['config']['model'])
438
+ ) != _model_architecture(experiment.model):
439
+ raise ValueError('checkpoint model configuration does not match the config')
440
+ model.load_state_dict(checkpoint['model'])
441
+ optimizer.load_state_dict(checkpoint['optimizer'])
442
+ scaler.load_state_dict(checkpoint.get('scaler', {}))
443
+ data_generator.set_state(checkpoint['data_generator_state'].cpu())
444
+ restore_rng_state(checkpoint['rng_state'])
445
+ start_step = int(checkpoint['step']) + 1
446
+ micro_batches_seen = int(checkpoint['micro_batches_seen'])
447
+ del checkpoint
448
+
449
+ batch_sampler.start_batch = micro_batches_seen
450
+ train_iterator = iter(train_loader)
451
+
452
+ output_dir = Path(config.output_dir)
453
+ output_dir.mkdir(parents=True, exist_ok=True)
454
+ with (output_dir / 'config.json').open('w', encoding='utf-8') as handle:
455
+ json.dump(experiment.to_dict(), handle, indent=2)
456
+ handle.write('\n')
457
+
458
+ print(
459
+ json.dumps(
460
+ {
461
+ 'event': 'start',
462
+ 'objective': experiment.reasoning.objective,
463
+ 'device': str(device),
464
+ 'precision': precision,
465
+ 'parameters': model.num_parameters,
466
+ 'parameters_human': format_parameter_count(model.num_parameters),
467
+ 'training_examples': len(train_dataset),
468
+ 'start_step': start_step,
469
+ }
470
+ )
471
+ )
472
+
473
+ model.train()
474
+ log_started = time.perf_counter()
475
+ log_loss = 0.0
476
+ log_metrics: dict[str, float] = {}
477
+ log_count = 0
478
+ end_step = config.max_steps
479
+ if max_run_steps is not None:
480
+ end_step = min(end_step, start_step + max_run_steps)
481
+ last_checkpoint = output_dir / 'latest.pt'
482
+
483
+ for step in range(start_step, end_step):
484
+ lr = learning_rate(step, config)
485
+ for group in optimizer.param_groups:
486
+ group['lr'] = lr
487
+ optimizer.zero_grad(set_to_none=True)
488
+
489
+ for _ in range(config.gradient_accumulation_steps):
490
+ batch = next(train_iterator)
491
+ if is_packed:
492
+ tokens, regions = batch, None
493
+ else:
494
+ tokens, regions = batch
495
+ regions = regions.to(device, non_blocking=True)
496
+ tokens = tokens.to(device, non_blocking=True)
497
+ micro_batches_seen += 1
498
+ with autocast_context(device, precision):
499
+ loss, metrics = _objective_step(model, tokens, regions, experiment, step=step)
500
+ scaled = loss / config.gradient_accumulation_steps
501
+ scaler.scale(scaled).backward()
502
+ log_loss += float(loss) / config.gradient_accumulation_steps
503
+ for key, value in metrics.items():
504
+ log_metrics[key] = (
505
+ log_metrics.get(key, 0.0) + value / config.gradient_accumulation_steps
506
+ )
507
+
508
+ scaler.unscale_(optimizer)
509
+ grad_norm = torch.nn.utils.clip_grad_norm_(model.parameters(), config.grad_clip)
510
+ scaler.step(optimizer)
511
+ scaler.update()
512
+ log_count += 1
513
+
514
+ if (step + 1) % config.log_interval == 0:
515
+ elapsed = time.perf_counter() - log_started
516
+ payload = {
517
+ 'event': 'train',
518
+ 'step': step + 1,
519
+ 'loss': log_loss / max(1, log_count),
520
+ 'learning_rate': lr,
521
+ 'grad_norm': float(grad_norm),
522
+ 'steps_per_second': log_count / max(elapsed, 1e-9),
523
+ }
524
+ payload.update(
525
+ {key: value / max(1, log_count) for key, value in log_metrics.items()}
526
+ )
527
+ print(json.dumps(payload))
528
+ log_started = time.perf_counter()
529
+ log_loss = 0.0
530
+ log_metrics = {}
531
+ log_count = 0
532
+
533
+ if val_loader is not None and (step + 1) % config.eval_interval == 0:
534
+ metrics = _evaluate(model, val_loader, experiment, device, precision)
535
+ print(json.dumps({'event': 'validation', 'step': step + 1, **metrics}))
536
+
537
+ if (step + 1) % config.save_interval == 0:
538
+ last_checkpoint = _save_checkpoint(
539
+ output_dir,
540
+ model,
541
+ optimizer,
542
+ scaler,
543
+ experiment,
544
+ step,
545
+ micro_batches_seen,
546
+ data_generator,
547
+ )
548
+ print(json.dumps({'event': 'checkpoint', 'path': str(last_checkpoint)}))
549
+
550
+ final_step = end_step - 1
551
+ if not last_checkpoint.exists() or (final_step + 1) % config.save_interval != 0:
552
+ last_checkpoint = _save_checkpoint(
553
+ output_dir,
554
+ model,
555
+ optimizer,
556
+ scaler,
557
+ experiment,
558
+ final_step,
559
+ micro_batches_seen,
560
+ data_generator,
561
+ )
562
+ event = 'complete' if end_step == config.max_steps else 'paused'
563
+ print(json.dumps({'event': event, 'checkpoint': str(last_checkpoint)}))
564
+ return last_checkpoint
565
+
566
+
567
+ def main() -> None:
568
+ parser = argparse.ArgumentParser(description=__doc__)
569
+ parser.add_argument('--config', type=Path, required=True)
570
+ parser.add_argument('--resume', type=Path)
571
+ parser.add_argument('--init-weights', type=Path)
572
+ parser.add_argument('--max-run-steps', type=int)
573
+ parser.add_argument('--device')
574
+ parser.add_argument(
575
+ '--precision', choices=('auto', 'float32', 'bfloat16', 'float16')
576
+ )
577
+ args = parser.parse_args()
578
+
579
+ experiment = load_reasoning_config(args.config)
580
+ if args.device or args.precision:
581
+ from dataclasses import replace
582
+
583
+ training = replace(
584
+ experiment.training,
585
+ device=args.device or experiment.training.device,
586
+ precision=args.precision or experiment.training.precision,
587
+ )
588
+ experiment = ReasoningExperiment(
589
+ model=experiment.model, training=training, reasoning=experiment.reasoning
590
+ )
591
+ train(
592
+ experiment,
593
+ resume=args.resume,
594
+ max_run_steps=args.max_run_steps,
595
+ init_weights=args.init_weights,
596
+ )
597
+
598
+
599
+ if __name__ == '__main__':
600
+ main()
src/diffusion_lm/sample.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate fixed-length text by iteratively unmasking tokens."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import gc
7
+ import hashlib
8
+ from pathlib import Path
9
+
10
+ import torch
11
+
12
+ from diffusion_lm.config import ModelConfig
13
+ from diffusion_lm.diffusion import iterative_unmask
14
+ from diffusion_lm.model import DiffusionTransformer
15
+ from diffusion_lm.tokenizer import load_tokenizer, special_token_id, special_token_ids
16
+ from diffusion_lm.train import resolve_device
17
+
18
+
19
+ def load_model(checkpoint_path: str | Path, device: torch.device) -> DiffusionTransformer:
20
+ try:
21
+ # mmap keeps unused optimizer tensors in a full training checkpoint off
22
+ # resident RAM. The compact inference export remains the preferred input.
23
+ checkpoint = torch.load(
24
+ checkpoint_path,
25
+ map_location="cpu",
26
+ weights_only=False,
27
+ mmap=True,
28
+ )
29
+ except TypeError: # PyTorch versions before mmap= support.
30
+ checkpoint = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
31
+ checkpoint_format = checkpoint.get("format")
32
+ if checkpoint_format not in {
33
+ "mini-diffusion-lm-checkpoint-v1",
34
+ "mini-diffusion-lm-inference-v1",
35
+ }:
36
+ raise ValueError("unsupported checkpoint format")
37
+ model_config = ModelConfig(**checkpoint["config"]["model"])
38
+ model = DiffusionTransformer(model_config)
39
+ if checkpoint_format == "mini-diffusion-lm-inference-v1" and device.type != "cpu":
40
+ # The weights-only export is BF16. Keep that dtype on accelerators instead
41
+ # of silently expanding a 1B model back to FP32 during load_state_dict.
42
+ first_weight = next(iter(checkpoint["model"].values()))
43
+ if first_weight.is_floating_point():
44
+ model = model.to(device=device, dtype=first_weight.dtype)
45
+ model.load_state_dict(checkpoint["model"])
46
+ model.tokenizer_sha256 = checkpoint.get("tokenizer_sha256")
47
+ del checkpoint
48
+ gc.collect()
49
+ return model.to(device).eval()
50
+
51
+
52
+ def generate(
53
+ model: DiffusionTransformer,
54
+ tokenizer_path: str | Path,
55
+ *,
56
+ prompt: str = "",
57
+ generation_length: int = 128,
58
+ num_samples: int = 1,
59
+ steps: int = 64,
60
+ temperature: float = 1.0,
61
+ strategy: str = "ancestral",
62
+ seed: int = 1337,
63
+ ) -> list[str]:
64
+ tokenizer = load_tokenizer(tokenizer_path)
65
+ tokenizer_hash = hashlib.sha256(Path(tokenizer_path).read_bytes()).hexdigest()
66
+ if model.tokenizer_sha256 is not None and tokenizer_hash != model.tokenizer_sha256:
67
+ raise ValueError("tokenizer file does not match the tokenizer used for training")
68
+ if tokenizer.get_vocab_size(with_added_tokens=True) != model.config.vocab_size:
69
+ raise ValueError("tokenizer vocabulary does not match the checkpoint")
70
+ mask_id = special_token_id(tokenizer, "mask")
71
+ if mask_id != model.config.mask_token_id:
72
+ raise ValueError("tokenizer mask id does not match the checkpoint")
73
+ if generation_length <= 0 or num_samples <= 0:
74
+ raise ValueError("generation_length and num_samples must be positive")
75
+
76
+ prompt_ids = tokenizer.encode(prompt).ids if prompt else []
77
+ total_length = len(prompt_ids) + generation_length
78
+ if total_length > model.config.max_seq_len:
79
+ raise ValueError(
80
+ f"prompt plus generation uses {total_length} tokens, but model limit is "
81
+ f"{model.config.max_seq_len}"
82
+ )
83
+
84
+ device = next(model.parameters()).device
85
+ input_ids = torch.full(
86
+ (num_samples, total_length),
87
+ model.config.mask_token_id,
88
+ dtype=torch.long,
89
+ device=device,
90
+ )
91
+ if prompt_ids:
92
+ input_ids[:, : len(prompt_ids)] = torch.tensor(prompt_ids, device=device)
93
+
94
+ torch.manual_seed(seed)
95
+ if device.type == "cuda":
96
+ torch.cuda.manual_seed_all(seed)
97
+ elif device.type == "mps" and hasattr(torch.mps, "manual_seed"):
98
+ torch.mps.manual_seed(seed)
99
+ role_ids = special_token_ids(tokenizer)
100
+ blocked = tuple(role_ids[role] for role in ("pad", "unk", "bos", "mask"))
101
+ result = iterative_unmask(
102
+ model,
103
+ input_ids,
104
+ model.config.mask_token_id,
105
+ steps=steps,
106
+ temperature=temperature,
107
+ strategy=strategy, # type: ignore[arg-type]
108
+ blocked_token_ids=blocked,
109
+ ).cpu()
110
+
111
+ eos_id = special_token_id(tokenizer, "eos")
112
+ texts: list[str] = []
113
+ for row in result.tolist():
114
+ if eos_id in row[len(prompt_ids) :]:
115
+ eos_position = row.index(eos_id, len(prompt_ids))
116
+ row = row[:eos_position]
117
+ texts.append(tokenizer.decode(row, skip_special_tokens=True))
118
+ return texts
119
+
120
+
121
+ def main() -> None:
122
+ parser = argparse.ArgumentParser(description=__doc__)
123
+ parser.add_argument("--checkpoint", type=Path, required=True)
124
+ parser.add_argument("--tokenizer", type=Path, required=True)
125
+ parser.add_argument("--prompt", default="")
126
+ parser.add_argument("--length", type=int, default=128, help="number of completion tokens")
127
+ parser.add_argument("--num-samples", type=int, default=1)
128
+ parser.add_argument("--steps", type=int, default=64)
129
+ parser.add_argument("--temperature", type=float, default=1.0)
130
+ parser.add_argument("--strategy", choices=("ancestral", "confidence"), default="ancestral")
131
+ parser.add_argument("--seed", type=int, default=1337)
132
+ parser.add_argument("--device", default="auto")
133
+ args = parser.parse_args()
134
+
135
+ device = resolve_device(args.device)
136
+ model = load_model(args.checkpoint, device)
137
+ texts = generate(
138
+ model,
139
+ args.tokenizer,
140
+ prompt=args.prompt,
141
+ generation_length=args.length,
142
+ num_samples=args.num_samples,
143
+ steps=args.steps,
144
+ temperature=args.temperature,
145
+ strategy=args.strategy,
146
+ seed=args.seed,
147
+ )
148
+ for index, text in enumerate(texts, start=1):
149
+ print(f"[{index}] {text}")
150
+
151
+
152
+ if __name__ == "__main__":
153
+ main()
src/diffusion_lm/stories.py ADDED
@@ -0,0 +1,264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """TinyStories preparation: AR-pretraining stream and instruct data with thought slots.
2
+
3
+ Two products over one shared tokenizer:
4
+
5
+ - ``prepare-pretrain``: TinyStoriesV2 stories packed as a continuous EOS-separated
6
+ uint16 stream for causal-LM pretraining.
7
+ - ``prepare-instruct``: TinyStories-Instruct records rendered with the reasoning
8
+ slot geometry — prompt holds the writing instruction, thought slots hold the
9
+ requirement restatements plus the story plan (the summary, which never appears
10
+ in the prompt), and the answer region holds the story.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import hashlib
17
+ import json
18
+ import re
19
+ from pathlib import Path
20
+ from typing import Iterator
21
+
22
+ import numpy as np
23
+
24
+ from diffusion_lm.reasoning import (
25
+ ExampleEncoder,
26
+ LayoutSpec,
27
+ ReasoningExample,
28
+ REASONING_SPECIAL_TOKENS,
29
+ _write_packed,
30
+ )
31
+ from diffusion_lm.tokenizer import (
32
+ load_tokenizer,
33
+ special_token_ids,
34
+ train_tokenizer_from_iterator,
35
+ )
36
+
37
+ _FIELD_RE = re.compile(r'^(Features|Words|Summary|Random sentence):\s*(.+)$', re.MULTILINE)
38
+ _STORY_RE = re.compile(r'^Story:\s*$', re.MULTILINE)
39
+
40
+
41
+ def iter_stories(path: Path) -> Iterator[str]:
42
+ """Yield individual stories from an ``<|endoftext|>``-separated text file."""
43
+
44
+ buffer = ''
45
+ with path.open('r', encoding='utf-8') as handle:
46
+ while True:
47
+ chunk = handle.read(1 << 24)
48
+ if not chunk:
49
+ break
50
+ buffer += chunk
51
+ *complete, buffer = buffer.split('<|endoftext|>')
52
+ for piece in complete:
53
+ story = piece.strip()
54
+ if story:
55
+ yield story
56
+ tail = buffer.strip()
57
+ if tail:
58
+ yield tail
59
+
60
+
61
+ def iter_instruct_records(path: Path) -> Iterator[tuple[dict[str, str], str]]:
62
+ """Yield ``(fields, story)`` pairs from a TinyStories-Instruct dump."""
63
+
64
+ for record in iter_stories(path):
65
+ story_match = _STORY_RE.search(record)
66
+ if not story_match:
67
+ continue
68
+ header = record[: story_match.start()]
69
+ story = record[story_match.end():].strip()
70
+ fields = dict(_FIELD_RE.findall(header))
71
+ if story and fields:
72
+ yield fields, story
73
+
74
+
75
+ def instruct_example(fields: dict[str, str], story: str) -> ReasoningExample | None:
76
+ """Render one record as prompt, thought steps, and the story answer.
77
+
78
+ The summary becomes plan thoughts and is deliberately excluded from the
79
+ prompt, so planning is generative rather than copyable.
80
+ """
81
+
82
+ summary = ' '.join(fields.get('Summary', '').split())
83
+ if not summary:
84
+ return None
85
+ prompt_parts = ['Write a short story.']
86
+ thoughts: list[str] = []
87
+ features = ' '.join(fields.get('Features', '').split())
88
+ words = ' '.join(fields.get('Words', '').split())
89
+ sentence = ' '.join(fields.get('Random sentence', '').split())
90
+ if features:
91
+ prompt_parts.append(f'It should feature: {features}.')
92
+ thoughts.append(f'The story needs these elements: {features}.')
93
+ if words:
94
+ prompt_parts.append(f'Use the words: {words}.')
95
+ thoughts.append(f'I have to work in the words {words}.')
96
+ if sentence:
97
+ prompt_parts.append(f'Include the sentence: {sentence}')
98
+ thoughts.append(f'The sentence "{sentence}" must appear.')
99
+ thoughts.append(f'Plan: {summary}')
100
+ story = '\n'.join(line.strip() for line in story.splitlines() if line.strip())
101
+ return ReasoningExample(
102
+ problem=' '.join(prompt_parts),
103
+ steps=tuple(thoughts),
104
+ answer=story,
105
+ expected_answer='',
106
+ )
107
+
108
+
109
+ def prepare_pretrain(args: argparse.Namespace) -> None:
110
+ tokenizer_path = Path(args.tokenizer)
111
+ if tokenizer_path.is_file():
112
+ tokenizer = load_tokenizer(tokenizer_path)
113
+ print(f'reusing tokenizer {tokenizer_path}')
114
+ else:
115
+ def _texts() -> Iterator[str]:
116
+ for index, story in enumerate(iter_stories(Path(args.train))):
117
+ if index >= args.tokenizer_sample:
118
+ break
119
+ yield story
120
+
121
+ tokenizer = train_tokenizer_from_iterator(
122
+ _texts(),
123
+ tokenizer_path,
124
+ vocab_size=args.vocab_size,
125
+ min_frequency=4,
126
+ length=args.tokenizer_sample,
127
+ extra_special_tokens=REASONING_SPECIAL_TOKENS,
128
+ )
129
+ print(f'trained tokenizer {tokenizer_path}')
130
+
131
+ eos_id = special_token_ids(tokenizer)['eos']
132
+ output_dir = Path(args.output_dir)
133
+ output_dir.mkdir(parents=True, exist_ok=True)
134
+ for split, source in (('train', args.train), ('validation', args.validation)):
135
+ source_path = Path(source)
136
+ out_path = output_dir / f'{split}.bin'
137
+ token_count = 0
138
+ document_count = 0
139
+ batch: list[str] = []
140
+ with out_path.open('wb') as destination:
141
+ def _flush(batch: list[str]) -> tuple[int, int]:
142
+ encodings = tokenizer.encode_batch(batch, add_special_tokens=False)
143
+ tokens = 0
144
+ for encoding in encodings:
145
+ ids = encoding.ids + [eos_id]
146
+ np.asarray(ids, dtype=np.uint16).tofile(destination)
147
+ tokens += len(ids)
148
+ return tokens, len(encodings)
149
+
150
+ for story in iter_stories(source_path):
151
+ batch.append(story)
152
+ if len(batch) >= 2048:
153
+ tokens, docs = _flush(batch)
154
+ token_count += tokens
155
+ document_count += docs
156
+ batch = []
157
+ if batch:
158
+ tokens, docs = _flush(batch)
159
+ token_count += tokens
160
+ document_count += docs
161
+
162
+ metadata = {
163
+ 'format': 'mini-diffusion-lm-packed-tokens-v1',
164
+ 'dtype': 'uint16',
165
+ 'token_count': token_count,
166
+ 'document_count': document_count,
167
+ 'vocab_size': tokenizer.get_vocab_size(with_added_tokens=True),
168
+ 'mask_token_id': special_token_ids(tokenizer)['mask'],
169
+ 'eos_token_id': eos_id,
170
+ 'special_token_ids': special_token_ids(tokenizer),
171
+ 'tokenizer_sha256': hashlib.sha256(tokenizer_path.read_bytes()).hexdigest(),
172
+ 'source_files': [str(source_path)],
173
+ }
174
+ with (out_path.parent / f'{out_path.name}.json').open('w', encoding='utf-8') as handle:
175
+ json.dump(metadata, handle, indent=2)
176
+ handle.write('\n')
177
+ print(f'{split}: {document_count:,} stories, {token_count:,} tokens -> {out_path}')
178
+
179
+
180
+ def prepare_instruct(args: argparse.Namespace) -> None:
181
+ tokenizer = load_tokenizer(Path(args.tokenizer))
182
+ spec = LayoutSpec(seq_len=args.seq_len, block=args.block, max_slots=args.max_slots)
183
+ encoder = ExampleEncoder(tokenizer, spec)
184
+ output_dir = Path(args.output_dir)
185
+
186
+ for split, source in (('train', args.train), ('validation', args.validation)):
187
+ flat, flat_regions, slotted, slotted_regions = [], [], [], []
188
+ prompts: list[dict[str, str]] = []
189
+ dropped = 0
190
+ count = 0
191
+ for fields, story in iter_instruct_records(Path(source)):
192
+ if args.limit and count >= args.limit:
193
+ break
194
+ example = instruct_example(fields, story)
195
+ if example is None:
196
+ dropped += 1
197
+ continue
198
+ encoded = encoder.encode_example(example)
199
+ if encoded is None:
200
+ dropped += 1
201
+ continue
202
+ count += 1
203
+ flat.append(encoded.flat)
204
+ flat_regions.append(encoded.flat_regions)
205
+ slotted.append(encoded.slotted)
206
+ slotted_regions.append(encoded.slotted_regions)
207
+ if split == 'validation' and len(prompts) < 500:
208
+ prompts.append({'problem': example.problem, 'expected_answer': ''})
209
+ if not flat:
210
+ raise ValueError(f'no usable records in {source}')
211
+ for layout, tokens, regions in (
212
+ ('flat', flat, flat_regions),
213
+ ('slotted', slotted, slotted_regions),
214
+ ):
215
+ _write_packed(
216
+ output_dir / f'{split}-{layout}.bin',
217
+ np.stack(tokens),
218
+ np.stack(regions),
219
+ layout=layout,
220
+ spec=spec,
221
+ tokenizer_path=Path(args.tokenizer),
222
+ tokenizer=tokenizer,
223
+ )
224
+ print(f'{split}: {count:,} examples ({dropped:,} dropped) -> {output_dir}')
225
+ if split == 'validation':
226
+ with (output_dir / 'validation-problems.jsonl').open('w', encoding='utf-8') as fh:
227
+ for record in prompts:
228
+ fh.write(json.dumps(record, ensure_ascii=False) + '\n')
229
+
230
+
231
+ def _build_parser() -> argparse.ArgumentParser:
232
+ parser = argparse.ArgumentParser(description=__doc__)
233
+ subparsers = parser.add_subparsers(dest='command', required=True)
234
+
235
+ pretrain = subparsers.add_parser('prepare-pretrain')
236
+ pretrain.add_argument('--train', required=True)
237
+ pretrain.add_argument('--validation', required=True)
238
+ pretrain.add_argument('--output-dir', required=True)
239
+ pretrain.add_argument('--tokenizer', required=True)
240
+ pretrain.add_argument('--vocab-size', type=int, default=8192)
241
+ pretrain.add_argument('--tokenizer-sample', type=int, default=400_000)
242
+
243
+ instruct = subparsers.add_parser('prepare-instruct')
244
+ instruct.add_argument('--train', required=True)
245
+ instruct.add_argument('--validation', required=True)
246
+ instruct.add_argument('--output-dir', required=True)
247
+ instruct.add_argument('--tokenizer', required=True)
248
+ instruct.add_argument('--seq-len', type=int, default=768)
249
+ instruct.add_argument('--block', type=int, default=32)
250
+ instruct.add_argument('--max-slots', type=int, default=9)
251
+ instruct.add_argument('--limit', type=int, default=0)
252
+ return parser
253
+
254
+
255
+ def main() -> None:
256
+ args = _build_parser().parse_args()
257
+ if args.command == 'prepare-pretrain':
258
+ prepare_pretrain(args)
259
+ elif args.command == 'prepare-instruct':
260
+ prepare_instruct(args)
261
+
262
+
263
+ if __name__ == '__main__':
264
+ main()
src/diffusion_lm/tokenizer.py ADDED
@@ -0,0 +1,357 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Train byte-level BPE tokenizers and encode packed token files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import hashlib
7
+ import json
8
+ from pathlib import Path
9
+ from typing import Iterable, Literal, Protocol, TypeAlias
10
+
11
+ import numpy as np
12
+ from tokenizers import Tokenizer, decoders, models, normalizers, pre_tokenizers, trainers
13
+ from tqdm import tqdm
14
+
15
+
16
+ SpecialTokenRole: TypeAlias = Literal["pad", "unk", "bos", "eos", "mask"]
17
+ SPECIAL_TOKEN_ROLES: tuple[SpecialTokenRole, ...] = ("pad", "unk", "bos", "eos", "mask")
18
+
19
+ # These sentinels deliberately include a project-specific random namespace. Common names such as
20
+ # ``[MASK]`` occur in documentation and source code often enough to stop a web-scale encoder.
21
+ _SPECIAL_TOKEN_NAMESPACE = "mdlm-v1-8f4e2a6c-917b-4d31-b3f7-6a2c8d5e0f19"
22
+ SPECIAL_TOKENS = tuple(
23
+ f"<|{_SPECIAL_TOKEN_NAMESPACE}:{role}|>" for role in SPECIAL_TOKEN_ROLES
24
+ )
25
+ LEGACY_SPECIAL_TOKENS = ("[PAD]", "[UNK]", "[BOS]", "[EOS]", "[MASK]")
26
+ # Native spellings honored on pretrained-backbone tokenizers (e.g. Qwen3). Only roles with a
27
+ # trained native token map here; the rest are added as namespaced sentinels on free vocab rows.
28
+ PRETRAINED_SPECIAL_TOKENS: tuple[str | None, ...] = (None, None, None, "<|endoftext|>", None)
29
+
30
+
31
+ def _role_token_candidates(index: int) -> tuple[str, ...]:
32
+ candidates = [SPECIAL_TOKENS[index], LEGACY_SPECIAL_TOKENS[index]]
33
+ pretrained = PRETRAINED_SPECIAL_TOKENS[index]
34
+ if pretrained is not None:
35
+ candidates.append(pretrained)
36
+ return tuple(candidates)
37
+
38
+
39
+ class _TokenizerLike(Protocol):
40
+ def token_to_id(self, token: str) -> int | None: ...
41
+
42
+
43
+ def _raw_tokenizer(tokenizer: _TokenizerLike) -> _TokenizerLike:
44
+ return getattr(tokenizer, "raw_tokenizer", tokenizer)
45
+
46
+
47
+ class RoleAwareTokenizer:
48
+ """Proxy a Tokenizer while keeping legacy role spellings usable by old callers.
49
+
50
+ Encoding is always delegated unchanged, so strings such as ``[MASK]`` remain ordinary text in
51
+ newly trained tokenizers. Only explicit ``token_to_id`` lookups receive alias compatibility.
52
+ """
53
+
54
+ def __init__(self, tokenizer: Tokenizer) -> None:
55
+ self.raw_tokenizer = tokenizer
56
+
57
+ def token_to_id(self, token: str) -> int | None:
58
+ token_id = self.raw_tokenizer.token_to_id(token)
59
+ if token_id is not None:
60
+ return token_id
61
+ for index in range(len(SPECIAL_TOKEN_ROLES)):
62
+ candidates = _role_token_candidates(index)
63
+ if token in candidates:
64
+ for concrete in candidates:
65
+ token_id = self.raw_tokenizer.token_to_id(concrete)
66
+ if token_id is not None:
67
+ return token_id
68
+ return None
69
+
70
+ def __getattr__(self, name: str):
71
+ return getattr(self.raw_tokenizer, name)
72
+
73
+
74
+ def _role_index(role: SpecialTokenRole | str) -> int:
75
+ try:
76
+ return SPECIAL_TOKEN_ROLES.index(role) # type: ignore[arg-type]
77
+ except ValueError as exc:
78
+ choices = ", ".join(SPECIAL_TOKEN_ROLES)
79
+ raise ValueError(f"unknown special-token role {role!r}; expected one of {choices}") from exc
80
+
81
+
82
+ def special_token_string(tokenizer: _TokenizerLike, role: SpecialTokenRole | str) -> str:
83
+ """Return the concrete token string used for ``role`` by a new or legacy tokenizer."""
84
+
85
+ index = _role_index(role)
86
+ raw = _raw_tokenizer(tokenizer)
87
+ for token in _role_token_candidates(index):
88
+ if raw.token_to_id(token) is not None:
89
+ return token
90
+ raise ValueError(f"tokenizer is missing the {role!r} special token")
91
+
92
+
93
+ def special_token_id(tokenizer: _TokenizerLike, role: SpecialTokenRole | str) -> int:
94
+ """Resolve a special-token ID by semantic role, independent of its serialized spelling."""
95
+
96
+ token = special_token_string(tokenizer, role)
97
+ token_id = _raw_tokenizer(tokenizer).token_to_id(token)
98
+ if token_id is None: # Kept defensive for non-Tokenizer protocol implementations.
99
+ raise ValueError(f"tokenizer is missing the {role!r} special token")
100
+ return token_id
101
+
102
+
103
+ def special_token_ids(tokenizer: _TokenizerLike) -> dict[SpecialTokenRole, int]:
104
+ """Return all semantic special-token IDs for a new or legacy tokenizer."""
105
+
106
+ return {role: special_token_id(tokenizer, role) for role in SPECIAL_TOKEN_ROLES}
107
+
108
+
109
+ def _build_tokenizer() -> Tokenizer:
110
+ tokenizer = Tokenizer(models.BPE(unk_token=SPECIAL_TOKENS[1]))
111
+ tokenizer.normalizer = normalizers.NFC()
112
+ tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)
113
+ tokenizer.decoder = decoders.ByteLevel()
114
+ return tokenizer
115
+
116
+
117
+ def _build_trainer(
118
+ *,
119
+ vocab_size: int,
120
+ min_frequency: int,
121
+ max_token_length: int,
122
+ extra_special_tokens: tuple[str, ...] = (),
123
+ ) -> trainers.BpeTrainer:
124
+ special_tokens = list(SPECIAL_TOKENS) + list(extra_special_tokens)
125
+ if len(set(special_tokens)) != len(special_tokens):
126
+ raise ValueError('extra_special_tokens must not duplicate the role tokens or each other')
127
+ if vocab_size <= len(special_tokens) + 256:
128
+ raise ValueError("vocab_size must leave room for the byte alphabet and special tokens")
129
+ if min_frequency <= 0:
130
+ raise ValueError("min_frequency must be positive")
131
+ if max_token_length <= 0:
132
+ raise ValueError("max_token_length must be positive")
133
+ return trainers.BpeTrainer(
134
+ vocab_size=vocab_size,
135
+ min_frequency=min_frequency,
136
+ special_tokens=special_tokens,
137
+ initial_alphabet=pre_tokenizers.ByteLevel.alphabet(),
138
+ max_token_length=max_token_length,
139
+ show_progress=True,
140
+ )
141
+
142
+
143
+ def _validate_special_token_layout(tokenizer: Tokenizer) -> None:
144
+ for expected_id, role in enumerate(SPECIAL_TOKEN_ROLES):
145
+ actual_id = special_token_id(tokenizer, role)
146
+ if actual_id != expected_id:
147
+ raise ValueError(
148
+ f"expected the {role!r} special token at id {expected_id}, found {actual_id}"
149
+ )
150
+
151
+
152
+ def _save_tokenizer(tokenizer: Tokenizer, output_path: str | Path) -> None:
153
+ output = Path(output_path)
154
+ output.parent.mkdir(parents=True, exist_ok=True)
155
+ temporary = output.with_name(f".{output.name}.tmp")
156
+ temporary.unlink(missing_ok=True)
157
+ tokenizer.save(str(temporary), pretty=True)
158
+ temporary.replace(output)
159
+
160
+
161
+ TokenizerTrainingItem: TypeAlias = str | list[str] | tuple[str, ...]
162
+
163
+
164
+ def train_tokenizer_from_iterator(
165
+ texts: Iterable[TokenizerTrainingItem],
166
+ output_path: str | Path,
167
+ *,
168
+ vocab_size: int = 32_768,
169
+ min_frequency: int = 10,
170
+ max_token_length: int = 64,
171
+ length: int | None = None,
172
+ extra_special_tokens: tuple[str, ...] = (),
173
+ ) -> RoleAwareTokenizer:
174
+ """Train a collision-resistant byte-level BPE directly from a text iterator.
175
+
176
+ Iterator items may be individual strings or batches of strings. Batched iteration avoids
177
+ materializing a large text export and is the intended entry point for Parquet corpora.
178
+ ``extra_special_tokens`` are assigned the ids directly after the role tokens.
179
+ """
180
+
181
+ if length is not None and length < 0:
182
+ raise ValueError("length must be non-negative")
183
+ tokenizer = _build_tokenizer()
184
+ trainer = _build_trainer(
185
+ vocab_size=vocab_size,
186
+ min_frequency=min_frequency,
187
+ max_token_length=max_token_length,
188
+ extra_special_tokens=extra_special_tokens,
189
+ )
190
+ tokenizer.train_from_iterator(texts, trainer=trainer, length=length)
191
+ _validate_special_token_layout(tokenizer)
192
+ _save_tokenizer(tokenizer, output_path)
193
+ return RoleAwareTokenizer(tokenizer)
194
+
195
+
196
+ def train_tokenizer(
197
+ input_paths: Iterable[str | Path],
198
+ output_path: str | Path,
199
+ *,
200
+ vocab_size: int = 32_768,
201
+ min_frequency: int = 10,
202
+ max_token_length: int = 64,
203
+ ) -> RoleAwareTokenizer:
204
+ """Train a byte-level BPE from local text files."""
205
+
206
+ paths = [str(Path(path)) for path in input_paths]
207
+ if not paths:
208
+ raise ValueError("at least one input text file is required")
209
+ missing = [path for path in paths if not Path(path).is_file()]
210
+ if missing:
211
+ raise FileNotFoundError(f"missing tokenizer inputs: {missing}")
212
+
213
+ tokenizer = _build_tokenizer()
214
+ trainer = _build_trainer(
215
+ vocab_size=vocab_size,
216
+ min_frequency=min_frequency,
217
+ max_token_length=max_token_length,
218
+ )
219
+ tokenizer.train(paths, trainer)
220
+ _validate_special_token_layout(tokenizer)
221
+ _save_tokenizer(tokenizer, output_path)
222
+ return RoleAwareTokenizer(tokenizer)
223
+
224
+
225
+ def load_tokenizer(path: str | Path) -> RoleAwareTokenizer:
226
+ """Load and validate a project, legacy, or pretrained-backbone token layout.
227
+
228
+ Project-trained tokenizers place the role tokens at ids 0-4 and keep the strict layout
229
+ check. Pretrained-backbone tokenizers (role tokens appended on free vocab rows, ``pad``
230
+ far from 0) only need every role to resolve to some id.
231
+ """
232
+
233
+ tokenizer_path = Path(path)
234
+ if not tokenizer_path.is_file():
235
+ raise FileNotFoundError(f"tokenizer not found: {tokenizer_path}")
236
+ tokenizer = Tokenizer.from_file(str(tokenizer_path))
237
+ if special_token_id(tokenizer, "pad") == 0:
238
+ _validate_special_token_layout(tokenizer)
239
+ else:
240
+ special_token_ids(tokenizer)
241
+ return RoleAwareTokenizer(tokenizer)
242
+
243
+
244
+ def token_metadata_path(token_path: str | Path) -> Path:
245
+ path = Path(token_path)
246
+ return path.with_suffix(path.suffix + ".json")
247
+
248
+
249
+ def encode_files(
250
+ tokenizer_path: str | Path,
251
+ input_paths: Iterable[str | Path],
252
+ output_path: str | Path,
253
+ ) -> dict[str, object]:
254
+ """Encode newline-delimited documents to a compact, memory-mappable file.
255
+
256
+ This compatibility path remains useful for small corpora. Web-scale Parquet preparation lives
257
+ in :mod:`diffusion_lm.corpus` and preserves embedded newlines within each document.
258
+ """
259
+
260
+ tokenizer = load_tokenizer(tokenizer_path)
261
+ inputs = [Path(path) for path in input_paths]
262
+ if not inputs:
263
+ raise ValueError("at least one input text file is required")
264
+ missing = [str(path) for path in inputs if not path.is_file()]
265
+ if missing:
266
+ raise FileNotFoundError(f"missing corpus inputs: {missing}")
267
+
268
+ vocab_size = tokenizer.get_vocab_size(with_added_tokens=True)
269
+ dtype = np.dtype("uint16" if vocab_size <= np.iinfo(np.uint16).max else "uint32")
270
+ role_ids = special_token_ids(tokenizer)
271
+ eos_id = role_ids["eos"]
272
+ reserved_ids = set(role_ids.values())
273
+
274
+ output = Path(output_path)
275
+ output.parent.mkdir(parents=True, exist_ok=True)
276
+ token_count = 0
277
+ document_count = 0
278
+ with output.open("wb") as destination:
279
+ for input_path in inputs:
280
+ with input_path.open("r", encoding="utf-8") as source:
281
+ for line_number, line in enumerate(
282
+ tqdm(source, desc=f"encoding {input_path.name}", unit="docs"), start=1
283
+ ):
284
+ text = line.rstrip("\r\n")
285
+ if not text:
286
+ continue
287
+ token_ids = tokenizer.encode(text, add_special_tokens=False).ids
288
+ encountered = reserved_ids.intersection(token_ids)
289
+ if encountered:
290
+ raise ValueError(
291
+ f"{input_path}:{line_number} encodes reserved special-token ids "
292
+ f"{sorted(encountered)}; remove literal special tokens from the corpus"
293
+ )
294
+ token_ids.append(eos_id)
295
+ np.asarray(token_ids, dtype=dtype).tofile(destination)
296
+ token_count += len(token_ids)
297
+ document_count += 1
298
+
299
+ tokenizer_bytes = Path(tokenizer_path).read_bytes()
300
+ metadata: dict[str, object] = {
301
+ "format": "mini-diffusion-lm-packed-tokens-v1",
302
+ "dtype": dtype.name,
303
+ "token_count": token_count,
304
+ "document_count": document_count,
305
+ "vocab_size": vocab_size,
306
+ "mask_token_id": role_ids["mask"],
307
+ "eos_token_id": eos_id,
308
+ "special_token_ids": role_ids,
309
+ "tokenizer_sha256": hashlib.sha256(tokenizer_bytes).hexdigest(),
310
+ "source_files": [str(path) for path in inputs],
311
+ }
312
+ metadata_path = token_metadata_path(output)
313
+ with metadata_path.open("w", encoding="utf-8") as handle:
314
+ json.dump(metadata, handle, indent=2)
315
+ handle.write("\n")
316
+ return metadata
317
+
318
+
319
+ def _build_parser() -> argparse.ArgumentParser:
320
+ parser = argparse.ArgumentParser(description=__doc__)
321
+ subparsers = parser.add_subparsers(dest="command", required=True)
322
+
323
+ train_parser = subparsers.add_parser("train", help="train a byte-level BPE tokenizer")
324
+ train_parser.add_argument("--input", type=Path, nargs="+", required=True)
325
+ train_parser.add_argument("--output", type=Path, required=True)
326
+ train_parser.add_argument("--vocab-size", type=int, default=32_768)
327
+ train_parser.add_argument("--min-frequency", type=int, default=10)
328
+ train_parser.add_argument("--max-token-length", type=int, default=64)
329
+
330
+ encode_parser = subparsers.add_parser("encode", help="encode text into packed tokens")
331
+ encode_parser.add_argument("--tokenizer", type=Path, required=True)
332
+ encode_parser.add_argument("--input", type=Path, nargs="+", required=True)
333
+ encode_parser.add_argument("--output", type=Path, required=True)
334
+ return parser
335
+
336
+
337
+ def main() -> None:
338
+ args = _build_parser().parse_args()
339
+ if args.command == "train":
340
+ tokenizer = train_tokenizer(
341
+ args.input,
342
+ args.output,
343
+ vocab_size=args.vocab_size,
344
+ min_frequency=args.min_frequency,
345
+ max_token_length=args.max_token_length,
346
+ )
347
+ print(f"saved {tokenizer.get_vocab_size():,}-token tokenizer to {args.output}")
348
+ elif args.command == "encode":
349
+ metadata = encode_files(args.tokenizer, args.input, args.output)
350
+ print(
351
+ f"wrote {metadata['token_count']:,} tokens from "
352
+ f"{metadata['document_count']:,} documents to {args.output}"
353
+ )
354
+
355
+
356
+ if __name__ == "__main__":
357
+ main()
src/diffusion_lm/train.py ADDED
@@ -0,0 +1,666 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Single-device training loop for the masked discrete diffusion LM."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import contextlib
7
+ import gc
8
+ import hashlib
9
+ import json
10
+ import math
11
+ import random
12
+ import shutil
13
+ import time
14
+ from dataclasses import replace
15
+ from pathlib import Path
16
+ import numpy as np
17
+ import torch
18
+ from torch import Tensor
19
+ from torch.optim import AdamW, Optimizer
20
+ from torch.utils.data import DataLoader
21
+
22
+ from diffusion_lm.config import ExperimentConfig, ModelConfig, TrainingConfig, load_config
23
+ from diffusion_lm.data import DeterministicBatchSampler, load_packed_dataset
24
+ from diffusion_lm.diffusion import corrupt_tokens, diffusion_cross_entropy
25
+ from diffusion_lm.model import DiffusionTransformer, format_parameter_count
26
+ from diffusion_lm.tokenizer import load_tokenizer, special_token_id
27
+
28
+
29
+ def seed_everything(seed: int, device: torch.device) -> None:
30
+ random.seed(seed)
31
+ np.random.seed(seed)
32
+ torch.manual_seed(seed)
33
+ if device.type == "cuda":
34
+ torch.cuda.manual_seed_all(seed)
35
+ if device.type == "mps" and hasattr(torch.mps, "manual_seed"):
36
+ # Materialize the lazy MPS runtime before setting its RNG. Seeding before
37
+ # the first allocation can otherwise be overwritten during initialization.
38
+ torch.empty((), device="mps")
39
+ torch.mps.synchronize()
40
+ torch.mps.manual_seed(seed)
41
+
42
+
43
+ def capture_rng_state(device: torch.device) -> dict[str, object]:
44
+ state: dict[str, object] = {
45
+ "python": random.getstate(),
46
+ "numpy": np.random.get_state(),
47
+ "torch": torch.get_rng_state(),
48
+ }
49
+ if device.type == "cuda":
50
+ state["cuda"] = torch.cuda.get_rng_state_all()
51
+ if device.type == "mps" and hasattr(torch.mps, "get_rng_state"):
52
+ state["mps"] = torch.mps.get_rng_state()
53
+ return state
54
+
55
+
56
+ def restore_rng_state(state: dict[str, object]) -> None:
57
+ random.setstate(state["python"]) # type: ignore[arg-type]
58
+ np.random.set_state(state["numpy"]) # type: ignore[arg-type]
59
+ torch.set_rng_state(state["torch"].cpu()) # type: ignore[union-attr]
60
+ if torch.cuda.is_available() and "cuda" in state:
61
+ torch.cuda.set_rng_state_all( # type: ignore[arg-type]
62
+ [rng_state.cpu() for rng_state in state["cuda"]] # type: ignore[union-attr]
63
+ )
64
+ if (
65
+ torch.backends.mps.is_available()
66
+ and "mps" in state
67
+ and hasattr(torch.mps, "set_rng_state")
68
+ ):
69
+ torch.mps.set_rng_state(state["mps"].cpu()) # type: ignore[union-attr]
70
+
71
+
72
+ def resolve_device(requested: str) -> torch.device:
73
+ if requested != "auto":
74
+ device = torch.device(requested)
75
+ if device.type == "cuda" and not torch.cuda.is_available():
76
+ raise RuntimeError("CUDA was requested but is unavailable")
77
+ if device.type == "mps" and not torch.backends.mps.is_available():
78
+ raise RuntimeError("MPS was requested but is unavailable")
79
+ return device
80
+ if torch.cuda.is_available():
81
+ return torch.device("cuda")
82
+ if torch.backends.mps.is_available():
83
+ return torch.device("mps")
84
+ return torch.device("cpu")
85
+
86
+
87
+ def resolve_precision(requested: str, device: torch.device) -> str:
88
+ if requested != "auto":
89
+ if device.type == "cpu" and requested == "float16":
90
+ raise ValueError("float16 training on CPU is unsupported; use float32 or bfloat16")
91
+ return requested
92
+ if device.type == "cuda":
93
+ return "bfloat16" if torch.cuda.is_bf16_supported() else "float16"
94
+ # Float32 is the most reliable default for CPU and Apple Silicon in this MVP.
95
+ return "float32"
96
+
97
+
98
+ def autocast_context(device: torch.device, precision: str):
99
+ if precision == "float32":
100
+ return contextlib.nullcontext()
101
+ dtype = {"bfloat16": torch.bfloat16, "float16": torch.float16}[precision]
102
+ return torch.autocast(device_type=device.type, dtype=dtype)
103
+
104
+
105
+ def configure_cuda_backends(device: torch.device, require_fused_attention: bool) -> None:
106
+ """Enable Ampere-friendly kernels and optionally forbid quadratic math attention."""
107
+
108
+ if device.type != "cuda":
109
+ if require_fused_attention:
110
+ raise ValueError("fused attention can only be required on CUDA")
111
+ return
112
+
113
+ torch.set_float32_matmul_precision("high")
114
+ torch.backends.cuda.matmul.allow_tf32 = True
115
+ torch.backends.cudnn.allow_tf32 = True
116
+ torch.backends.cuda.enable_flash_sdp(True)
117
+ torch.backends.cuda.enable_mem_efficient_sdp(True)
118
+ # MultiheadAttention delegates to scaled_dot_product_attention when it is
119
+ # called with need_weights=False (as TransformerEncoderLayer does). When
120
+ # required, disabling math makes an unsupported fused path fail fast rather
121
+ # than silently allocating an L x L attention matrix. Set it in both branches
122
+ # so repeated train()/benchmark calls in one process cannot leak backend state.
123
+ torch.backends.cuda.enable_math_sdp(not require_fused_attention)
124
+
125
+
126
+ def build_optimizer(model: DiffusionTransformer, config: TrainingConfig) -> Optimizer:
127
+ decay: list[Tensor] = []
128
+ no_decay: list[Tensor] = []
129
+ for parameter in model.parameters():
130
+ if not parameter.requires_grad:
131
+ continue
132
+ (decay if parameter.ndim >= 2 else no_decay).append(parameter)
133
+ parameter_groups = [
134
+ {"params": decay, "weight_decay": config.weight_decay},
135
+ {"params": no_decay, "weight_decay": 0.0},
136
+ ]
137
+ common = {
138
+ "lr": config.learning_rate,
139
+ "betas": (0.9, 0.95),
140
+ "eps": 1e-8,
141
+ }
142
+ if config.optimizer == "adamw8bit":
143
+ try:
144
+ import bitsandbytes as bnb
145
+ except ImportError as exc:
146
+ raise RuntimeError(
147
+ 'optimizer=adamw8bit requires bitsandbytes; install the "gpu" extra'
148
+ ) from exc
149
+ if config.optimizer_embedding_32bit:
150
+ # Bitsandbytes recommends higher-precision optimizer state for NLP
151
+ # embeddings. The LM head is tied to this exact Parameter, so one
152
+ # override protects both roles at a modest memory cost.
153
+ manager = bnb.optim.GlobalOptimManager.get_instance()
154
+ manager.register_module_override(
155
+ model.token_embedding,
156
+ "weight",
157
+ {"optim_bits": 32},
158
+ )
159
+ return bnb.optim.AdamW8bit(
160
+ parameter_groups,
161
+ min_8bit_size=config.optimizer_min_8bit_size,
162
+ **common,
163
+ )
164
+ return AdamW(parameter_groups, foreach=False, **common)
165
+
166
+
167
+ def accumulation_mask_probabilities(
168
+ batch_size: int,
169
+ micro_batch_index: int,
170
+ config: TrainingConfig,
171
+ offset: Tensor,
172
+ device: torch.device,
173
+ ) -> Tensor:
174
+ """Stratify diffusion times across a complete gradient-accumulation step."""
175
+
176
+ slots = config.batch_size * config.gradient_accumulation_steps
177
+ start = micro_batch_index * config.batch_size
178
+ indices = torch.arange(start, start + batch_size, device=device, dtype=torch.float32)
179
+ unit = (offset + indices / max(1, slots)) % 1.0
180
+ return config.mask_eps + (1.0 - config.mask_eps) * unit
181
+
182
+
183
+ def learning_rate(step: int, config: TrainingConfig) -> float:
184
+ if step < config.warmup_steps:
185
+ return config.learning_rate * (step + 1) / max(1, config.warmup_steps)
186
+ progress = (step - config.warmup_steps) / max(1, config.max_steps - config.warmup_steps - 1)
187
+ cosine = 0.5 * (1.0 + math.cos(math.pi * min(progress, 1.0)))
188
+ return config.min_learning_rate + cosine * (
189
+ config.learning_rate - config.min_learning_rate
190
+ )
191
+
192
+
193
+ def create_grad_scaler(enabled: bool):
194
+ """Use the unified API when available and retain PyTorch 2.2 support."""
195
+
196
+ unified_scaler = getattr(torch.amp, "GradScaler", None)
197
+ if unified_scaler is not None:
198
+ return unified_scaler("cuda", enabled=enabled)
199
+ return torch.cuda.amp.GradScaler(enabled=enabled)
200
+
201
+
202
+ def validate_inputs(config: ExperimentConfig) -> None:
203
+ tokenizer = load_tokenizer(config.training.tokenizer)
204
+ actual_vocab = tokenizer.get_vocab_size(with_added_tokens=True)
205
+ actual_mask = special_token_id(tokenizer, "mask")
206
+ if actual_vocab != config.model.vocab_size:
207
+ raise ValueError(
208
+ f"config vocab_size is {config.model.vocab_size}, tokenizer has {actual_vocab}; "
209
+ "train the requested tokenizer or update the model budget"
210
+ )
211
+ if actual_mask != config.model.mask_token_id:
212
+ raise ValueError(
213
+ f"config mask_token_id is {config.model.mask_token_id}, tokenizer uses {actual_mask}"
214
+ )
215
+ tokenizer_hash = hashlib.sha256(Path(config.training.tokenizer).read_bytes()).hexdigest()
216
+ for data_path in (config.training.train_data, config.training.val_data):
217
+ if data_path is None:
218
+ continue
219
+ dataset = load_packed_dataset(data_path, config.model.max_seq_len)
220
+ metadata = dataset.metadata
221
+ if int(metadata["vocab_size"]) != config.model.vocab_size:
222
+ raise ValueError(f"{data_path} was encoded with a different vocabulary size")
223
+ if metadata["tokenizer_sha256"] != tokenizer_hash:
224
+ raise ValueError(f"{data_path} was encoded with a different tokenizer file")
225
+
226
+
227
+ @torch.no_grad()
228
+ def evaluate(
229
+ model: DiffusionTransformer,
230
+ loader: DataLoader[Tensor],
231
+ device: torch.device,
232
+ precision: str,
233
+ mask_eps: float,
234
+ max_batches: int,
235
+ ) -> dict[str, float]:
236
+ training_rng_state = capture_rng_state(device)
237
+ was_training = model.training
238
+ try:
239
+ # Fixed corruption masks make validation checkpoints directly comparable.
240
+ # The complete caller RNG state is restored below, so evaluation remains
241
+ # invisible to the subsequent training trajectory.
242
+ seed_everything(0, device)
243
+ model.eval()
244
+ losses: list[float] = []
245
+ correct_weighted = 0.0
246
+ masked_total = 0
247
+ for batch_index, clean_tokens in enumerate(loader):
248
+ if batch_index >= max_batches:
249
+ break
250
+ clean_tokens = clean_tokens.to(device, non_blocking=True)
251
+ # Cover the full noise range deterministically even when evaluation uses
252
+ # microbatch one. Random batch-1 evaluation can otherwise miss the hard
253
+ # near-fully-masked regime for many consecutive checkpoints.
254
+ level = mask_eps + (1.0 - mask_eps) * (batch_index + 0.5) / max_batches
255
+ mask_probability = torch.full(
256
+ (clean_tokens.shape[0],), level, device=device, dtype=torch.float32
257
+ )
258
+ corruption = corrupt_tokens(
259
+ clean_tokens,
260
+ model.config.mask_token_id,
261
+ mask_probability=mask_probability,
262
+ eps=mask_eps,
263
+ )
264
+ with autocast_context(device, precision):
265
+ logits = model(corruption.noisy_tokens, output_positions=corruption.mask)
266
+ output = diffusion_cross_entropy(logits, clean_tokens, corruption)
267
+ losses.append(float(output.loss))
268
+ correct_weighted += float(output.masked_accuracy) * output.masked_tokens
269
+ masked_total += output.masked_tokens
270
+ return {
271
+ "loss": sum(losses) / max(1, len(losses)),
272
+ "masked_accuracy": correct_weighted / max(1, masked_total),
273
+ }
274
+ finally:
275
+ restore_rng_state(training_rng_state)
276
+ model.train(was_training)
277
+
278
+
279
+ def save_checkpoint(
280
+ output_dir: Path,
281
+ model: DiffusionTransformer,
282
+ optimizer: Optimizer,
283
+ scaler,
284
+ experiment: ExperimentConfig,
285
+ step: int,
286
+ tokens_seen: int,
287
+ keep_last_checkpoints: int,
288
+ data_generator: torch.Generator,
289
+ micro_batches_seen: int,
290
+ ) -> Path:
291
+ output_dir.mkdir(parents=True, exist_ok=True)
292
+ checkpoint = {
293
+ "format": "mini-diffusion-lm-checkpoint-v1",
294
+ "step": step,
295
+ "tokens_seen": tokens_seen,
296
+ "config": experiment.to_dict(),
297
+ "tokenizer_sha256": hashlib.sha256(
298
+ Path(experiment.training.tokenizer).read_bytes()
299
+ ).hexdigest(),
300
+ "rng_state": capture_rng_state(next(model.parameters()).device),
301
+ "data_generator_state": data_generator.get_state(),
302
+ "micro_batches_seen": micro_batches_seen,
303
+ "model": model.state_dict(),
304
+ "optimizer": optimizer.state_dict(),
305
+ "scaler": scaler.state_dict(),
306
+ }
307
+ numbered_path = output_dir / f"step-{step:08d}.pt"
308
+ temporary_path = output_dir / ".checkpoint.tmp"
309
+ torch.save(checkpoint, temporary_path)
310
+ temporary_path.replace(numbered_path)
311
+
312
+ # Keep `latest.pt` as a hard link when possible so a large optimizer state is
313
+ # not stored twice. The temporary name makes replacement atomic.
314
+ latest_path = output_dir / "latest.pt"
315
+ latest_temporary = output_dir / ".latest.tmp"
316
+ latest_temporary.unlink(missing_ok=True)
317
+ try:
318
+ latest_temporary.hardlink_to(numbered_path)
319
+ except OSError:
320
+ shutil.copyfile(numbered_path, latest_temporary)
321
+ latest_temporary.replace(latest_path)
322
+
323
+ if keep_last_checkpoints:
324
+ numbered_checkpoints = sorted(output_dir.glob("step-*.pt"))
325
+ for old_checkpoint in numbered_checkpoints[:-keep_last_checkpoints]:
326
+ old_checkpoint.unlink()
327
+ if experiment.training.save_inference_checkpoint:
328
+ save_inference_checkpoint(output_dir, model, experiment, step, tokens_seen)
329
+ return numbered_path
330
+
331
+
332
+ def _inference_state_dict(model: DiffusionTransformer) -> dict[str, Tensor]:
333
+ """Copy weights to CPU BF16 while preserving tied tensor storage."""
334
+
335
+ converted: dict[str, Tensor] = {}
336
+ shared: dict[tuple[object, ...], Tensor] = {}
337
+ for name, tensor in model.state_dict().items():
338
+ key = (
339
+ tensor.untyped_storage().data_ptr(),
340
+ tensor.storage_offset(),
341
+ tuple(tensor.shape),
342
+ tuple(tensor.stride()),
343
+ )
344
+ value = shared.get(key)
345
+ if value is None:
346
+ dtype = torch.bfloat16 if tensor.is_floating_point() else tensor.dtype
347
+ value = tensor.detach().to(device="cpu", dtype=dtype)
348
+ shared[key] = value
349
+ converted[name] = value
350
+ return converted
351
+
352
+
353
+ def save_inference_checkpoint(
354
+ output_dir: Path,
355
+ model: DiffusionTransformer,
356
+ experiment: ExperimentConfig,
357
+ step: int,
358
+ tokens_seen: int,
359
+ ) -> Path:
360
+ """Write a compact weights-only checkpoint for sampling and the playground."""
361
+
362
+ path = output_dir / "inference-latest.pt"
363
+ temporary = output_dir / ".inference.tmp"
364
+ state = _inference_state_dict(model)
365
+ payload = {
366
+ "format": "mini-diffusion-lm-inference-v1",
367
+ "step": step,
368
+ "tokens_seen": tokens_seen,
369
+ "config": experiment.to_dict(),
370
+ "tokenizer_sha256": hashlib.sha256(
371
+ Path(experiment.training.tokenizer).read_bytes()
372
+ ).hexdigest(),
373
+ "model": state,
374
+ }
375
+ torch.save(payload, temporary)
376
+ temporary.replace(path)
377
+ del payload, state
378
+ gc.collect()
379
+ return path
380
+
381
+
382
+ def train(
383
+ experiment: ExperimentConfig,
384
+ resume: str | Path | None = None,
385
+ max_run_steps: int | None = None,
386
+ ) -> Path:
387
+ config = experiment.training
388
+ if max_run_steps is not None and max_run_steps <= 0:
389
+ raise ValueError("max_run_steps must be positive")
390
+ device = resolve_device(config.device)
391
+ seed_everything(config.seed, device)
392
+ configure_cuda_backends(device, config.require_fused_attention)
393
+ validate_inputs(experiment)
394
+
395
+ precision = resolve_precision(config.precision, device)
396
+ train_dataset = load_packed_dataset(config.train_data, experiment.model.max_seq_len)
397
+ data_generator = torch.Generator().manual_seed(config.seed)
398
+ train_batch_sampler = DeterministicBatchSampler(
399
+ len(train_dataset), config.batch_size, seed=config.seed
400
+ )
401
+ train_loader = DataLoader(
402
+ train_dataset,
403
+ batch_sampler=train_batch_sampler,
404
+ num_workers=config.num_workers,
405
+ pin_memory=device.type == "cuda",
406
+ generator=data_generator,
407
+ )
408
+
409
+ val_loader = None
410
+ if config.val_data is not None:
411
+ val_dataset = load_packed_dataset(config.val_data, experiment.model.max_seq_len)
412
+ val_loader = DataLoader(
413
+ val_dataset,
414
+ batch_size=config.batch_size,
415
+ shuffle=False,
416
+ num_workers=config.num_workers,
417
+ pin_memory=device.type == "cuda",
418
+ )
419
+
420
+ model = DiffusionTransformer(experiment.model).to(device)
421
+ optimizer = build_optimizer(model, config)
422
+ scaler = create_grad_scaler(device.type == "cuda" and precision == "float16")
423
+ start_step = 0
424
+ tokens_seen = 0
425
+ micro_batches_seen = 0
426
+ if resume is not None:
427
+ checkpoint = torch.load(resume, map_location="cpu", weights_only=False)
428
+ if checkpoint.get("format") != "mini-diffusion-lm-checkpoint-v1":
429
+ raise ValueError("unsupported checkpoint format")
430
+ if ModelConfig(**checkpoint["config"]["model"]) != experiment.model:
431
+ raise ValueError("checkpoint model configuration does not match the requested config")
432
+ checkpoint_training = checkpoint["config"].get("training", {})
433
+ checkpoint_optimizer = checkpoint_training.get("optimizer", "adamw")
434
+ if checkpoint_optimizer != config.optimizer:
435
+ raise ValueError(
436
+ f"checkpoint optimizer is {checkpoint_optimizer}, requested {config.optimizer}"
437
+ )
438
+ if config.optimizer == "adamw8bit":
439
+ checkpoint_min_size = int(
440
+ checkpoint_training.get("optimizer_min_8bit_size", 4096)
441
+ )
442
+ checkpoint_embedding_32bit = bool(
443
+ # An absent legacy field means no explicit 32-bit override was
444
+ # guaranteed. Never silently reinterpret it as the safer setting.
445
+ checkpoint_training.get("optimizer_embedding_32bit", False)
446
+ )
447
+ if checkpoint_min_size != config.optimizer_min_8bit_size:
448
+ raise ValueError("checkpoint 8-bit optimizer minimum tensor size does not match")
449
+ if checkpoint_embedding_32bit != config.optimizer_embedding_32bit:
450
+ raise ValueError("checkpoint embedding optimizer precision does not match")
451
+ current_tokenizer_hash = hashlib.sha256(Path(config.tokenizer).read_bytes()).hexdigest()
452
+ if checkpoint.get("tokenizer_sha256") != current_tokenizer_hash:
453
+ raise ValueError("checkpoint was trained with a different tokenizer")
454
+ model.load_state_dict(checkpoint["model"])
455
+ optimizer.load_state_dict(checkpoint["optimizer"])
456
+ optimizer.param_groups[0]["weight_decay"] = config.weight_decay
457
+ optimizer.param_groups[1]["weight_decay"] = 0.0
458
+ for group in optimizer.param_groups:
459
+ group["betas"] = (0.9, 0.95)
460
+ group["eps"] = 1e-8
461
+ scaler.load_state_dict(checkpoint.get("scaler", {}))
462
+ if "data_generator_state" in checkpoint:
463
+ data_generator.set_state(checkpoint["data_generator_state"].cpu())
464
+ if "rng_state" in checkpoint:
465
+ restore_rng_state(checkpoint["rng_state"])
466
+ start_step = int(checkpoint["step"]) + 1
467
+ tokens_seen = int(checkpoint.get("tokens_seen", 0))
468
+ micro_batches_seen = int(
469
+ checkpoint.get(
470
+ "micro_batches_seen",
471
+ start_step * int(checkpoint["config"]["training"]["gradient_accumulation_steps"]),
472
+ )
473
+ )
474
+ del checkpoint
475
+ gc.collect()
476
+
477
+ train_batch_sampler.start_batch = micro_batches_seen
478
+ train_iterator = iter(train_loader)
479
+
480
+ output_dir = Path(config.output_dir)
481
+ output_dir.mkdir(parents=True, exist_ok=True)
482
+ with (output_dir / "config.json").open("w", encoding="utf-8") as handle:
483
+ json.dump(experiment.to_dict(), handle, indent=2)
484
+ handle.write("\n")
485
+
486
+ print(
487
+ json.dumps(
488
+ {
489
+ "event": "start",
490
+ "device": str(device),
491
+ "precision": precision,
492
+ "parameters": model.num_parameters,
493
+ "parameters_human": format_parameter_count(model.num_parameters),
494
+ "optimizer": config.optimizer,
495
+ "activation_checkpointing": experiment.model.activation_checkpointing,
496
+ "fused_attention_required": config.require_fused_attention,
497
+ "training_blocks": len(train_dataset),
498
+ "start_step": start_step,
499
+ }
500
+ )
501
+ )
502
+
503
+ last_checkpoint = output_dir / "latest.pt"
504
+ model.train()
505
+ log_started = time.perf_counter()
506
+ log_loss = torch.zeros((), device=device)
507
+ log_accuracy = torch.zeros((), device=device)
508
+ log_tokens = 0
509
+ end_step = config.max_steps
510
+ if max_run_steps is not None:
511
+ end_step = min(end_step, start_step + max_run_steps)
512
+ for step in range(start_step, end_step):
513
+ lr = learning_rate(step, config)
514
+ for group in optimizer.param_groups:
515
+ group["lr"] = lr
516
+ optimizer.zero_grad(set_to_none=True)
517
+ step_loss = torch.zeros((), device=device)
518
+ step_accuracy = torch.zeros((), device=device)
519
+ noise_offset = torch.rand((), device=device)
520
+
521
+ for micro_batch_index in range(config.gradient_accumulation_steps):
522
+ clean_tokens = next(train_iterator).to(device, non_blocking=True)
523
+ micro_batches_seen += 1
524
+ mask_probability = accumulation_mask_probabilities(
525
+ clean_tokens.shape[0],
526
+ micro_batch_index,
527
+ config,
528
+ noise_offset,
529
+ device,
530
+ )
531
+ corruption = corrupt_tokens(
532
+ clean_tokens,
533
+ experiment.model.mask_token_id,
534
+ mask_probability=mask_probability,
535
+ eps=config.mask_eps,
536
+ )
537
+ with autocast_context(device, precision):
538
+ logits = model(corruption.noisy_tokens, output_positions=corruption.mask)
539
+ output = diffusion_cross_entropy(logits, clean_tokens, corruption)
540
+ scaled_loss = output.loss / config.gradient_accumulation_steps
541
+ scaler.scale(scaled_loss).backward()
542
+ step_loss += output.loss.detach() / config.gradient_accumulation_steps
543
+ step_accuracy += output.masked_accuracy.detach() / config.gradient_accumulation_steps
544
+ tokens_seen += clean_tokens.numel()
545
+ log_tokens += clean_tokens.numel()
546
+
547
+ scaler.unscale_(optimizer)
548
+ grad_norm = torch.nn.utils.clip_grad_norm_(model.parameters(), config.grad_clip)
549
+ scaler.step(optimizer)
550
+ scaler.update()
551
+ log_loss += step_loss
552
+ log_accuracy += step_accuracy
553
+
554
+ if (step + 1) % config.log_interval == 0:
555
+ elapsed = time.perf_counter() - log_started
556
+ print(
557
+ json.dumps(
558
+ {
559
+ "event": "train",
560
+ "step": step + 1,
561
+ "loss": float(log_loss / config.log_interval),
562
+ "masked_accuracy": float(log_accuracy / config.log_interval),
563
+ "learning_rate": lr,
564
+ "grad_norm": float(grad_norm),
565
+ "tokens_seen": tokens_seen,
566
+ "tokens_per_second": log_tokens / max(elapsed, 1e-9),
567
+ "memory_allocated_gib": (
568
+ torch.cuda.memory_allocated(device) / 1024**3
569
+ if device.type == "cuda"
570
+ else 0.0
571
+ ),
572
+ "memory_reserved_gib": (
573
+ torch.cuda.memory_reserved(device) / 1024**3
574
+ if device.type == "cuda"
575
+ else 0.0
576
+ ),
577
+ "peak_memory_allocated_gib": (
578
+ torch.cuda.max_memory_allocated(device) / 1024**3
579
+ if device.type == "cuda"
580
+ else 0.0
581
+ ),
582
+ }
583
+ )
584
+ )
585
+ log_started = time.perf_counter()
586
+ log_loss.zero_()
587
+ log_accuracy.zero_()
588
+ log_tokens = 0
589
+
590
+ if val_loader is not None and (step + 1) % config.eval_interval == 0:
591
+ metrics = evaluate(
592
+ model,
593
+ val_loader,
594
+ device,
595
+ precision,
596
+ config.mask_eps,
597
+ config.eval_batches,
598
+ )
599
+ print(json.dumps({"event": "validation", "step": step + 1, **metrics}))
600
+
601
+ if (step + 1) % config.save_interval == 0:
602
+ last_checkpoint = save_checkpoint(
603
+ output_dir,
604
+ model,
605
+ optimizer,
606
+ scaler,
607
+ experiment,
608
+ step,
609
+ tokens_seen,
610
+ config.keep_last_checkpoints,
611
+ data_generator,
612
+ micro_batches_seen,
613
+ )
614
+ print(json.dumps({"event": "checkpoint", "path": str(last_checkpoint)}))
615
+
616
+ final_step = end_step - 1
617
+ if final_step < start_step:
618
+ raise ValueError("checkpoint step is already at or beyond max_steps")
619
+ if not last_checkpoint.exists() or (final_step + 1) % config.save_interval != 0:
620
+ last_checkpoint = save_checkpoint(
621
+ output_dir,
622
+ model,
623
+ optimizer,
624
+ scaler,
625
+ experiment,
626
+ final_step,
627
+ tokens_seen,
628
+ config.keep_last_checkpoints,
629
+ data_generator,
630
+ micro_batches_seen,
631
+ )
632
+ event = "complete" if end_step == config.max_steps else "paused"
633
+ print(json.dumps({"event": event, "checkpoint": str(last_checkpoint)}))
634
+ return last_checkpoint
635
+
636
+
637
+ def main() -> None:
638
+ parser = argparse.ArgumentParser(description=__doc__)
639
+ parser.add_argument("--config", type=Path, required=True)
640
+ parser.add_argument("--resume", type=Path)
641
+ parser.add_argument(
642
+ "--max-run-steps",
643
+ type=int,
644
+ help="stop safely after this many optimizer steps (useful for scheduled jobs/tests)",
645
+ )
646
+ parser.add_argument("--device", help="override config device, e.g. cpu, mps, cuda")
647
+ parser.add_argument(
648
+ "--precision",
649
+ choices=("auto", "float32", "bfloat16", "float16"),
650
+ help="override config precision",
651
+ )
652
+ args = parser.parse_args()
653
+
654
+ experiment = load_config(args.config)
655
+ if args.device or args.precision:
656
+ training = replace(
657
+ experiment.training,
658
+ device=args.device or experiment.training.device,
659
+ precision=args.precision or experiment.training.precision,
660
+ )
661
+ experiment = replace(experiment, training=training)
662
+ train(experiment, resume=args.resume, max_run_steps=args.max_run_steps)
663
+
664
+
665
+ if __name__ == "__main__":
666
+ main()
tokenizer-qwen3-adaptive.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:561587f831d1efad45896c86901cc142551f8f22c8ddbe097890f8f0f0c1bf15
3
+ size 11424780