Skip to main content

feagi_evolutionary/
plasticity_detector.rs

1//! Plasticity detection for genome analysis
2//!
3//! This module provides utilities to detect whether a genome contains
4//! plasticity features (neuroplasticity via memory areas or synaptic plasticity via STDP).
5
6use serde_json::Value;
7use std::collections::HashMap;
8use tracing::{debug, error};
9
10/// Check if a genome JSON contains any form of plasticity
11///
12/// This function checks for:
13/// 1. Memory cortical areas (identified by `memory-b` flag = true)
14/// 2. STDP connections (identified by `plasticity_flag` = true in morphologies)
15///
16/// # Arguments
17/// * `genome_json` - The genome JSON Value
18///
19/// # Returns
20/// * `true` if plasticity is detected, `false` otherwise
21///
22pub fn genome_has_plasticity(genome_json: &Value) -> bool {
23    let has_memory = has_memory_areas(genome_json);
24    let has_stdp = has_stdp_connections(genome_json);
25
26    debug!(
27        target: "feagi-evolutionary",
28        "Plasticity detection: memory_areas={}, stdp_connections={}",
29        has_memory, has_stdp
30    );
31
32    has_memory || has_stdp
33}
34
35/// Check if genome has memory cortical areas
36///
37/// Memory areas are identified by the `memory-b` property set to `true` in the blueprint.
38/// The `_group` field may still be "CUSTOM", so we rely on the `memory-b` flag.
39///
40fn has_memory_areas(genome_json: &Value) -> bool {
41    if let Some(blueprint) = genome_json.get("blueprint").and_then(|b| b.as_object()) {
42        for (key, value) in blueprint {
43            // Check for memory-b flag
44            if key.ends_with("-cx-memory-b") {
45                if let Some(is_memory) = value.as_bool() {
46                    if is_memory {
47                        debug!(
48                            target: "feagi-evolutionary",
49                            "Found memory area via key: {}", key
50                        );
51                        return true;
52                    }
53                }
54            }
55        }
56    }
57    false
58}
59
60/// Check if genome has STDP connections (plastic synapses)
61///
62/// STDP connections are identified by `plasticity_flag: true` in the destination map morphologies.
63///
64fn has_stdp_connections(genome_json: &Value) -> bool {
65    if let Some(blueprint) = genome_json.get("blueprint").and_then(|b| b.as_object()) {
66        for (key, value) in blueprint {
67            // Check for destination mapping (dstmap)
68            if key.ends_with("-cx-dstmap-d") {
69                if let Some(dstmap) = value.as_object() {
70                    for (dst_area_id, morphology_list) in dstmap {
71                        if let Some(morphologies) = morphology_list.as_array() {
72                            for morph in morphologies {
73                                if let Some(plasticity_flag) = morph.get("plasticity_flag") {
74                                    if plasticity_flag.as_bool() == Some(true) {
75                                        debug!(
76                                            target: "feagi-evolutionary",
77                                            "Found STDP connection: key={}, dst={}", key, dst_area_id
78                                        );
79                                        return true;
80                                    }
81                                }
82                            }
83                        }
84                    }
85                }
86            }
87        }
88    }
89    false
90}
91
92/// Genome key selecting change-based MP encoding for a memory area.
93pub const MP_CHANGE_MODE_KEY: &str = "mp_change_mode";
94/// Genome key for the differential rounding step, in membrane potential units.
95pub const MP_DELTA_QUANTIZATION_KEY: &str = "mp_delta_quantization";
96/// Genome key for the ratio rounding step, in percent per compounding bucket.
97pub const MP_RATIO_QUANTIZATION_KEY: &str = "mp_ratio_quantization";
98/// Genome key for replay-oriented MP learning.
99pub const MP_LEARNING_ENABLED_KEY: &str = "mp_learning_enabled";
100
101/// Change-based membrane potential encoding for a memory area.
102///
103/// When not `None`, pattern identity comes from how upstream MPs change between
104/// consecutive frames of the temporal window instead of which neurons fired.
105/// Mutually exclusive with `mp_learning_enabled`.
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
107pub enum MpChangeMode {
108    /// Pattern identity from fired neuron sets (standard episodic memory).
109    #[default]
110    None,
111    /// Identity from quantized MP differences; a neuron missing from a frame counts as MP 0.
112    Differential,
113    /// Identity from compounding percentage changes between positive MPs only.
114    Ratio,
115}
116
117impl MpChangeMode {
118    /// Canonical genome string for this mode.
119    pub const fn as_str(self) -> &'static str {
120        match self {
121            MpChangeMode::None => "none",
122            MpChangeMode::Differential => "mp_differential",
123            MpChangeMode::Ratio => "mp_ratio",
124        }
125    }
126
127    /// Parse a canonical genome string.
128    pub fn parse(value: &str) -> Option<Self> {
129        match value {
130            "none" => Some(MpChangeMode::None),
131            "mp_differential" => Some(MpChangeMode::Differential),
132            "mp_ratio" => Some(MpChangeMode::Ratio),
133            _ => None,
134        }
135    }
136}
137
138/// Validate the MP encoding properties of a memory area.
139///
140/// Checks the change mode value, that both quantization levels are finite and
141/// positive when present, and that MP learning and a change mode are not both on.
142/// Absent keys are valid and resolve to defaults.
143pub fn validate_memory_mp_properties(properties: &HashMap<String, Value>) -> Result<(), String> {
144    let mode = match properties.get(MP_CHANGE_MODE_KEY) {
145        None | Some(Value::Null) => MpChangeMode::None,
146        Some(Value::String(s)) => MpChangeMode::parse(s).ok_or_else(|| {
147            format!(
148                "{MP_CHANGE_MODE_KEY} '{s}' is invalid; expected one of 'none', 'mp_differential', 'mp_ratio'"
149            )
150        })?,
151        Some(other) => {
152            return Err(format!(
153                "{MP_CHANGE_MODE_KEY} must be a string, got {other}"
154            ))
155        }
156    };
157
158    for key in [MP_DELTA_QUANTIZATION_KEY, MP_RATIO_QUANTIZATION_KEY] {
159        match properties.get(key) {
160            None | Some(Value::Null) => {}
161            Some(value) => match value.as_f64() {
162                Some(q) if q.is_finite() && q > 0.0 => {}
163                _ => {
164                    return Err(format!(
165                        "{key} must be a number greater than 0, got {value}"
166                    ))
167                }
168            },
169        }
170    }
171
172    let mp_learning = match properties.get(MP_LEARNING_ENABLED_KEY) {
173        None | Some(Value::Null) => false,
174        Some(Value::Bool(b)) => *b,
175        Some(other) => {
176            return Err(format!(
177                "{MP_LEARNING_ENABLED_KEY} must be a boolean, got {other}"
178            ))
179        }
180    };
181    if mp_learning && mode != MpChangeMode::None {
182        return Err(format!(
183            "{MP_LEARNING_ENABLED_KEY} and {MP_CHANGE_MODE_KEY}='{}' are mutually exclusive",
184            mode.as_str()
185        ));
186    }
187    Ok(())
188}
189
190/// Memory-specific cortical area properties
191#[derive(Debug, Clone)]
192pub struct MemoryAreaProperties {
193    /// Number of timesteps to consider for temporal pattern detection
194    pub temporal_depth: u32,
195    /// Threshold for long-term memory formation (number of activations)
196    pub longterm_threshold: u32,
197    /// Rate at which neuron lifespan grows with reactivations
198    pub lifespan_growth_rate: f32,
199    /// Initial lifespan for newly created memory neurons
200    pub init_lifespan: u32,
201    /// When true, membrane potentials are captured and stored with replay frames
202    pub mp_learning_enabled: bool,
203    /// Change-based MP encoding; exclusive with `mp_learning_enabled`.
204    pub mp_change_mode: MpChangeMode,
205    /// Differential rounding step in membrane potential units.
206    pub mp_delta_quantization: f32,
207    /// Ratio rounding step in percent per compounding bucket.
208    pub mp_ratio_quantization: f32,
209    /// Minimum fired voxels inside a scan window for that window to be considered.
210    pub min_window_activity: u32,
211    /// Skip the entire field scan when current-burst density exceeds this fraction (0.0-1.0).
212    pub scan_skip_density: f32,
213}
214
215impl Default for MemoryAreaProperties {
216    fn default() -> Self {
217        Self {
218            // Enforce minimum temporal depth of 1; 0 is not a valid configuration because
219            // the pattern detector needs at least one timestep of history.
220            temporal_depth: 1,
221            longterm_threshold: 100,
222            lifespan_growth_rate: 1.0,
223            init_lifespan: 9,
224            mp_learning_enabled: false,
225            mp_change_mode: MpChangeMode::None,
226            mp_delta_quantization: 1.0,
227            mp_ratio_quantization: 20.0,
228            min_window_activity: 1,
229            scan_skip_density: 1.0,
230        }
231    }
232}
233
234/// Extract memory-specific properties from a cortical area's properties HashMap
235///
236/// Returns `Some(MemoryAreaProperties)` if the area is a memory area (`is_mem_type` = true),
237/// otherwise returns `None`.
238///
239/// # Arguments
240/// * `properties` - The cortical area properties HashMap
241///
242pub fn extract_memory_properties(
243    properties: &HashMap<String, Value>,
244) -> Option<MemoryAreaProperties> {
245    let is_memory = properties
246        .get("is_mem_type")
247        .and_then(|v| v.as_bool())
248        .unwrap_or(false);
249
250    if !is_memory {
251        return None;
252    }
253
254    let defaults = MemoryAreaProperties::default();
255    let mp_change_mode = match properties.get(MP_CHANGE_MODE_KEY).and_then(|v| v.as_str()) {
256        None => defaults.mp_change_mode,
257        Some(s) => MpChangeMode::parse(s).unwrap_or_else(|| {
258            // @architecture:acceptable - emergency fallback; validate_memory_mp_properties
259            // rejects unknown modes at genome load and API update.
260            error!(
261                target: "feagi-evolutionary",
262                "Unknown {} '{}'; change encoding disabled for this memory area",
263                MP_CHANGE_MODE_KEY, s
264            );
265            MpChangeMode::None
266        }),
267    };
268
269    Some(MemoryAreaProperties {
270        temporal_depth: properties
271            .get("temporal_depth")
272            .and_then(|v| v.as_u64())
273            .unwrap_or(1)
274            .max(1) as u32,
275        longterm_threshold: properties
276            .get("longterm_mem_threshold")
277            .and_then(|v| v.as_u64())
278            .unwrap_or(100) as u32,
279        lifespan_growth_rate: properties
280            .get("lifespan_growth_rate")
281            .and_then(|v| v.as_f64())
282            .unwrap_or(1.0) as f32,
283        init_lifespan: properties
284            .get("init_lifespan")
285            .and_then(|v| v.as_u64())
286            .unwrap_or(9) as u32,
287        mp_learning_enabled: properties
288            .get(MP_LEARNING_ENABLED_KEY)
289            .and_then(|v| v.as_bool())
290            .unwrap_or(defaults.mp_learning_enabled),
291        mp_change_mode,
292        mp_delta_quantization: properties
293            .get(MP_DELTA_QUANTIZATION_KEY)
294            .and_then(|v| v.as_f64())
295            .map(|v| v as f32)
296            .unwrap_or(defaults.mp_delta_quantization),
297        mp_ratio_quantization: properties
298            .get(MP_RATIO_QUANTIZATION_KEY)
299            .and_then(|v| v.as_f64())
300            .map(|v| v as f32)
301            .unwrap_or(defaults.mp_ratio_quantization),
302        min_window_activity: properties
303            .get("min_window_activity")
304            .and_then(|v| v.as_u64())
305            .unwrap_or(1)
306            .max(1) as u32,
307        scan_skip_density: properties
308            .get("scan_skip_density")
309            .and_then(|v| v.as_f64())
310            .unwrap_or(1.0)
311            .clamp(0.0, 1.0) as f32,
312    })
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use serde_json::json;
319
320    #[test]
321    fn test_no_plasticity() {
322        let genome = json!({
323            "blueprint": {
324                "_____10c-Y2FfX19fX50=-cx-_group-t": "CUSTOM",
325                "_____10c-Y2FfX19fX50=-cx-memory-b": false,
326                "_____10c-Y2FfX19fX50=-cx-dstmap-d": {
327                    "b2ltZwkAAAA=": [{
328                        "morphology_id": "projector",
329                        "plasticity_flag": false
330                    }]
331                }
332            }
333        });
334
335        assert!(!genome_has_plasticity(&genome));
336    }
337
338    #[test]
339    fn test_has_memory_areas() {
340        let genome = json!({
341            "blueprint": {
342                "_____10c-Y21fX19fXxg=-cx-_group-t": "CUSTOM",
343                "_____10c-Y21fX19fXxg=-cx-memory-b": true,
344                "_____10c-Y21fX19fXxg=-cx-mem__t-i": 100
345            }
346        });
347
348        assert!(genome_has_plasticity(&genome));
349        assert!(has_memory_areas(&genome));
350        assert!(!has_stdp_connections(&genome));
351    }
352
353    #[test]
354    fn test_has_stdp_connections() {
355        let genome = json!({
356            "blueprint": {
357                "_____10c-Y2FfX19fX50=-cx-_group-t": "CUSTOM",
358                "_____10c-Y2FfX19fX50=-cx-memory-b": false,
359                "_____10c-Y2FfX19fX50=-cx-dstmap-d": {
360                    "b2ltZwkAAAA=": [{
361                        "morphology_id": "projector",
362                        "plasticity_flag": true,
363                        "postSynapticCurrent_multiplier": 1
364                    }]
365                }
366            }
367        });
368
369        assert!(genome_has_plasticity(&genome));
370        assert!(!has_memory_areas(&genome));
371        assert!(has_stdp_connections(&genome));
372    }
373
374    #[test]
375    fn test_has_both_plasticity_types() {
376        let genome = json!({
377            "blueprint": {
378                "_____10c-Y21fX19fXxg=-cx-memory-b": true,
379                "_____10c-Y2FfX19fX50=-cx-dstmap-d": {
380                    "b2ltZwkAAAA=": [{
381                        "morphology_id": "projector",
382                        "plasticity_flag": true
383                    }]
384                }
385            }
386        });
387
388        assert!(genome_has_plasticity(&genome));
389        assert!(has_memory_areas(&genome));
390        assert!(has_stdp_connections(&genome));
391    }
392
393    #[test]
394    fn test_extract_memory_properties() {
395        let mut properties = HashMap::new();
396        properties.insert("is_mem_type".to_string(), json!(true));
397        properties.insert("temporal_depth".to_string(), json!(5));
398        properties.insert("longterm_mem_threshold".to_string(), json!(200));
399        properties.insert("lifespan_growth_rate".to_string(), json!(1.5));
400        properties.insert("init_lifespan".to_string(), json!(15));
401
402        let mem_props = extract_memory_properties(&properties).expect("Should extract properties");
403        assert_eq!(mem_props.temporal_depth, 5);
404        assert_eq!(mem_props.longterm_threshold, 200);
405        assert_eq!(mem_props.lifespan_growth_rate, 1.5);
406        assert_eq!(mem_props.init_lifespan, 15);
407    }
408
409    #[test]
410    fn test_extract_memory_properties_defaults() {
411        let mut properties = HashMap::new();
412        properties.insert("is_mem_type".to_string(), json!(true));
413
414        let mem_props = extract_memory_properties(&properties).expect("Should extract properties");
415        assert_eq!(mem_props.temporal_depth, 1);
416        assert_eq!(mem_props.longterm_threshold, 100);
417        assert_eq!(mem_props.lifespan_growth_rate, 1.0);
418        assert_eq!(mem_props.init_lifespan, 9);
419    }
420
421    fn memory_props(entries: &[(&str, Value)]) -> HashMap<String, Value> {
422        let mut properties = HashMap::new();
423        properties.insert("is_mem_type".to_string(), json!(true));
424        for (k, v) in entries {
425            properties.insert(k.to_string(), v.clone());
426        }
427        properties
428    }
429
430    #[test]
431    fn test_mp_change_defaults() {
432        let mem_props = extract_memory_properties(&memory_props(&[])).unwrap();
433        assert_eq!(mem_props.mp_change_mode, MpChangeMode::None);
434        assert_eq!(mem_props.mp_delta_quantization, 1.0);
435        assert_eq!(mem_props.mp_ratio_quantization, 20.0);
436        assert!(validate_memory_mp_properties(&memory_props(&[])).is_ok());
437    }
438
439    #[test]
440    fn test_mp_change_mode_round_trip() {
441        for mode in [
442            MpChangeMode::None,
443            MpChangeMode::Differential,
444            MpChangeMode::Ratio,
445        ] {
446            assert_eq!(MpChangeMode::parse(mode.as_str()), Some(mode));
447        }
448        assert_eq!(MpChangeMode::parse("MP_RATIO"), None);
449        assert_eq!(MpChangeMode::parse(""), None);
450    }
451
452    #[test]
453    fn test_extract_mp_change_properties() {
454        let props = memory_props(&[
455            (MP_CHANGE_MODE_KEY, json!("mp_ratio")),
456            (MP_DELTA_QUANTIZATION_KEY, json!(0.5)),
457            (MP_RATIO_QUANTIZATION_KEY, json!(10)),
458        ]);
459        let mem_props = extract_memory_properties(&props).unwrap();
460        assert_eq!(mem_props.mp_change_mode, MpChangeMode::Ratio);
461        assert_eq!(mem_props.mp_delta_quantization, 0.5);
462        assert_eq!(mem_props.mp_ratio_quantization, 10.0);
463    }
464
465    #[test]
466    fn test_validate_rejects_unknown_mode() {
467        let props = memory_props(&[(MP_CHANGE_MODE_KEY, json!("mp_percent"))]);
468        assert!(validate_memory_mp_properties(&props)
469            .unwrap_err()
470            .contains("mp_percent"));
471        let props = memory_props(&[(MP_CHANGE_MODE_KEY, json!(1))]);
472        assert!(validate_memory_mp_properties(&props).is_err());
473    }
474
475    #[test]
476    fn test_validate_rejects_non_positive_quantization() {
477        for key in [MP_DELTA_QUANTIZATION_KEY, MP_RATIO_QUANTIZATION_KEY] {
478            for bad in [json!(0), json!(-1.0), json!("1")] {
479                let props = memory_props(&[(key, bad.clone())]);
480                assert!(
481                    validate_memory_mp_properties(&props).is_err(),
482                    "{key}={bad} should be rejected"
483                );
484            }
485        }
486    }
487
488    #[test]
489    fn test_validate_rejects_mp_learning_with_change_mode() {
490        let props = memory_props(&[
491            (MP_LEARNING_ENABLED_KEY, json!(true)),
492            (MP_CHANGE_MODE_KEY, json!("mp_differential")),
493        ]);
494        assert!(validate_memory_mp_properties(&props)
495            .unwrap_err()
496            .contains("mutually exclusive"));
497
498        let props = memory_props(&[
499            (MP_LEARNING_ENABLED_KEY, json!(true)),
500            (MP_CHANGE_MODE_KEY, json!("none")),
501        ]);
502        assert!(validate_memory_mp_properties(&props).is_ok());
503    }
504
505    #[test]
506    fn test_extract_memory_properties_non_memory() {
507        let mut properties = HashMap::new();
508        properties.insert("is_mem_type".to_string(), json!(false));
509
510        assert!(extract_memory_properties(&properties).is_none());
511    }
512}