# audit: scripts/slice_qwen4exp_gguf.py + scripts/replay_moe_cache.py (read-only)

both self-tests pass (`--self-test`, exit 0). nothing edited.

## slice_qwen4exp_gguf.py

verified clean:
- header: magic/version/u64 counts, KV raw round-trip (incl. nested arrays and string arrays), tensor info layout (name, u32 n_dims, u64 dims, u32 type, u64 offset). ok.
- alignment: `general.alignment` honored; data section = align(header end); every new rel_offset aligned (l.330); tail zero-padding (l.373-377). ok. offsets are recomputed in directory order, so input data order doesn't matter (the l.159 comment claiming ascending order and `padded_span` l.158-168 are dead code, harmless).
- GGML_TYPE_TRAITS: all match ggml (Q2_K 84, Q3_K 110, IQ2_XXS 66, IQ2_XS 74, IQ3_XXS (256,98), IQ1_S 50, IQ4_NL (32,18), IQ3_S (256,110), IQ2_S 82, IQ4_XS (256,136), IQ1_M 56, TQ1_0 54, TQ2_0 66, MXFP4 (32,17)). Q2_0=42 (64,18) = 2.25 bpw and Q1_0=41 (128,18), NVFP4=40 (64,36) match secondary quotes of upstream ggml-common.h (couldn't read the header itself).
- dim order: ggml ne[] is innermost-first, so `dims[-1]` is the outermost axis: experts for `ffn_*_exps` [n_ff|n_embd, ., n_expert] and router rows for `ffn_gate_inp` [n_embd, n_expert]; per-expert bytes are contiguous, so `nbytes // 512` slicing is right. router rows and expert slabs use the same keep order, so the permutation stays consistent.
- `split.count`/`split.no`/`split.tensors.count`: tensor count doesn't change, so leaving them alone is right. shard 2+ have no `expert_count` KV (gguf-split puts it in shard 1 only), which is fine.

bugs / edge cases (most important first):
1. **per-expert tensors the regex misses -> llama.cpp shape-mismatch load failure** (l.50-52). only `_exps.weight` and `ffn_gate_inp.{weight,bias}` match. any `blk.N.ffn_{gate,up,down,gate_up}_exps.bias`, `blk.N.ffn_*_exps.scale` (per-expert scale tensors), or `blk.N.exp_probs_b.bias` ([512] expert-bias) gets copied with 512 rows while expert_count=K. fix: match `^blk\.(\d+)\.(ffn_\w*_exps\.(weight|bias|scale)|ffn_gate_inp\.(weight|bias)|exp_probs_b\.bias)$`, and after the loop warn about (or fail on) any `blk.*` tensor still having dims[-1]==old_n_experts that wasn't sliced.
2. **MTP layer blk.48.** if the shard has nextn/MTP expert tensors at blk.48, l.307 raises KeyError because `--keep-json` is documented as '0'..'47' (l.477). that fails loud rather than corrupting anything, but the keep-JSON generator has to emit layer 48 (from MTP routing stats). fix the help text and the selection pipeline.
3. **no `expert_used_count <= K` check** (l.284-290). if K < 10 llama.cpp asserts at load. fix: parse `*.expert_used_count` too and raise if > new_k. also cross-check the header `expert_count` against `old_n_experts` (it's parsed at l.130 and never used).
4. **in == out path destroys the input** (l.353, O_TRUNC before the data is read). there's also no atomic write, so a crash leaves a truncated shard with a valid-looking header. fix: refuse `os.path.samefile`, write to `out+'.partial'`, then `os.replace`.
5. **>2 GiB tensors** (l.362): `os.pread(whole tensor)` caps at 0x7ffff000 bytes on Linux (that's a loud EOFError there) and gives EINVAL on macOS. Q2_0/IQ3_S expert tensors are about 240-720 MB, so they're fine, but BF16/Q8 teacher-style `ffn_gate_up_exps` go over. pwrite results aren't checked either (l.355/371/377: short writes are silent). fix: per kept expert, `pread(per_exp_bytes, abs_start + idx*per_exp_bytes)`, and loop pwrite until the whole buffer is written.
6. minor: the self-test comment at l.418 is stale ("32 elems = 10B"; Q2_0 is 64 elems = 18B). `len(set(idx_list)) != new_k` (l.281) does catch duplicates. fine.

## replay_moe_cache.py

verified clean:
- `select_moce_top_k`: z' = z + λ·Δ·1[cached ∨ rank<J], stable tie-break on rank, top-J always survive (they get the bonus too). λ=0 gives the unbiased top-k. Δ is the per-token top1-topk range, not a corpus mean as the docstring says (fine, but say so).
- `belady_min`: next-use lists are built on unbiased top-k, and pointers advance only on chosen experts. at λ=0 every use is a choice, so non-chosen candidates' pointers are correct; it evicts max-next-use among non-chosen. main() skips it for λ>0 (l.288), which is right. it matches the trace semantics.
- warmup accounting and miss counting are right.

bugs:
1. **EMA policies only decay resident experts** (l.190-191, 195-196, 206-207). near_pick_ema and inverse_router_ema credit every top-N/top-m expert, resident or not, but only decay `cache_set`. so non-resident experts pile up an undecayed sum, and evicted experts keep a frozen score. demo: an expert sitting at a near-pick rank for 300 tokens builds up 104.9 (a true EMA would be ~33.7) against ~26 for the hot residents. once it's chosen once, it stays pinned for the next 99 tokens without ever being used again. plain `ema` has the frozen-on-eviction half of this. this biases exactly the near-pick vs inverse-router comparison that PLAN §2.5 #6 wants. fix: decay all n_experts each step (`for c in range(n_experts)`, 512 mults is cheap), or do lazy decay with `score*decay**(t-last_t)`.
2. **cache_cap < k_used** (l.165-166): the fallback can evict an expert chosen in this same step, including one admitted a moment earlier, and the miss count turns into garbage. fix: `assert cache_cap >= k_used`.
3. near_pick credits unbiased ranks only (l.197-202). under λ>0 the experts MoCE actually picked from outside the unbiased top-k get only near-pick credit (zero at rank ≥ near_pick_n). decide whether that's intended. if not, add `credit = max(credit, 1.0)` for `eid in chosen_set`.
4. trace shorter than warmup (l.217): misses=0, eval_tokens=1, so 0 misses/token goes silently into the 48-layer sum. fix: skip/flag it.
5. scope gaps vs PLAN §6.1 (these aren't math bugs, but they'll give wrong numbers if someone trusts the output):
   - the docstring promises non-evicting next-layer prefetch eval, and none of it exists.
   - the trace format has no session/turn/prefill-vs-decode markers, so the cache is never reset and there are no per-turn numbers.
   - prefill is replayed token by token instead of as a per-chunk expert union (that misstates prefill misses).
   - `read_bytes()` + Python tuples won't scale to the planned 12 GB / 1M-token trace. use a numpy memmap.
   - λ>0 here is open-loop. it only models same-layer selection feedback, not downstream logit drift (§6.1 #6 already says closed loop is needed).
6. MB/expert inconsistency: l.305-306 uses 1.3824 MB (= 4.915M × 2.25/8, which is correct for Q2_0 (64,18)), but PLAN §5.2 l.373 says ~1.536 MB and the §3 Q2_0 column (37.7/23.6/18.9/16.5 GB) is computed at 2.5 bpw. so PLAN overstates Q2_0 bytes by ~11% (224 experts: 14.9 GB, not 16.5).
