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