Skip to main content

feagi_evolutionary/genome/migration/
mod.rs

1// Copyright 2025 Neuraville Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Stepwise genome migration: traits, registry, and chain runner.
5//!
6//! A `Migrator` performs a single `vN -> vN+1` transformation on a JSON
7//! genome. Migrators are registered in a `ChainRegistry` keyed by their
8//! `from_version`. The `ChainRunner` walks a genome from its detected
9//! schema version up to a target version, invoking each migrator in
10//! sequence and per-version validators between hops.
11//!
12//! See `feagi-core/docs/GENOME_SCHEMA_VERSIONING.md` for the system-level
13//! design, and `crates/feagi-evolutionary/src/genome/README.md` for the
14//! contributor contract (invariants, retention, anti-patterns).
15
16use std::collections::BTreeMap;
17
18use serde_json::Value;
19use thiserror::Error;
20
21use crate::genome::normalizers::{NormalizationDiagnostics, Normalizer};
22use crate::genome::schema::GenomeSchemaVersion;
23use crate::genome::validators::{ValidationReport, Validator};
24
25pub mod chain;
26pub mod v2_to_v3;
27
28pub use chain::ChainRunner;
29pub use v2_to_v3::V2ToV3Migrator;
30
31/// Errors emitted by migrators and by the chain machinery itself.
32#[derive(Debug, Error)]
33pub enum MigrationError {
34    /// A migrator returned an error during its `migrate` call.
35    #[error("Migrator '{name}' ({from} -> {to}) failed: {reason}")]
36    StepFailed {
37        name: &'static str,
38        from: GenomeSchemaVersion,
39        to: GenomeSchemaVersion,
40        reason: String,
41    },
42
43    /// The runner needed a migrator for a version but the registry didn't
44    /// have one. Indicates a gap in the chain.
45    #[error("No migrator registered with from_version={from} (needed to reach v{target})")]
46    MissingMigrator {
47        from: GenomeSchemaVersion,
48        target: GenomeSchemaVersion,
49    },
50
51    /// A migrator's declared `to_version` is not exactly `from_version + 1`,
52    /// or two migrators share a `from_version`. The runner refuses to start
53    /// in either case; see the README's "registry MUST be contiguous" rule.
54    #[error("Registry violates the contiguity invariant: {0}")]
55    InvalidRegistry(String),
56
57    /// The genome's detected schema version is newer than the requested
58    /// target. Forward-only migrations are by design.
59    #[error("Cannot migrate downward: genome is at v{from} but target is v{target}")]
60    DowngradeRefused {
61        from: GenomeSchemaVersion,
62        target: GenomeSchemaVersion,
63    },
64
65    /// `detect_schema_version` could not resolve the input genome.
66    #[error("Failed to detect genome schema version: {0}")]
67    DetectionFailed(String),
68}
69
70/// Diagnostic record produced by a single migrator step.
71///
72/// Per the contributor contract, every transformation a migrator performs
73/// MUST contribute at least one entry to `transformations`. A migrator that
74/// runs and produces zero diagnostics is a bug.
75#[derive(Debug, Clone)]
76pub struct MigrationStepDiagnostics {
77    pub from_version: GenomeSchemaVersion,
78    pub to_version: GenomeSchemaVersion,
79    pub transformations: Vec<String>,
80    /// Deterministic identifier rewrites produced by this migration step.
81    ///
82    /// Brain-artifact migration consumes these mappings to rewrite
83    /// connectome-lite references after its embedded genome is migrated.
84    pub identifier_remaps: BTreeMap<String, String>,
85}
86
87impl MigrationStepDiagnostics {
88    pub fn new(from: GenomeSchemaVersion, to: GenomeSchemaVersion) -> Self {
89        Self {
90            from_version: from,
91            to_version: to,
92            transformations: Vec::new(),
93            identifier_remaps: BTreeMap::new(),
94        }
95    }
96
97    pub fn record(&mut self, msg: impl Into<String>) {
98        self.transformations.push(msg.into());
99    }
100
101    pub fn record_identifier_remap(
102        &mut self,
103        source: impl Into<String>,
104        destination: impl Into<String>,
105    ) {
106        self.identifier_remaps
107            .insert(source.into(), destination.into());
108    }
109}
110
111/// Single `vN -> vN+1` migration step.
112///
113/// Implementations operate on `serde_json::Value` and MUST satisfy the
114/// invariants documented in the module-level README:
115/// determinism, idempotence, bounded compute, no side channels, JSON only,
116/// diagnostics over silence.
117///
118/// Migrators MUST NOT mutate the `genome_schema_version` field on the
119/// input `Value`. The chain runner stamps the new version after each
120/// successful step. This keeps the migrator focused on shape changes and
121/// lets the runner be the single source of truth for version bookkeeping.
122#[allow(clippy::wrong_self_convention)]
123// The `from_*`/`to_*` accessor names describe the migrator's *schema
124// version range*, not constructors. Renaming would hurt readability for
125// every caller (`source_version`/`target_version` were considered and
126// rejected in design review).
127pub trait Migrator: Send + Sync {
128    /// Schema version this migrator accepts as input.
129    fn from_version(&self) -> GenomeSchemaVersion;
130
131    /// Schema version this migrator produces. MUST equal
132    /// `from_version() + 1`; the registry rejects anything else.
133    fn to_version(&self) -> GenomeSchemaVersion;
134
135    /// Stable identifier for diagnostics and logs. Should not change
136    /// across releases for a given step.
137    fn name(&self) -> &'static str;
138
139    /// Perform the transformation in place. Return diagnostics describing
140    /// what changed, or an error.
141    fn migrate(&self, genome: &mut Value) -> Result<MigrationStepDiagnostics, MigrationError>;
142}
143
144/// Aggregate result returned by a successful chain run.
145///
146/// Contains everything `validate-and-repair` needs to surface to clients
147/// per decision #8 in the design doc: the version range traversed, which
148/// named migrators and normalizers ran, per-step diagnostics, advisory
149/// warnings collected between hops, and any blocking errors raised by
150/// the final validator.
151#[derive(Debug, Clone)]
152pub struct ChainResult {
153    pub from_version: GenomeSchemaVersion,
154    pub to_version: GenomeSchemaVersion,
155    pub migrators_applied: Vec<&'static str>,
156    pub normalizers_applied: Vec<&'static str>,
157    pub per_step_diagnostics: Vec<MigrationStepDiagnostics>,
158    pub per_normalizer_diagnostics: Vec<NormalizationDiagnostics>,
159    pub advisory_warnings: Vec<String>,
160    pub blocking_errors: Vec<String>,
161}
162
163impl ChainResult {
164    /// True when the final-version validator reported zero errors.
165    pub fn is_blocking_clean(&self) -> bool {
166        self.blocking_errors.is_empty()
167    }
168}
169
170/// Holds the registered migrators, normalizers, and validators that the
171/// chain runner will dispatch through.
172///
173/// Migrators are keyed by `from_version` (one per integer; duplicates are
174/// rejected at registration). Normalizers are keyed by `schema_version`,
175/// at most one per version. Validators are keyed by `schema_version`.
176/// Contiguity (no gaps in the migrator chain) is checked at runner-start
177/// time over the actual range being traversed, not at registration time.
178pub struct ChainRegistry {
179    migrators: BTreeMap<u32, Box<dyn Migrator>>,
180    normalizers: BTreeMap<u32, Box<dyn Normalizer>>,
181    validators: BTreeMap<u32, Box<dyn Validator>>,
182}
183
184impl ChainRegistry {
185    pub fn new() -> Self {
186        Self {
187            migrators: BTreeMap::new(),
188            normalizers: BTreeMap::new(),
189            validators: BTreeMap::new(),
190        }
191    }
192
193    /// Register a migrator. Rejects duplicates and migrators whose
194    /// `to_version` is not exactly `from_version + 1`.
195    pub fn register_migrator(&mut self, migrator: Box<dyn Migrator>) -> Result<(), MigrationError> {
196        let from = migrator.from_version();
197        let to = migrator.to_version();
198        if to.as_u32() != from.as_u32().saturating_add(1) {
199            return Err(MigrationError::InvalidRegistry(format!(
200                "migrator '{}' declares from={} to={}, expected to=from+1",
201                migrator.name(),
202                from,
203                to
204            )));
205        }
206        if self.migrators.contains_key(&from.as_u32()) {
207            return Err(MigrationError::InvalidRegistry(format!(
208                "duplicate migrator with from_version={from}"
209            )));
210        }
211        self.migrators.insert(from.as_u32(), migrator);
212        Ok(())
213    }
214
215    /// Register a normalizer. Rejects duplicates at the same schema
216    /// version. At most one normalizer per version is supported on
217    /// purpose: composing multiple normalizers in a stable order is
218    /// future work and not needed today.
219    pub fn register_normalizer(
220        &mut self,
221        normalizer: Box<dyn Normalizer>,
222    ) -> Result<(), MigrationError> {
223        let v = normalizer.schema_version();
224        if self.normalizers.contains_key(&v.as_u32()) {
225            return Err(MigrationError::InvalidRegistry(format!(
226                "duplicate normalizer at schema_version={v}"
227            )));
228        }
229        self.normalizers.insert(v.as_u32(), normalizer);
230        Ok(())
231    }
232
233    /// Register a validator. Replaces any existing validator at the same
234    /// schema version (validators are policy-bearing; the latest registered
235    /// one wins).
236    pub fn register_validator(&mut self, validator: Box<dyn Validator>) {
237        let v = validator.schema_version().as_u32();
238        self.validators.insert(v, validator);
239    }
240
241    /// Look up the migrator that consumes genomes at `from`.
242    pub fn migrator_for(&self, from: GenomeSchemaVersion) -> Option<&dyn Migrator> {
243        self.migrators.get(&from.as_u32()).map(|b| b.as_ref())
244    }
245
246    /// Look up the normalizer at `version`.
247    pub fn normalizer_for(&self, version: GenomeSchemaVersion) -> Option<&dyn Normalizer> {
248        self.normalizers.get(&version.as_u32()).map(|b| b.as_ref())
249    }
250
251    /// Look up the validator at `version`.
252    pub fn validator_for(&self, version: GenomeSchemaVersion) -> Option<&dyn Validator> {
253        self.validators.get(&version.as_u32()).map(|b| b.as_ref())
254    }
255
256    /// Run the validator at `version` if one is registered, otherwise
257    /// return an empty advisory report stamped with that version. Used by
258    /// the chain runner so callers always see a consistent shape.
259    pub fn run_validator(&self, version: GenomeSchemaVersion, genome: &Value) -> ValidationReport {
260        match self.validator_for(version) {
261            Some(v) => v.validate(genome),
262            None => ValidationReport::new(version),
263        }
264    }
265
266    /// Number of migrators currently registered.
267    pub fn migrator_count(&self) -> usize {
268        self.migrators.len()
269    }
270
271    /// Number of normalizers currently registered.
272    pub fn normalizer_count(&self) -> usize {
273        self.normalizers.len()
274    }
275
276    /// Number of validators currently registered.
277    pub fn validator_count(&self) -> usize {
278        self.validators.len()
279    }
280}
281
282impl Default for ChainRegistry {
283    fn default() -> Self {
284        Self::new()
285    }
286}
287
288/// Test-only helpers shared with `chain.rs`.
289///
290/// Lives in its own non-`tests` module so that `pub(super)` re-exports
291/// don't trip `clippy::items_after_test_module`.
292#[cfg(test)]
293pub(super) mod test_support {
294    use super::*;
295    use serde_json::json;
296
297    /// Synthetic migrator that bumps a `step_count` field, used to
298    /// exercise the runner mechanics without depending on real domain
299    /// transforms.
300    pub struct SyntheticMigrator {
301        from: GenomeSchemaVersion,
302        to: GenomeSchemaVersion,
303        name: &'static str,
304        fail: bool,
305    }
306
307    impl SyntheticMigrator {
308        pub fn ok(from: u32, name: &'static str) -> Box<Self> {
309            Box::new(Self {
310                from: GenomeSchemaVersion(from),
311                to: GenomeSchemaVersion(from + 1),
312                name,
313                fail: false,
314            })
315        }
316
317        pub fn failing(from: u32, name: &'static str) -> Box<Self> {
318            Box::new(Self {
319                from: GenomeSchemaVersion(from),
320                to: GenomeSchemaVersion(from + 1),
321                name,
322                fail: true,
323            })
324        }
325    }
326
327    impl Migrator for SyntheticMigrator {
328        fn from_version(&self) -> GenomeSchemaVersion {
329            self.from
330        }
331
332        fn to_version(&self) -> GenomeSchemaVersion {
333            self.to
334        }
335
336        fn name(&self) -> &'static str {
337            self.name
338        }
339
340        fn migrate(&self, genome: &mut Value) -> Result<MigrationStepDiagnostics, MigrationError> {
341            if self.fail {
342                return Err(MigrationError::StepFailed {
343                    name: self.name,
344                    from: self.from,
345                    to: self.to,
346                    reason: "synthetic failure".to_string(),
347                });
348            }
349            let mut diag = MigrationStepDiagnostics::new(self.from, self.to);
350            let count = genome
351                .get("step_count")
352                .and_then(|v| v.as_u64())
353                .unwrap_or(0)
354                + 1;
355            genome
356                .as_object_mut()
357                .expect("test genome must be a JSON object")
358                .insert("step_count".to_string(), json!(count));
359            diag.record(format!("incremented step_count to {count}"));
360            Ok(diag)
361        }
362    }
363
364    pub fn make_ok(from: u32, name: &'static str) -> Box<dyn Migrator> {
365        SyntheticMigrator::ok(from, name)
366    }
367
368    pub fn make_failing(from: u32, name: &'static str) -> Box<dyn Migrator> {
369        SyntheticMigrator::failing(from, name)
370    }
371}
372
373#[cfg(test)]
374mod tests {
375    use super::test_support::SyntheticMigrator;
376    use super::*;
377    use serde_json::json;
378
379    #[test]
380    fn registry_accepts_a_well_formed_migrator() {
381        let mut reg = ChainRegistry::new();
382        reg.register_migrator(SyntheticMigrator::ok(2, "v2_to_v3"))
383            .unwrap();
384        assert_eq!(reg.migrator_count(), 1);
385        assert!(reg.migrator_for(GenomeSchemaVersion(2)).is_some());
386        assert!(reg.migrator_for(GenomeSchemaVersion(3)).is_none());
387    }
388
389    #[test]
390    fn registry_rejects_to_version_not_equal_to_from_plus_one() {
391        struct Skipping;
392        impl Migrator for Skipping {
393            fn from_version(&self) -> GenomeSchemaVersion {
394                GenomeSchemaVersion(2)
395            }
396            fn to_version(&self) -> GenomeSchemaVersion {
397                GenomeSchemaVersion(4)
398            }
399            fn name(&self) -> &'static str {
400                "skip"
401            }
402            fn migrate(
403                &self,
404                _genome: &mut Value,
405            ) -> Result<MigrationStepDiagnostics, MigrationError> {
406                unreachable!()
407            }
408        }
409        let mut reg = ChainRegistry::new();
410        let err = reg.register_migrator(Box::new(Skipping)).unwrap_err();
411        assert!(matches!(err, MigrationError::InvalidRegistry(_)));
412    }
413
414    #[test]
415    fn registry_rejects_duplicate_from_version() {
416        let mut reg = ChainRegistry::new();
417        reg.register_migrator(SyntheticMigrator::ok(2, "first"))
418            .unwrap();
419        let err = reg
420            .register_migrator(SyntheticMigrator::ok(2, "second"))
421            .unwrap_err();
422        assert!(matches!(err, MigrationError::InvalidRegistry(_)));
423    }
424
425    #[test]
426    fn migration_step_diagnostics_records_transformations() {
427        let mut diag =
428            MigrationStepDiagnostics::new(GenomeSchemaVersion(2), GenomeSchemaVersion(3));
429        diag.record("converted blueprint keys");
430        diag.record("renamed legacy fields");
431        assert_eq!(diag.transformations.len(), 2);
432        assert_eq!(diag.from_version, GenomeSchemaVersion(2));
433        assert_eq!(diag.to_version, GenomeSchemaVersion(3));
434    }
435
436    #[test]
437    fn run_validator_returns_empty_when_unregistered() {
438        let reg = ChainRegistry::new();
439        let report = reg.run_validator(GenomeSchemaVersion(3), &json!({}));
440        assert_eq!(report.schema_version, Some(GenomeSchemaVersion(3)));
441        assert!(report.is_clean());
442    }
443}