Skip to main content

nam_rs/models/a2/model/
set_weights.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 Fábio Henrique de Lima Silva (fhl.bsb@gmail.com) All rights reserved.
3
4//! Weight loading for WaveNet A2 models.
5//!
6//! Parses a flat f32 weight stream in NAM JSON order and populates the
7//! model layers with quantized weights and col-major-per-tap layouts.
8//!
9//! When the layer JSON contains active FiLM entries, FiLM weights are read
10//! from the stream after each layer's standard weights.
11
12use super::WaveNetA2;
13use crate::math::common::AlignedVec;
14use crate::models::a2::conv1d_ch3::A2Conv1dCh3;
15use crate::models::a2::conv1d_ch8::A2Conv1dCh8;
16use crate::models::a2::film::{FiLMConfig, FiLMLayer};
17use crate::models::a2::head::A2HeadConv;
18use crate::models::a2::layer::A2Layer;
19use crate::models::a2::params::{
20    A2_DILATIONS, A2_HEAD_KERNEL_SIZE, A2_KERNEL_SIZES, A2_NUM_LAYERS,
21};
22use crate::models::a2::weights_layout::{
23    FILM_KEYS, film_bias_count, film_bias_count_generic, film_weight_count,
24    film_weight_count_generic, transpose_conv1d_interleaved_4wide, transpose_dense_f32,
25    transpose_head_w,
26};
27
28impl<const CH: usize> WaveNetA2<CH> {
29    /// Loads weights from a flat f32 slice in the exact A2 stream order.
30    ///
31    /// ## Weight order (mirrors `a2_fast.cpp:196-282`)
32    ///
33    /// 1. `_rechannel`: weights `CH` f32 (no bias — matches C++ A2FastModel)
34    /// 2. Per layer 0..22:
35    ///    - `_conv`: weights `CH*CH*K` f32 + bias `CH` f32
36    ///    - `_input_mixin`: weights `CH` f32 (no bias)
37    ///    - `_layer1x1`: weights `CH*CH` f32 (col-major) + bias `CH` f32
38    /// 3. `_head_rechannel`: conv k=16 weights `16*CH` f32 + head_bias `1` f32
39    /// 4. `head_scale`: last f32 in the stream
40    ///
41    /// ## Acceptance criteria
42    /// - Calls `verify_exhaustion()` — consumed count must equal `weights.len()`.
43    /// - Returns a clear error if the weight stream is shorter or longer than expected.
44    #[expect(
45        clippy::too_many_lines,
46        reason = "Function body contains exhaustive A2 kernel-size dispatch table — splitting into sub-functions would scatter related logic"
47    )]
48    pub fn set_weights(&mut self, weights: &[f32]) -> Result<(), String> {
49        let total = weights.len();
50        let mut pos: usize = 0;
51
52        // ── 1. Rechannel: Conv1x1(1 → CH) (no bias) ─────────────────────
53        let rw_f32 = read_slice(weights, &mut pos, CH, total, "rechannel_w")?;
54        let rechannel_w = AlignedVec::from_vec(rw_f32.to_vec())
55            .expect("allocation should succeed for test-sized buffers");
56
57        // ── 2. Per-layer weights ──────────────────────────────────────────
58        let mut layers = Vec::with_capacity(A2_NUM_LAYERS);
59
60        for i in 0..A2_NUM_LAYERS {
61            let ksize = A2_KERNEL_SIZES[i];
62            let dilation = A2_DILATIONS[i];
63            let conv_w_count = CH * CH * ksize;
64            let num_blocks = CH.div_ceil(4);
65            let conv_w_padded = num_blocks * 4 * CH * ksize;
66
67            // 2a. Dilated conv weights: read CH×CH×K, store padded interleaved 4-wide.
68            // For CH=8 we also keep a f32 copy for the col-major-per-tap path.
69            let conv_w_f32 = read_slice(
70                weights,
71                &mut pos,
72                conv_w_count,
73                total,
74                &format!("layer[{i}].conv_w"),
75            )?;
76            // Owned copy: needed for CH=8 col-major-per-tap (re-indexed, not interleaved-4-wide).
77            let conv_w_f32_owned: Vec<f32> = conv_w_f32.to_vec();
78            // Interleave-4-wide f32 for the fallback conv path.
79            let mut conv_w = AlignedVec::new(conv_w_padded, 0.0f32)
80                .expect("allocation should succeed for test-sized buffers");
81            transpose_conv1d_interleaved_4wide(conv_w_f32, &mut conv_w, CH, CH, ksize);
82
83            // 2b. Conv bias (f32, one per output channel).
84            let conv_b_f32 =
85                read_slice(weights, &mut pos, CH, total, &format!("layer[{i}].conv_b"))?;
86            let conv_b = AlignedVec::from_vec(conv_b_f32.to_vec())
87                .expect("allocation should succeed for test-sized buffers");
88
89            // Build the scalar/SIMD fallback conv (interleaved-4-wide f32 weights).
90            let conv = crate::models::a2::conv1d::A2Conv1d::new(
91                conv_w,
92                conv_b.clone(),
93                true,
94                dilation,
95                CH,
96                CH,
97                ksize,
98            );
99
100            // Optional col-major-per-tap f32 conv for CH=3 and CH=8.
101            // Uses the original (non-interleaved) f32 weights for SIMD-friendly access.
102            let conv_ch = match CH {
103                3 => {
104                    let ch3 = A2Conv1dCh3::new(conv_w_f32, CH, CH, ksize, dilation, conv_b_f32)
105                        .map_err(|e| format!("{e}"))?;
106                    Some(crate::models::a2::layer::A2ConvCh::Ch3(ch3))
107                }
108                8 => {
109                    let ch8 = A2Conv1dCh8::new(&conv_w_f32_owned, CH, CH, ksize, dilation, &conv_b)
110                        .map_err(|e| format!("{e}"))?;
111                    Some(crate::models::a2::layer::A2ConvCh::Ch8(ch8))
112                }
113                _ => None,
114            };
115
116            // 2c. Input mixin: per-channel scalar weights (f32, no bias).
117            // Applied as `z[c] += mixin[c] * input` after the dilated conv.
118            let mixin_w_f32 =
119                read_slice(weights, &mut pos, CH, total, &format!("layer[{i}].mixin_w"))?;
120            let mixin_w = AlignedVec::from_vec(mixin_w_f32.to_vec())
121                .expect("allocation should succeed for test-sized buffers");
122
123            // 2d. Layer 1×1 projection: CH×CH dense matrix (f32, col-major).
124            // NAM JSON stores row-major; we transpose to col-major for SIMD dot products.
125            let l1x1_w_f32 = read_slice(
126                weights,
127                &mut pos,
128                CH * CH,
129                total,
130                &format!("layer[{i}].l1x1_w"),
131            )?;
132            let mut l1x1_w = AlignedVec::new(CH * CH, 0.0f32)
133                .expect("allocation should succeed for test-sized buffers");
134            transpose_dense_f32(l1x1_w_f32, &mut l1x1_w, CH, CH);
135
136            // 2e. Layer 1×1 bias: one f32 per output channel.
137            let l1x1_b_f32 =
138                read_slice(weights, &mut pos, CH, total, &format!("layer[{i}].l1x1_b"))?;
139            let l1x1_b = AlignedVec::from_vec(l1x1_b_f32.to_vec())
140                .expect("allocation should succeed for test-sized buffers");
141
142            // Assemble the layer: priority is ch3_conv > ch8_conv > scalar fallback.
143            let mut layer = A2Layer::new(conv, mixin_w, l1x1_w, l1x1_b);
144            if let Some(conv_ch) = conv_ch {
145                layer.conv_ch = Some(conv_ch);
146            }
147
148            // 2f. FiLM layers (if active in layer_raw JSON) — read weights after l1x1 bias.
149            if let Some(ref raw) = self.layer_raw {
150                let configs = parse_film_configs(raw);
151                load_film_for_layer(&mut layer, &configs, CH, 1, 1, weights, &mut pos, total, i)?;
152            }
153
154            layers.push(layer);
155        }
156
157        // ── 3. Head rechannel: Conv1D(CH → 1, K=16, bias) ─────────────────
158        let head_w_f32 = read_slice(weights, &mut pos, A2_HEAD_KERNEL_SIZE * CH, total, "head_w")?;
159        let mut head_w = AlignedVec::new(A2_HEAD_KERNEL_SIZE * CH, 0.0f32)
160            .expect("allocation should succeed for test-sized buffers");
161        transpose_head_w(head_w_f32, &mut head_w, CH, A2_HEAD_KERNEL_SIZE);
162
163        let head_b = {
164            let s = read_slice(weights, &mut pos, 1, total, "head_b")?;
165            if !s[0].is_finite() {
166                return Err(format!(
167                    "set_weights: head_b is not finite (value: {:e})",
168                    s[0]
169                ));
170            }
171            s[0]
172        };
173
174        // ── 4. Head scale (last float) ─────────────────────────────────────
175        let head_scale = {
176            let s = read_slice(weights, &mut pos, 1, total, "head_scale")?;
177            if !s[0].is_finite() {
178                return Err(format!(
179                    "set_weights: head_scale is not finite (value: {:e})",
180                    s[0]
181                ));
182            }
183            s[0]
184        };
185
186        // ── 5. Exhaustion check ────────────────────────────────────────────
187        if pos != total {
188            return Err(format!(
189                "set_weights: stream has {} unconsumed f32 after loading all weights (consumed {}, total {})",
190                total - pos,
191                pos,
192                total
193            ));
194        }
195
196        // ── 6. Commit to self (all-or-nothing) ──────────────────────────────
197        self.rechannel_w_f32 = rechannel_w;
198        self.layers = layers;
199        self.head_conv = Some(A2HeadConv::new(head_w, head_b, head_scale, CH));
200
201        Ok(())
202    }
203}
204
205// =============================================================================
206// Private helpers for set_weights
207// =============================================================================
208
209/// Reads a contiguous slice of `n` f32 values from `weights[pos..]`,
210/// advancing `pos`. Returns an error with the label if out of bounds.
211#[inline]
212pub(crate) fn read_slice<'a>(
213    weights: &'a [f32],
214    pos: &mut usize,
215    n: usize,
216    total: usize,
217    label: &str,
218) -> Result<&'a [f32], String> {
219    if *pos + n > total {
220        return Err(format!(
221            "set_weights: stream exhausted at position {} (need {} for \"{}\", total {})",
222            *pos, n, label, total
223        ));
224    }
225    let slice = &weights[*pos..*pos + n];
226    *pos += n;
227    Ok(slice)
228}
229
230// =============================================================================
231// FiLM loading helpers
232// =============================================================================
233
234pub(crate) fn parse_single_film_config(raw: &serde_json::Value, key: &str) -> FiLMConfig {
235    let obj = match raw.get(key).and_then(|v| v.as_object()) {
236        Some(o) => o,
237        None => return FiLMConfig::default(),
238    };
239    FiLMConfig {
240        active: obj.get("active").and_then(|a| a.as_bool()).unwrap_or(false),
241        shift: obj.get("shift").and_then(|s| s.as_bool()).unwrap_or(true),
242        groups: obj
243            .get("groups")
244            .and_then(|g| g.as_u64())
245            .map(|g| g as u32)
246            .unwrap_or(1),
247    }
248}
249
250pub(crate) fn parse_film_configs(raw: &serde_json::Value) -> [FiLMConfig; 8] {
251    let mut configs = [FiLMConfig::default(); 8];
252    for &(key, idx) in FILM_KEYS {
253        configs[idx] = parse_single_film_config(raw, key);
254    }
255    configs
256}
257
258pub(crate) fn set_layer_film(
259    layer: &mut A2Layer,
260    _config: &FiLMConfig,
261    idx: usize,
262    film: FiLMLayer,
263) -> Result<(), String> {
264    match idx {
265        0 => layer.conv_pre_film = Some(film),
266        1 => layer.conv_post_film = Some(film),
267        2 => layer.input_mixin_pre_film = Some(film),
268        3 => layer.input_mixin_post_film = Some(film),
269        4 => layer.activation_pre_film = Some(film),
270        5 => layer.activation_post_film = Some(film),
271        6 => layer.layer1x1_post_film = Some(film),
272        7 => layer.head1x1_post_film = Some(film),
273        _ => return Err(format!("FiLM slot index {} out of range (0-7)", idx)),
274    }
275    Ok(())
276}
277
278/// Convenience wrapper — see `weights_layout::film_weight_count`.
279#[cfg_attr(
280    not(test),
281    expect(
282        dead_code,
283        reason = "Retained for future integration when gated feature is enabled"
284    )
285)]
286pub(crate) fn film_weight_count_cfg(
287    config: &FiLMConfig,
288    cond_size: usize,
289    channels: usize,
290) -> usize {
291    film_weight_count(config.groups, cond_size, channels, config.shift)
292}
293
294/// Convenience wrapper — see `weights_layout::film_bias_count`.
295#[cfg_attr(
296    not(test),
297    expect(
298        dead_code,
299        reason = "Retained for future integration when gated feature is enabled"
300    )
301)]
302pub(crate) fn film_bias_count_cfg(config: &FiLMConfig, channels: usize) -> usize {
303    film_bias_count(channels, config.shift)
304}
305
306#[expect(
307    clippy::too_many_arguments,
308    reason = "A2 model weight-setter requiring many dimension parameters to safely map weight slices to layer buffers"
309)]
310pub(crate) fn load_film_for_layer(
311    layer: &mut A2Layer,
312    configs: &[FiLMConfig; 8],
313    channels: usize,
314    cond_size: usize,
315    head_channels: usize,
316    weights: &[f32],
317    pos: &mut usize,
318    total: usize,
319    layer_idx: usize,
320) -> Result<(), String> {
321    for (idx, config) in configs.iter().enumerate() {
322        if !config.active {
323            continue;
324        }
325        let film_channels = match idx {
326            2 => cond_size,
327            7 => head_channels,
328            _ => channels,
329        };
330        let (w_count, b_count) = if cond_size > 1 {
331            // A2 generic: integer-safe formula with channels-sized bias
332            (
333                film_weight_count_generic(config.groups, cond_size, film_channels, config.shift),
334                film_bias_count_generic(film_channels),
335            )
336        } else {
337            (
338                film_weight_count(config.groups, cond_size, film_channels, config.shift),
339                film_bias_count(film_channels, config.shift),
340            )
341        };
342        let key = FILM_KEYS[idx].0;
343
344        let film_w = read_slice(
345            weights,
346            pos,
347            w_count,
348            total,
349            &format!("layer[{layer_idx}].{key}.w"),
350        )?;
351        let film_b = read_slice(
352            weights,
353            pos,
354            b_count,
355            total,
356            &format!("layer[{layer_idx}].{key}.b"),
357        )?;
358
359        let film_layer = FiLMLayer::load(
360            *config,
361            cond_size,
362            film_channels,
363            film_w.to_vec(),
364            film_b.to_vec(),
365        )
366        .map_err(|e| format!("{e}"))?;
367        set_layer_film(layer, config, idx, film_layer)?;
368    }
369    Ok(())
370}
371
372#[cfg(test)]
373#[path = "set_weights_test.rs"]
374mod set_weights_test;