Skip to main content

feagi_evolutionary/genome/normalizers/
v3.rs

1// Copyright 2025 Neuraville Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4//! V3 normalizer: cleans well-known bad values in a v3 genome before
5//! validation.
6//!
7//! Mirrors the semantics of the legacy `crate::validator::auto_fix_genome`
8//! function, which still operates on `RuntimeGenome` and is retained for
9//! external consumers (Python bindings, CLI tools). The two paths are
10//! intentionally redundant during the rollout window: the chain runner
11//! uses this JSON-level normalizer; external callers that already use
12//! `auto_fix_genome` continue to work unchanged.
13//!
14//! Field paths are the **hierarchical** v3 JSON shape (post-flat→hierarchical
15//! conversion, post-`migrate_genome`):
16//!
17//! - `physiology.simulation_timestep` (f64): if present and ≤ 0, replace
18//!   with the default. The legacy alias `physiology.burst_delay` is read
19//!   only by the runtime parser; this normalizer does not synthesize one
20//!   field from the other.
21//! - `physiology.max_age` (u64): if present and == 0, replace with default.
22//! - `physiology.quantization_precision` (string): if empty/non-canonical/
23//!   invalid, normalize or replace with default.
24//! - `blueprint[id].block_boundaries` (`[u32; 3]`): any zero element is
25//!   replaced with 1.
26//! - `blueprint[id].per_voxel_neuron_cnt` (u64): if present and == 0,
27//!   replaced with 1.
28//!
29//! Missing fields are not added here; `parse_physiology` and the cortical
30//! area parser already fill defaults during JSON→`RuntimeGenome`. Adding
31//! defaults here would change the on-wire genome shape, which is out of
32//! scope for a normalizer.
33
34use serde_json::{json, Value};
35
36use super::{NormalizationDiagnostics, Normalizer};
37use crate::genome::migration::MigrationError;
38use crate::genome::schema::{GenomeSchemaVersion, CURRENT_SCHEMA_VERSION};
39
40/// Default quantization precision used when the field is empty or invalid.
41/// Mirrors `crate::runtime::default_quantization_precision()` to avoid a
42/// runtime crate dependency cycle in module init.
43const DEFAULT_QUANTIZATION_PRECISION: &str = "fp32";
44
45/// Default simulation timestep used when the field is present but ≤ 0.
46/// Mirrors `crate::runtime::PhysiologyConfig::default().simulation_timestep`.
47const DEFAULT_SIMULATION_TIMESTEP: f64 = 0.025;
48
49/// Default max age used when the field is present but == 0.
50/// Mirrors `crate::runtime::PhysiologyConfig::default().max_age`.
51const DEFAULT_MAX_AGE: u64 = 10_000_000;
52
53#[derive(Debug, Default, Clone, Copy)]
54pub struct V3Normalizer;
55
56impl V3Normalizer {
57    pub const fn new() -> Self {
58        Self
59    }
60}
61
62impl Normalizer for V3Normalizer {
63    fn schema_version(&self) -> GenomeSchemaVersion {
64        CURRENT_SCHEMA_VERSION
65    }
66
67    fn name(&self) -> &'static str {
68        "v3_normalizer"
69    }
70
71    fn normalize(&self, genome: &mut Value) -> Result<NormalizationDiagnostics, MigrationError> {
72        let mut diag = NormalizationDiagnostics::new(CURRENT_SCHEMA_VERSION);
73
74        normalize_physiology(genome, &mut diag);
75        normalize_blueprint(genome, &mut diag);
76
77        Ok(diag)
78    }
79}
80
81/// Apply corrections to the `physiology` section if present.
82///
83/// Silent when `physiology` is absent: the parser will fill defaults.
84fn normalize_physiology(genome: &mut Value, diag: &mut NormalizationDiagnostics) {
85    let physiology = match genome.get_mut("physiology").and_then(Value::as_object_mut) {
86        Some(p) => p,
87        None => return,
88    };
89
90    if let Some(ts) = physiology
91        .get("simulation_timestep")
92        .and_then(Value::as_f64)
93    {
94        if ts <= 0.0 {
95            physiology.insert(
96                "simulation_timestep".to_string(),
97                json!(DEFAULT_SIMULATION_TIMESTEP),
98            );
99            diag.record(format!(
100                "physiology.simulation_timestep {ts} -> {DEFAULT_SIMULATION_TIMESTEP} (default)"
101            ));
102        }
103    }
104
105    if let Some(age) = physiology.get("max_age").and_then(Value::as_u64) {
106        if age == 0 {
107            physiology.insert("max_age".to_string(), json!(DEFAULT_MAX_AGE));
108            diag.record(format!(
109                "physiology.max_age 0 -> {DEFAULT_MAX_AGE} (default)"
110            ));
111        }
112    }
113
114    let precision_action = match physiology
115        .get("quantization_precision")
116        .and_then(Value::as_str)
117    {
118        Some("") => Some(PrecisionAction::ReplaceWithDefault {
119            previous: String::new(),
120        }),
121        Some(other) => match canonicalize_precision(other) {
122            Some(canonical) if canonical != other => Some(PrecisionAction::Normalize {
123                previous: other.to_string(),
124                canonical,
125            }),
126            Some(_) => None,
127            None => Some(PrecisionAction::ReplaceWithDefault {
128                previous: other.to_string(),
129            }),
130        },
131        None => None,
132    };
133
134    if let Some(action) = precision_action {
135        match action {
136            PrecisionAction::Normalize {
137                previous,
138                canonical,
139            } => {
140                physiology.insert(
141                    "quantization_precision".to_string(),
142                    Value::String(canonical.clone()),
143                );
144                diag.record(format!(
145                    "physiology.quantization_precision '{previous}' -> '{canonical}' (normalized)"
146                ));
147            }
148            PrecisionAction::ReplaceWithDefault { previous } => {
149                physiology.insert(
150                    "quantization_precision".to_string(),
151                    Value::String(DEFAULT_QUANTIZATION_PRECISION.to_string()),
152                );
153                diag.record(format!(
154                    "physiology.quantization_precision '{previous}' -> '{DEFAULT_QUANTIZATION_PRECISION}' (default)"
155                ));
156            }
157        }
158    }
159}
160
161enum PrecisionAction {
162    Normalize { previous: String, canonical: String },
163    ReplaceWithDefault { previous: String },
164}
165
166/// Returns the canonical lowercase form of a known precision token, or
167/// `None` if the input is unrecognized.
168///
169/// The set of recognized tokens mirrors `feagi_npu_neural::types::Precision`
170/// without taking a dependency on it, since this normalizer should not
171/// pull in NPU types just to canonicalize a string.
172fn canonicalize_precision(input: &str) -> Option<String> {
173    match input.to_lowercase().as_str() {
174        "fp32" | "f32" => Some("fp32".to_string()),
175        "fp16" | "f16" => Some("fp16".to_string()),
176        "int8" => Some("int8".to_string()),
177        _ => None,
178    }
179}
180
181/// Apply per-cortical-area corrections to the `blueprint` section if
182/// present.
183fn normalize_blueprint(genome: &mut Value, diag: &mut NormalizationDiagnostics) {
184    let blueprint = match genome.get_mut("blueprint").and_then(Value::as_object_mut) {
185        Some(b) => b,
186        None => return,
187    };
188
189    let area_ids: Vec<String> = blueprint.keys().cloned().collect();
190    for cortical_id in area_ids {
191        let area = match blueprint
192            .get_mut(&cortical_id)
193            .and_then(Value::as_object_mut)
194        {
195            Some(a) => a,
196            None => continue,
197        };
198
199        normalize_block_boundaries(area, &cortical_id, diag);
200        normalize_per_voxel_neuron_cnt(area, &cortical_id, diag);
201    }
202}
203
204fn normalize_block_boundaries(
205    area: &mut serde_json::Map<String, Value>,
206    cortical_id: &str,
207    diag: &mut NormalizationDiagnostics,
208) {
209    let boundaries = match area
210        .get_mut("block_boundaries")
211        .and_then(Value::as_array_mut)
212    {
213        Some(b) if b.len() == 3 => b,
214        _ => return,
215    };
216
217    static AXIS_NAMES: [&str; 3] = ["width", "height", "depth"];
218    for (i, slot) in boundaries.iter_mut().enumerate() {
219        if slot.as_u64() == Some(0) {
220            *slot = json!(1u32);
221            diag.record(format!(
222                "blueprint['{cortical_id}'].block_boundaries[{i}] ({}) 0 -> 1",
223                AXIS_NAMES[i]
224            ));
225        }
226    }
227}
228
229fn normalize_per_voxel_neuron_cnt(
230    area: &mut serde_json::Map<String, Value>,
231    cortical_id: &str,
232    diag: &mut NormalizationDiagnostics,
233) {
234    if area.get("per_voxel_neuron_cnt").and_then(Value::as_u64) == Some(0) {
235        area.insert("per_voxel_neuron_cnt".to_string(), json!(1u32));
236        diag.record(format!(
237            "blueprint['{cortical_id}'].per_voxel_neuron_cnt 0 -> 1"
238        ));
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use serde_json::json;
246
247    #[test]
248    fn reports_current_schema_version() {
249        let n = V3Normalizer::new();
250        assert_eq!(n.schema_version(), CURRENT_SCHEMA_VERSION);
251        assert_eq!(n.name(), "v3_normalizer");
252    }
253
254    #[test]
255    fn clean_genome_yields_clean_diagnostics() {
256        let n = V3Normalizer::new();
257        let mut g = json!({
258            "physiology": {
259                "simulation_timestep": 0.025,
260                "max_age": 10_000_000,
261                "quantization_precision": "fp32"
262            },
263            "blueprint": {
264                "abc12345": {
265                    "block_boundaries": [10, 10, 10],
266                    "per_voxel_neuron_cnt": 1
267                }
268            }
269        });
270        let d = n.normalize(&mut g).unwrap();
271        assert!(d.is_clean());
272    }
273
274    #[test]
275    fn fixes_negative_simulation_timestep() {
276        let n = V3Normalizer::new();
277        let mut g = json!({
278            "physiology": { "simulation_timestep": -0.1 }
279        });
280        let d = n.normalize(&mut g).unwrap();
281        assert_eq!(g["physiology"]["simulation_timestep"], json!(0.025));
282        assert_eq!(d.transformations.len(), 1);
283        assert!(d.transformations[0].contains("simulation_timestep"));
284    }
285
286    #[test]
287    fn fixes_zero_simulation_timestep() {
288        let n = V3Normalizer::new();
289        let mut g = json!({ "physiology": { "simulation_timestep": 0.0 } });
290        let d = n.normalize(&mut g).unwrap();
291        assert_eq!(g["physiology"]["simulation_timestep"], json!(0.025));
292        assert!(!d.is_clean());
293    }
294
295    #[test]
296    fn leaves_burst_delay_alone() {
297        // The legacy alias is parser territory; the normalizer must not
298        // synthesize fields. parse_physiology will fold burst_delay into
299        // simulation_timestep at deserialize time.
300        let n = V3Normalizer::new();
301        let mut g = json!({ "physiology": { "burst_delay": 0.030 } });
302        let d = n.normalize(&mut g).unwrap();
303        assert!(d.is_clean());
304        assert_eq!(g["physiology"]["burst_delay"], json!(0.030));
305        assert!(g["physiology"].get("simulation_timestep").is_none());
306    }
307
308    #[test]
309    fn fixes_zero_max_age() {
310        let n = V3Normalizer::new();
311        let mut g = json!({ "physiology": { "max_age": 0 } });
312        let d = n.normalize(&mut g).unwrap();
313        assert_eq!(g["physiology"]["max_age"], json!(DEFAULT_MAX_AGE));
314        assert!(!d.is_clean());
315    }
316
317    #[test]
318    fn replaces_empty_precision_with_default() {
319        let n = V3Normalizer::new();
320        let mut g = json!({ "physiology": { "quantization_precision": "" } });
321        let d = n.normalize(&mut g).unwrap();
322        assert_eq!(g["physiology"]["quantization_precision"], json!("fp32"));
323        assert_eq!(d.transformations.len(), 1);
324    }
325
326    #[test]
327    fn normalizes_uppercase_precision() {
328        let n = V3Normalizer::new();
329        let mut g = json!({ "physiology": { "quantization_precision": "FP32" } });
330        let d = n.normalize(&mut g).unwrap();
331        assert_eq!(g["physiology"]["quantization_precision"], json!("fp32"));
332        assert!(d.transformations[0].contains("normalized"));
333    }
334
335    #[test]
336    fn normalizes_f32_alias_precision() {
337        let n = V3Normalizer::new();
338        let mut g = json!({ "physiology": { "quantization_precision": "f32" } });
339        let d = n.normalize(&mut g).unwrap();
340        assert_eq!(g["physiology"]["quantization_precision"], json!("fp32"));
341        assert!(d.transformations[0].contains("normalized"));
342    }
343
344    #[test]
345    fn replaces_invalid_precision_with_default() {
346        let n = V3Normalizer::new();
347        let mut g = json!({ "physiology": { "quantization_precision": "garbage" } });
348        let d = n.normalize(&mut g).unwrap();
349        assert_eq!(g["physiology"]["quantization_precision"], json!("fp32"));
350        assert!(d.transformations[0].contains("default"));
351    }
352
353    #[test]
354    fn fixes_zero_block_boundaries_per_axis() {
355        let n = V3Normalizer::new();
356        let mut g = json!({
357            "blueprint": {
358                "abc12345": { "block_boundaries": [0, 5, 0] }
359            }
360        });
361        let d = n.normalize(&mut g).unwrap();
362        assert_eq!(
363            g["blueprint"]["abc12345"]["block_boundaries"],
364            json!([1, 5, 1])
365        );
366        assert_eq!(d.transformations.len(), 2);
367    }
368
369    #[test]
370    fn fixes_zero_per_voxel_neuron_cnt() {
371        let n = V3Normalizer::new();
372        let mut g = json!({
373            "blueprint": {
374                "abc12345": { "per_voxel_neuron_cnt": 0 }
375            }
376        });
377        let d = n.normalize(&mut g).unwrap();
378        assert_eq!(g["blueprint"]["abc12345"]["per_voxel_neuron_cnt"], json!(1));
379        assert_eq!(d.transformations.len(), 1);
380    }
381
382    #[test]
383    fn handles_missing_fields_silently() {
384        // Missing fields are parser territory; the normalizer must not
385        // synthesize them. This pins the contract.
386        let n = V3Normalizer::new();
387        let mut g = json!({});
388        let d = n.normalize(&mut g).unwrap();
389        assert!(d.is_clean());
390        assert_eq!(g, json!({}));
391    }
392
393    #[test]
394    fn is_idempotent() {
395        // Running the normalizer twice must produce the same output and
396        // an empty diagnostics on the second pass.
397        let n = V3Normalizer::new();
398        let mut g = json!({
399            "physiology": {
400                "simulation_timestep": 0.0,
401                "max_age": 0,
402                "quantization_precision": ""
403            },
404            "blueprint": {
405                "abc12345": {
406                    "block_boundaries": [0, 0, 0],
407                    "per_voxel_neuron_cnt": 0
408                }
409            }
410        });
411
412        let d1 = n.normalize(&mut g).unwrap();
413        assert!(!d1.is_clean());
414        let snapshot = g.clone();
415
416        let d2 = n.normalize(&mut g).unwrap();
417        assert!(d2.is_clean());
418        assert_eq!(g, snapshot);
419    }
420}