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