|
| 1 | +"""Tests that ``HybridTinyBlock`` / ``HybridTinyLM`` thread ``document_ids`` |
| 2 | +into the Engram (``N`` symbol) block so that n-gram aggregation respects |
| 3 | +packed-document boundaries. |
| 4 | +
|
| 5 | +Mirrors the kwarg convention used in nanochat |
| 6 | +(``nanochat/engram.py::EngramBranch.forward(self, x, doc_ids=None)`` and |
| 7 | +``nanochat/unified_superblock.py`` which calls ``self.engram(x_norm, doc_ids=doc_ids)``): |
| 8 | +the parent stack carries the raw ``(B, S)`` int document IDs and only the |
| 9 | +engram branch consumes them via the ``doc_ids`` kwarg. |
| 10 | +""" |
| 11 | + |
| 12 | +from __future__ import annotations |
| 13 | + |
| 14 | +import mlx.core as mx |
| 15 | +import pytest |
| 16 | + |
| 17 | +from cppmega_mlx.models.hybrid_lm import ( |
| 18 | + HybridTinyBlock, |
| 19 | + HybridTinyConfig, |
| 20 | + HybridTinyLM, |
| 21 | +) |
| 22 | +from cppmega_mlx.nn.engram import EngramBranch |
| 23 | + |
| 24 | + |
| 25 | +def _engram_only_config() -> HybridTinyConfig: |
| 26 | + return HybridTinyConfig( |
| 27 | + vocab_size=16, |
| 28 | + hidden_size=8, |
| 29 | + pattern="N", |
| 30 | + depth=1, |
| 31 | + num_attention_heads=2, |
| 32 | + max_seq_length=8, |
| 33 | + moe_num_experts=2, |
| 34 | + moe_top_k=1, |
| 35 | + moe_expert_hidden_size=4, |
| 36 | + moe_shared_expert_hidden_size=None, |
| 37 | + m2rnn_k_head_dim=2, |
| 38 | + m2rnn_v_head_dim=2, |
| 39 | + engram_ngram_orders=(2, 3), |
| 40 | + engram_conv_kernel=0, |
| 41 | + ) |
| 42 | + |
| 43 | + |
| 44 | +def _make_engram_model(*, conv_kernel: int = 0) -> HybridTinyLM: |
| 45 | + """Build an engram-only model and randomize ``out_proj`` so the branch is |
| 46 | + not a degenerate identity / all-zero map at init (the upstream |
| 47 | + ``EngramBranch`` deliberately zero-inits ``out_proj`` so the residual |
| 48 | + branch is identity at step 0).""" |
| 49 | + |
| 50 | + cfg = HybridTinyConfig( |
| 51 | + vocab_size=16, |
| 52 | + hidden_size=8, |
| 53 | + pattern="N", |
| 54 | + depth=1, |
| 55 | + num_attention_heads=2, |
| 56 | + max_seq_length=8, |
| 57 | + moe_num_experts=2, |
| 58 | + moe_top_k=1, |
| 59 | + moe_expert_hidden_size=4, |
| 60 | + moe_shared_expert_hidden_size=None, |
| 61 | + m2rnn_k_head_dim=2, |
| 62 | + m2rnn_v_head_dim=2, |
| 63 | + engram_ngram_orders=(2, 3), |
| 64 | + engram_conv_kernel=conv_kernel, |
| 65 | + ) |
| 66 | + model = HybridTinyLM(cfg) |
| 67 | + engram = model.layers[0].block |
| 68 | + assert isinstance(engram, EngramBranch) |
| 69 | + mx.random.seed(7) |
| 70 | + engram.out_proj.weight = mx.random.normal( |
| 71 | + engram.out_proj.weight.shape, dtype=engram.out_proj.weight.dtype |
| 72 | + ) * 0.3 |
| 73 | + return model |
| 74 | + |
| 75 | + |
| 76 | +# --------------------------------------------------------------------------- |
| 77 | +# (1) doc_ids actually flow through the model into the engram block |
| 78 | +# --------------------------------------------------------------------------- |
| 79 | + |
| 80 | + |
| 81 | +def test_engram_doc_ids_change_hidden_states(): |
| 82 | + model = _make_engram_model() |
| 83 | + mx.random.seed(0) |
| 84 | + input_ids = mx.random.randint(0, 16, shape=(1, 6)) |
| 85 | + document_ids = mx.array([[0, 0, 0, 1, 1, 1]], dtype=mx.int32) |
| 86 | + |
| 87 | + out_no_docs = model.decoder_hidden_states(input_ids) |
| 88 | + out_with_docs = model.decoder_hidden_states(input_ids, document_ids=document_ids) |
| 89 | + mx.eval(out_no_docs, out_with_docs) |
| 90 | + |
| 91 | + # The two outputs must differ — the engram n-gram averages span the |
| 92 | + # 0->1 doc boundary in the unmasked case and get clipped in the masked |
| 93 | + # case. We check absolute diff at the positions that the order=2/3 |
| 94 | + # average windows touch around the boundary (positions 3 and 4). |
| 95 | + diff = mx.max(mx.abs(out_no_docs - out_with_docs), axis=-1) |
| 96 | + mx.eval(diff) |
| 97 | + assert float(diff[0, 3].item()) > 1e-4, ( |
| 98 | + "engram delta at position 3 should change when its order>=2 average " |
| 99 | + "stops pulling in the prior document's tokens" |
| 100 | + ) |
| 101 | + |
| 102 | + |
| 103 | +def test_engram_doc_ids_flow_through_conv_path(): |
| 104 | + """Same as above but with the depthwise causal conv branch active so the |
| 105 | + other doc_ids-aware code path inside ``EngramBranch`` is exercised too.""" |
| 106 | + |
| 107 | + model = _make_engram_model(conv_kernel=3) |
| 108 | + mx.random.seed(1) |
| 109 | + input_ids = mx.random.randint(0, 16, shape=(1, 6)) |
| 110 | + document_ids = mx.array([[0, 0, 0, 1, 1, 1]], dtype=mx.int32) |
| 111 | + |
| 112 | + out_no_docs = model.decoder_hidden_states(input_ids) |
| 113 | + out_with_docs = model.decoder_hidden_states(input_ids, document_ids=document_ids) |
| 114 | + mx.eval(out_no_docs, out_with_docs) |
| 115 | + |
| 116 | + diff = mx.max(mx.abs(out_no_docs - out_with_docs)) |
| 117 | + mx.eval(diff) |
| 118 | + assert float(diff.item()) > 1e-4 |
| 119 | + |
| 120 | + |
| 121 | +# --------------------------------------------------------------------------- |
| 122 | +# (2) No engram in the stack -> document_ids has no effect on non-attention |
| 123 | +# hidden states (we use a pattern that contains no attention layer so |
| 124 | +# the attention additive-mask path is not exercised either). |
| 125 | +# --------------------------------------------------------------------------- |
| 126 | + |
| 127 | + |
| 128 | +def test_document_ids_noop_when_no_engram_no_attention(): |
| 129 | + cfg = HybridTinyConfig( |
| 130 | + vocab_size=16, |
| 131 | + hidden_size=8, |
| 132 | + pattern="C", |
| 133 | + depth=1, |
| 134 | + num_attention_heads=2, |
| 135 | + max_seq_length=8, |
| 136 | + moe_num_experts=2, |
| 137 | + moe_top_k=1, |
| 138 | + moe_expert_hidden_size=4, |
| 139 | + moe_shared_expert_hidden_size=None, |
| 140 | + m2rnn_k_head_dim=2, |
| 141 | + m2rnn_v_head_dim=2, |
| 142 | + concept_num_concepts=4, |
| 143 | + concept_num_heads=2, |
| 144 | + ) |
| 145 | + model = HybridTinyLM(cfg) |
| 146 | + assert tuple(layer.backend for layer in model.layers) == ("concept",) |
| 147 | + |
| 148 | + mx.random.seed(2) |
| 149 | + input_ids = mx.random.randint(0, 16, shape=(1, 6)) |
| 150 | + document_ids = mx.array([[0, 0, 0, 1, 1, 1]], dtype=mx.int32) |
| 151 | + |
| 152 | + out_no_docs = model.decoder_hidden_states(input_ids) |
| 153 | + out_with_docs = model.decoder_hidden_states(input_ids, document_ids=document_ids) |
| 154 | + mx.eval(out_no_docs, out_with_docs) |
| 155 | + |
| 156 | + assert mx.allclose(out_no_docs, out_with_docs, atol=0.0, rtol=0.0).item() |
| 157 | + |
| 158 | + |
| 159 | +# --------------------------------------------------------------------------- |
| 160 | +# (3) Regression: calling the block / model without doc_ids still produces |
| 161 | +# the same shape and dtype it always did. |
| 162 | +# --------------------------------------------------------------------------- |
| 163 | + |
| 164 | + |
| 165 | +def test_engram_block_without_doc_ids_unchanged_shape_dtype(): |
| 166 | + cfg = _engram_only_config() |
| 167 | + layer = cfg.expanded_pattern().layers[0] |
| 168 | + block = HybridTinyBlock(layer, cfg) |
| 169 | + assert block.backend == "engram" |
| 170 | + |
| 171 | + x = mx.random.normal((2, 6, cfg.hidden_size)) |
| 172 | + delta_no_docs = block.route_delta(x, mask=None) |
| 173 | + delta_kwarg_none = block.route_delta(x, mask=None, doc_ids=None) |
| 174 | + mx.eval(delta_no_docs, delta_kwarg_none) |
| 175 | + |
| 176 | + assert delta_no_docs.shape == x.shape |
| 177 | + assert delta_no_docs.dtype == x.dtype |
| 178 | + # Explicit doc_ids=None must be byte-identical to omitting the kwarg. |
| 179 | + assert mx.array_equal(delta_no_docs, delta_kwarg_none).item() |
| 180 | + |
| 181 | + |
| 182 | +def test_hybrid_lm_forward_without_document_ids_runs(): |
| 183 | + model = _make_engram_model() |
| 184 | + input_ids = mx.array([[1, 2, 3, 4, 5, 6]], dtype=mx.int32) |
| 185 | + out = model(input_ids) |
| 186 | + mx.eval(out) |
| 187 | + assert out.shape == (1, 6, model.config.vocab_size) |
| 188 | + |
| 189 | + |
| 190 | +# --------------------------------------------------------------------------- |
| 191 | +# (4) Wrong-shape document_ids fails closed via _validate_document_ids |
| 192 | +# --------------------------------------------------------------------------- |
| 193 | + |
| 194 | + |
| 195 | +def test_document_ids_wrong_shape_raises(): |
| 196 | + model = _make_engram_model() |
| 197 | + input_ids = mx.array([[1, 2, 3, 4, 5, 6]], dtype=mx.int32) |
| 198 | + # Mismatched seq length: input is (1, 6), document_ids is (1, 5) |
| 199 | + bad_doc_ids = mx.array([[0, 0, 0, 1, 1]], dtype=mx.int32) |
| 200 | + with pytest.raises(ValueError, match="document_ids shape"): |
| 201 | + model.decoder_hidden_states(input_ids, document_ids=bad_doc_ids) |
| 202 | + |
| 203 | + |
| 204 | +def test_document_ids_wrong_rank_raises(): |
| 205 | + model = _make_engram_model() |
| 206 | + input_ids = mx.array([[1, 2, 3, 4, 5, 6]], dtype=mx.int32) |
| 207 | + # Rank 1 instead of (B, S) |
| 208 | + bad_doc_ids = mx.array([0, 0, 0, 1, 1, 1], dtype=mx.int32) |
| 209 | + with pytest.raises(ValueError, match="document_ids must be shaped"): |
| 210 | + model.decoder_hidden_states(input_ids, document_ids=bad_doc_ids) |
| 211 | + |
| 212 | + |
| 213 | +def test_document_ids_negative_raises(): |
| 214 | + model = _make_engram_model() |
| 215 | + input_ids = mx.array([[1, 2, 3, 4, 5, 6]], dtype=mx.int32) |
| 216 | + bad_doc_ids = mx.array([[0, 0, -1, 1, 1, 1]], dtype=mx.int32) |
| 217 | + with pytest.raises(ValueError, match="non-negative"): |
| 218 | + model.decoder_hidden_states(input_ids, document_ids=bad_doc_ids) |
0 commit comments