Skip to main content

feagi_structures/genomic/classifiers/
mod.rs

1// Copyright 2025 Neuraville Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4/*!
5First-class genome classifier assembly.
6
7Classifiers are stored under the top-level genome key `classifiers`, parallel
8to `brain_regions`. A classifier is not a brain region and is not exportable
9as a circuit. It records the assembly's properties, owned internals, and
10referenced input areas so neuroembryogenesis and area/mapping edits stay
11aligned.
12
13Each field binding is one Classifier mapping: an interconnect area scanning
14the shared kernel memory, with its own detection twin.
15*/
16
17use serde::{Deserialize, Serialize};
18use std::collections::HashMap;
19
20/// Morphology used for kernel → kernel-memory encode.
21pub const CLASSIFIER_KERNEL_MORPHOLOGY: &str = "episodic_memory";
22/// Morphology used for class → class-memory encode.
23pub const CLASSIFIER_CLASS_MORPHOLOGY: &str = "episodic_memory";
24/// Morphology used for kernel-memory → class-memory bind.
25pub const CLASSIFIER_ASSOCIATIVE_MORPHOLOGY: &str = "associative_memory";
26/// Morphology used for field → kernel-memory scan.
27pub const CLASSIFIER_SCAN_MORPHOLOGY: &str = "episodic_scan";
28
29/// Directed mapping owned by a classifier assembly.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct ClassifierMapping {
32    pub src_area_id: String,
33    pub dst_area_id: String,
34    pub morphology_id: String,
35}
36
37/// One field area scanning this classifier, and the twin that shows its detections.
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
39pub struct ClassifierField {
40    pub field_area_id: String,
41    pub scan_twin_id: String,
42}
43
44/// First-class classifier record persisted in the genome.
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
46pub struct Classifier {
47    /// UUID string, genome map key.
48    pub classifier_id: String,
49    pub name: String,
50    /// Region that contains this assembly. Classifiers are not regions.
51    pub parent_region_id: String,
52    pub coordinates_3d: [i32; 3],
53    /// Referenced inputs. Cleared when that area is deleted.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub kernel_area_id: Option<String>,
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub class_area_id: Option<String>,
58    /// Field scans. Empty until Classifier mappings are drawn.
59    #[serde(default)]
60    pub fields: Vec<ClassifierField>,
61    /// Owned internals. Deleting kernel or class memory deletes the classifier.
62    pub kernel_memory_id: String,
63    pub class_memory_id: String,
64    #[serde(default)]
65    pub properties: HashMap<String, serde_json::Value>,
66}
67
68impl Classifier {
69    /// Kernel memory and class memory. Deleting either deletes the assembly.
70    pub fn assembly_core_ids(&self) -> Vec<String> {
71        vec![self.kernel_memory_id.clone(), self.class_memory_id.clone()]
72    }
73
74    /// Owned internals deleted with the classifier, including every field twin.
75    pub fn owned_area_ids(&self) -> Vec<String> {
76        let mut owned = self.assembly_core_ids();
77        for field in &self.fields {
78            if !field.scan_twin_id.is_empty() {
79                owned.push(field.scan_twin_id.clone());
80            }
81        }
82        owned
83    }
84
85    /// Referenced input areas that may be cleared independently.
86    pub fn input_area_ids(&self) -> Vec<String> {
87        let mut inputs = Vec::new();
88        if let Some(kernel) = &self.kernel_area_id {
89            inputs.push(kernel.clone());
90        }
91        if let Some(class) = &self.class_area_id {
92            inputs.push(class.clone());
93        }
94        for field in &self.fields {
95            inputs.push(field.field_area_id.clone());
96        }
97        inputs
98    }
99
100    pub fn owns_assembly_core(&self, area_id: &str) -> bool {
101        self.kernel_memory_id == area_id || self.class_memory_id == area_id
102    }
103
104    /// True when `area_id` is kernel memory, class memory, or one of the field twins.
105    pub fn owns_area(&self, area_id: &str) -> bool {
106        self.owns_assembly_core(area_id) || self.field_for_twin(area_id).is_some()
107    }
108
109    pub fn field_for_twin(&self, twin_id: &str) -> Option<&ClassifierField> {
110        self.fields
111            .iter()
112            .find(|field| field.scan_twin_id == twin_id)
113    }
114
115    pub fn binding_for_field(&self, field_area_id: &str) -> Option<&ClassifierField> {
116        self.fields
117            .iter()
118            .find(|field| field.field_area_id == field_area_id)
119    }
120
121    /// True when `area_id` is a referenced input of this classifier.
122    pub fn references_input(&self, area_id: &str) -> bool {
123        self.kernel_area_id.as_deref() == Some(area_id)
124            || self.class_area_id.as_deref() == Some(area_id)
125            || self.binding_for_field(area_id).is_some()
126    }
127
128    /// Apply classifier-level edit. Does not replace owned internals or field bindings.
129    pub fn apply_assembly_update(
130        &mut self,
131        name: Option<String>,
132        coordinates_3d: Option<[i32; 3]>,
133        parent_region_id: Option<String>,
134        kernel_area_id: Option<String>,
135        class_area_id: Option<String>,
136    ) -> Result<(), String> {
137        if let Some(name) = name {
138            let trimmed = name.trim();
139            if trimmed.is_empty() {
140                return Err("Classifier name cannot be blank".to_string());
141            }
142            self.name = trimmed.to_string();
143        }
144        if let Some(coordinates_3d) = coordinates_3d {
145            self.coordinates_3d = coordinates_3d;
146        }
147        if let Some(parent_region_id) = parent_region_id {
148            let trimmed = parent_region_id.trim();
149            if trimmed.is_empty() {
150                return Err("parent_region_id cannot be blank".to_string());
151            }
152            self.parent_region_id = trimmed.to_string();
153        }
154        if let Some(kernel_area_id) = kernel_area_id {
155            self.kernel_area_id = Some(required_area_id(kernel_area_id, "kernel_area_id")?);
156        }
157        if let Some(class_area_id) = class_area_id {
158            self.class_area_id = Some(required_area_id(class_area_id, "class_area_id")?);
159        }
160        Ok(())
161    }
162
163    /// Apply classifier-level metadata. Does not change owned internals or input slots.
164    pub fn apply_metadata_update(
165        &mut self,
166        name: Option<String>,
167        coordinates_3d: Option<[i32; 3]>,
168    ) -> Result<(), String> {
169        self.apply_assembly_update(name, coordinates_3d, None, None, None)
170    }
171
172    /// Mappings this classifier requires given its current inputs.
173    pub fn required_mappings(&self) -> Vec<ClassifierMapping> {
174        let mut mappings = Vec::new();
175        if let Some(kernel) = &self.kernel_area_id {
176            mappings.push(ClassifierMapping {
177                src_area_id: kernel.clone(),
178                dst_area_id: self.kernel_memory_id.clone(),
179                morphology_id: CLASSIFIER_KERNEL_MORPHOLOGY.to_string(),
180            });
181        }
182        if let Some(class) = &self.class_area_id {
183            mappings.push(ClassifierMapping {
184                src_area_id: class.clone(),
185                dst_area_id: self.class_memory_id.clone(),
186                morphology_id: CLASSIFIER_CLASS_MORPHOLOGY.to_string(),
187            });
188        }
189        mappings.push(ClassifierMapping {
190            src_area_id: self.kernel_memory_id.clone(),
191            dst_area_id: self.class_memory_id.clone(),
192            morphology_id: CLASSIFIER_ASSOCIATIVE_MORPHOLOGY.to_string(),
193        });
194        for field in &self.fields {
195            mappings.push(ClassifierMapping {
196                src_area_id: field.field_area_id.clone(),
197                dst_area_id: self.kernel_memory_id.clone(),
198                morphology_id: CLASSIFIER_SCAN_MORPHOLOGY.to_string(),
199            });
200        }
201        mappings
202    }
203
204    /// Drop an input reference when that area is deleted.
205    pub fn clear_input(&mut self, area_id: &str) {
206        if self.kernel_area_id.as_deref() == Some(area_id) {
207            self.kernel_area_id = None;
208        }
209        if self.class_area_id.as_deref() == Some(area_id) {
210            self.class_area_id = None;
211        }
212        self.fields.retain(|field| field.field_area_id != area_id);
213    }
214
215    /// Remove one field binding. Returns the removed twin id.
216    pub fn detach_field(&mut self, field_area_id: &str) -> Option<String> {
217        let position = self
218            .fields
219            .iter()
220            .position(|field| field.field_area_id == field_area_id)?;
221        Some(self.fields.remove(position).scan_twin_id)
222    }
223
224    /// Remove the binding whose twin is `twin_id`. Returns the field area id.
225    pub fn detach_twin(&mut self, twin_id: &str) -> Option<String> {
226        let position = self
227            .fields
228            .iter()
229            .position(|field| field.scan_twin_id == twin_id)?;
230        Some(self.fields.remove(position).field_area_id)
231    }
232
233    pub fn attach_field(
234        &mut self,
235        field_area_id: String,
236        scan_twin_id: String,
237    ) -> Result<(), String> {
238        let field_area_id = required_area_id(field_area_id, "field_area_id")?;
239        let scan_twin_id = required_area_id(scan_twin_id, "scan_twin_id")?;
240        if self.binding_for_field(&field_area_id).is_some() {
241            return Err(format!(
242                "field_area_id {field_area_id} is already mapped to this classifier"
243            ));
244        }
245        self.fields.push(ClassifierField {
246            field_area_id,
247            scan_twin_id,
248        });
249        Ok(())
250    }
251
252    /// Bind or clear kernel/class inputs from a mapping change. Field scans are attached explicitly.
253    pub fn apply_mapping_change(
254        &mut self,
255        src_area_id: &str,
256        dst_area_id: &str,
257        morphology_id: &str,
258        removed: bool,
259    ) -> bool {
260        if dst_area_id == self.kernel_memory_id && morphology_id == CLASSIFIER_KERNEL_MORPHOLOGY {
261            self.kernel_area_id = if removed {
262                None
263            } else {
264                Some(src_area_id.to_string())
265            };
266            return true;
267        }
268        if dst_area_id == self.class_memory_id && morphology_id == CLASSIFIER_CLASS_MORPHOLOGY {
269            self.class_area_id = if removed {
270                None
271            } else {
272                Some(src_area_id.to_string())
273            };
274            return true;
275        }
276        false
277    }
278}
279
280fn required_area_id(area_id: String, field: &str) -> Result<String, String> {
281    let trimmed = area_id.trim();
282    if trimmed.is_empty() {
283        return Err(format!("{field} cannot be blank"));
284    }
285    Ok(trimmed.to_string())
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    fn sample() -> Classifier {
293        let mut classifier = Classifier {
294            classifier_id: "clf-1".to_string(),
295            name: "demo".to_string(),
296            parent_region_id: "region".to_string(),
297            coordinates_3d: [1, 2, 3],
298            kernel_area_id: Some("kernel".to_string()),
299            class_area_id: Some("class".to_string()),
300            fields: Vec::new(),
301            kernel_memory_id: "kmem".to_string(),
302            class_memory_id: "cmem".to_string(),
303            properties: HashMap::new(),
304        };
305        classifier
306            .attach_field("field".to_string(), "twin".to_string())
307            .expect("first field");
308        classifier
309    }
310
311    #[test]
312    fn required_mappings_cover_shared_edges_and_each_field() {
313        let mut classifier = sample();
314        classifier
315            .attach_field("field-b".to_string(), "twin-b".to_string())
316            .expect("second field");
317        let mappings = classifier.required_mappings();
318        assert_eq!(mappings.len(), 5);
319        assert!(mappings.iter().any(|m| {
320            m.src_area_id == "field"
321                && m.dst_area_id == "kmem"
322                && m.morphology_id == CLASSIFIER_SCAN_MORPHOLOGY
323        }));
324        assert!(mappings.iter().any(|m| m.src_area_id == "field-b"));
325    }
326
327    #[test]
328    fn deleting_one_field_keeps_the_other_eye() {
329        let mut classifier = sample();
330        classifier
331            .attach_field("field-b".to_string(), "twin-b".to_string())
332            .expect("second field");
333        assert_eq!(classifier.detach_field("field"), Some("twin".to_string()));
334        assert!(classifier.binding_for_field("field").is_none());
335        assert_eq!(
336            classifier
337                .binding_for_field("field-b")
338                .map(|f| f.scan_twin_id.as_str()),
339            Some("twin-b")
340        );
341        assert!(classifier.owns_assembly_core("kmem"));
342        assert!(!classifier.owns_area("twin"));
343        assert!(classifier.owns_area("twin-b"));
344    }
345
346    #[test]
347    fn duplicate_field_mapping_is_rejected() {
348        let mut classifier = sample();
349        let result = classifier.attach_field("field".to_string(), "other-twin".to_string());
350        assert!(result.is_err());
351        assert_eq!(classifier.fields.len(), 1);
352    }
353
354    #[test]
355    fn metadata_update_renames_without_touching_areas() {
356        let mut classifier = sample();
357        classifier
358            .apply_metadata_update(Some("  renamed  ".to_string()), Some([9, 8, 7]))
359            .expect("valid metadata");
360        assert_eq!(classifier.name, "renamed");
361        assert_eq!(classifier.coordinates_3d, [9, 8, 7]);
362        assert_eq!(classifier.kernel_memory_id, "kmem");
363        assert_eq!(classifier.fields[0].scan_twin_id, "twin");
364        assert_eq!(classifier.kernel_area_id.as_deref(), Some("kernel"));
365    }
366
367    #[test]
368    fn assembly_update_retargets_kernel_and_class_only() {
369        let mut classifier = sample();
370        classifier
371            .apply_assembly_update(
372                None,
373                None,
374                Some("other-region".to_string()),
375                Some("kernel2".to_string()),
376                Some("class2".to_string()),
377            )
378            .expect("valid assembly update");
379        assert_eq!(classifier.parent_region_id, "other-region");
380        assert_eq!(classifier.kernel_area_id.as_deref(), Some("kernel2"));
381        assert_eq!(classifier.class_area_id.as_deref(), Some("class2"));
382        assert_eq!(classifier.fields[0].field_area_id, "field");
383    }
384}