File size: 10,437 Bytes
aef8188
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
"""Generation loop of the adaptive hybrid: AR control, in-block denoising, AR answer.

Numpy port of the inference paths in ``diffusion_lm.hybrid`` and the playground's
streaming loop. Sampling stays in fp64 (Gumbel argmax, the reference semantics of the
torch backend); the RNG is a ``numpy.random.Generator``, so sampled transcripts match the
torch serving path statistically, not token-for-token.
"""

from __future__ import annotations

import time

import numpy as np

from masks import adaptive_block_mask, prefix_causal_blocked
from model import Denoiser

FLOAT_MIN = np.finfo(np.float32).min


def sample_categorical(logits: np.ndarray, temperature: float, rng: np.random.Generator):
    """Sample one token per row with fp64 Gumbel noise; argmax at temperature zero."""

    if temperature < 0:
        raise ValueError('temperature must be non-negative')
    if temperature == 0:
        return logits.argmax(axis=-1)
    scaled = logits.astype(np.float64) / temperature
    uniform = rng.random(scaled.shape).clip(1e-12, 1.0 - 1e-12)
    return (scaled - np.log(-np.log(uniform))).argmax(axis=-1)


def apply_repetition_penalty(
    logits: np.ndarray, token_ids: list[int], penalty: float
) -> np.ndarray:
    """Divide logits of already-emitted tokens by ``penalty`` (CTRL convention)."""

    if penalty == 1.0 or not token_ids:
        return logits
    index = np.asarray(sorted(set(token_ids)))
    adjusted = logits.copy()
    selected = logits[index]
    adjusted[index] = np.where(selected > 0, selected / penalty, selected * penalty)
    return adjusted


def apply_top_p(logits: np.ndarray, top_p: float) -> np.ndarray:
    """Restrict sampling to the smallest set of tokens whose mass reaches ``top_p``."""

    if top_p >= 1.0:
        return logits
    order = np.argsort(-logits, kind='stable')
    ordered = logits[order].astype(np.float64)
    shifted = np.exp(ordered - ordered.max())
    probabilities = shifted / shifted.sum()
    remove = probabilities.cumsum() - probabilities >= top_p
    ordered = np.where(remove, FLOAT_MIN, ordered)
    restricted = np.empty_like(logits)
    restricted[order] = ordered.astype(logits.dtype)
    return restricted


def predict_control(
    denoiser: Denoiser,
    sequence: list[int],
    control_ids: list[int],
    *,
    prefix_len: int,
    causal_prefix: bool,
    temperature: float,
    rng: np.random.Generator,
) -> int:
    """Predict the next control token, restricted to the size/stop menu."""

    seq_len = len(sequence)
    blocked = prefix_causal_blocked(prefix_len, seq_len, causal_prefix=causal_prefix)
    logits = denoiser.logits(sequence, blocked, [seq_len - 1])[0]
    restricted = np.full_like(logits, FLOAT_MIN)
    index = np.asarray(control_ids)
    restricted[index] = logits[index]
    return int(sample_categorical(restricted[None, :], temperature, rng)[0])


def denoise_block_steps(
    denoiser: Denoiser,
    sequence: list[int],
    size: int,
    *,
    problem_len: int,
    steps: int,
    temperature: float,
    causal_prefix: bool,
    rng: np.random.Generator,
    cache_prefix: bool = False,
):
    """Yield ``(step, block_ids)`` while ancestrally denoising one ``<szN>`` block.

    The ancestral reverse transition reveals each still-masked position with probability
    ``1 / steps_left``, so the final step always completes the block. ``cache_prefix``
    encodes the context once per block and re-feeds the changing rows each step, trimming
    the cache back in between. The cache boundary sits BEFORE the ``<szN>`` token: the
    trained mask groups that token with its own block, attended bidirectionally, so its
    keys depend on the block's current state and must be recomputed every step — priming
    it with the context would freeze a key that never saw the block. Everything earlier
    never attends forward into the new block, which is what makes its cache sound.
    """

    mdlm = denoiser.config['mdlm']
    mask_id = mdlm['mask_token_id']
    size_ids = tuple(int(i) for i in mdlm['size_ids'].values())
    window = np.asarray([*sequence, *([mask_id] * size)], dtype=np.int64)
    window_prefix = len(sequence)
    prime_len = window_prefix - 1
    blocked = adaptive_block_mask(
        window, problem_len, window.shape[0], size_ids, mdlm['end_think_id'],
        causal_prefix=causal_prefix,
    )
    caches = None
    if cache_prefix:
        caches = denoiser.new_caches()
        denoiser.logits(window[:prime_len], blocked[:prime_len, :prime_len], None, caches)

    for step in range(steps):
        masked = window == mask_id
        if not masked.any():
            break
        if caches is not None:
            denoiser.trim_caches(caches, prime_len)
            positions = np.flatnonzero(masked[prime_len:])
            logits = denoiser.logits(
                window[prime_len:], blocked[prime_len:, :], positions, caches
            )
        else:
            logits = denoiser.logits(window, blocked, np.flatnonzero(masked))
        predictions = sample_categorical(logits, temperature, rng)
        proposed = window.copy()
        proposed[masked] = predictions
        reveal_probability = 1.0 / (steps - step)
        reveal = (rng.random(window.shape[0]) < reveal_probability) & masked
        window = np.where(reveal, proposed, window)
        yield step + 1, [int(t) for t in window[window_prefix:]]

    if (window == mask_id).any():
        raise RuntimeError('sampler finished with masked positions')


def decode_answer_steps(
    denoiser: Denoiser,
    sequence: list[int],
    *,
    prefix_len: int,
    stop_ids: tuple[int, ...],
    max_new_tokens: int,
    temperature: float,
    top_p: float,
    repetition_penalty: float,
    causal_prefix: bool,
    rng: np.random.Generator,
):
    """Yield answer tokens decoded autoregressively with the prefix in a key/value cache."""

    caches = denoiser.new_caches()
    generated: list[int] = []
    step_ids = list(sequence)
    for _ in range(max(0, max_new_tokens)):
        seen = caches[0].offset
        length = seen + len(step_ids)
        blocked = prefix_causal_blocked(prefix_len, length, causal_prefix=causal_prefix)
        logits = denoiser.logits(step_ids, blocked[seen:, :], [len(step_ids) - 1], caches)[0]
        logits = apply_repetition_penalty(logits, generated, repetition_penalty)
        logits = apply_top_p(logits, top_p)
        token = int(sample_categorical(logits[None, :], temperature, rng)[0])
        generated.append(token)
        yield token
        if token in stop_ids:
            break
        step_ids = [token]


def block_contents(
    think_ids: list[int], *, size_by_id: dict[int, int], pad_id: int, end_id: int
) -> list[tuple[int, list[int]]]:
    """Split the think stream into ``(size, content ids)`` blocks by walking ``<szN>``."""

    blocks = []
    cut = 0
    while cut < len(think_ids):
        size = size_by_id.get(think_ids[cut])
        if size is None:
            cut += 1
            continue
        block = think_ids[cut + 1 : cut + 1 + size]
        blocks.append((size, [t for t in block if t not in (pad_id, end_id)]))
        cut += 1 + size
    return blocks


def stream_turn(
    denoiser: Denoiser,
    prompt_ids: list[int],
    *,
    rng: np.random.Generator,
    temperature: float = 0.8,
    top_p: float = 0.95,
    repetition_penalty: float = 1.0,
    steps_per_block: int | None = None,
    max_answer_tokens: int | None = None,
    max_blocks: int = 48,
    # Prefix caching during denoising measured 1.7x faster per block on M1 Pro (2026-08-14,
    # 2.64s vs 4.44s for sz32 at prefix 500) with cached==uncached logits at fp32 noise level.
    cache_blocks: bool = True,
):
    """Stream one assistant turn: control loop, per-block denoising, AR answer.

    Yields ``('block', index, size, step, total, block_ids)`` during thinking,
    ``('answer', token_id)`` per answer token, and one final ``('done', result)`` where
    ``result`` carries the think/answer ids, per-block notes, and wall times.
    """

    mdlm = denoiser.config['mdlm']
    max_seq_len = mdlm['max_seq_len']
    end_think_id = mdlm['end_think_id']
    size_by_id = {int(i): int(size) for size, i in mdlm['size_ids'].items()}
    control_ids = [*size_by_id.keys(), end_think_id]
    causal_prefix = mdlm['causal_prefix']
    steps = steps_per_block or mdlm['steps_per_block']
    answer_limit = max_answer_tokens or mdlm['max_answer_tokens']

    sequence = [*prompt_ids, mdlm['think_id']]
    prefix_len = len(prompt_ids) + 1
    terminated = False
    think_started = time.perf_counter()
    for block_index in range(max_blocks):
        control = predict_control(
            denoiser, sequence, control_ids,
            prefix_len=prefix_len, causal_prefix=causal_prefix, temperature=0.0, rng=rng,
        )
        if control == end_think_id:
            terminated = True
            break
        size = size_by_id[control]
        if len(sequence) + 1 + size > max_seq_len - 8:
            break
        sequence.append(control)
        block: list[int] = []
        for step, block_ids in denoise_block_steps(
            denoiser, sequence, size,
            problem_len=len(prompt_ids), steps=steps, temperature=temperature,
            causal_prefix=causal_prefix, rng=rng, cache_prefix=cache_blocks,
        ):
            block = block_ids
            yield 'block', block_index, size, step, steps, block_ids
        sequence.extend(block)
    sequence.append(end_think_id)
    think_seconds = time.perf_counter() - think_started
    think_ids = sequence[prefix_len:]

    answer_ids: list[int] = []
    answer_started = time.perf_counter()
    for token in decode_answer_steps(
        denoiser, sequence,
        prefix_len=prefix_len, stop_ids=(mdlm['im_end_id'], mdlm['eos_id']),
        max_new_tokens=min(answer_limit, max_seq_len - len(sequence)),
        temperature=temperature, top_p=top_p, repetition_penalty=repetition_penalty,
        causal_prefix=causal_prefix, rng=rng,
    ):
        answer_ids.append(token)
        yield 'answer', token

    yield 'done', {
        'think_ids': think_ids,
        'answer_ids': answer_ids,
        'blocks': block_contents(
            think_ids, size_by_id=size_by_id,
            pad_id=mdlm['thought_pad_id'], end_id=end_think_id,
        ),
        'terminated': terminated,
        'think_seconds': think_seconds,
        'answer_seconds': time.perf_counter() - answer_started,
    }