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
7A classifier is packaging around two memory areas joined by `associative_memory`.
8It is stored under the top-level genome key `classifiers`, parallel to
9`brain_regions`. It is not a brain region and is not exportable as a circuit.
10
11External inputs are ordinary episodic connections selected by connectivity rule:
12
13- `classifier_sample` lands on the sample memory
14- `classifier_class` lands on the class memory and creates one class output
15  whose shape matches that class input
16
17The sample memory accepts any number of those inputs, including a mix of
18memory and interconnect areas. The class memory does the same. Pattern identity
19is the memory area's own episodic hash. The classifier does not add a second
20input rule on top of that.
21*/
22
23use serde::{Deserialize, Serialize};
24use std::collections::HashMap;
25
26/// Connectivity rule: episodic input into the classifier sample memory.
27pub const CLASSIFIER_SAMPLE_MORPHOLOGY: &str = "classifier_sample";
28/// Connectivity rule: episodic input into the classifier class memory.
29pub const CLASSIFIER_CLASS_MORPHOLOGY: &str = "classifier_class";
30/// Morphology used for sample-memory → class-memory bind.
31pub const CLASSIFIER_ASSOCIATIVE_MORPHOLOGY: &str = "associative_memory";
32
33/// True when a mapping rule is an episodic encode into a memory area.
34///
35/// Classifier sample and class rules are episodic encodes. A plain
36/// `episodic_memory` rule remains one too, so a classifier memory area still
37/// accepts the same upstream set a standalone memory area accepts.
38pub fn morphology_is_episodic_input(morphology_id: &str) -> bool {
39    matches!(
40        morphology_id,
41        "episodic_memory" | CLASSIFIER_SAMPLE_MORPHOLOGY | CLASSIFIER_CLASS_MORPHOLOGY
42    )
43}
44
45/// Result of recording one mapping edit on a classifier.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct ClassifierMappingEffect {
48    pub applied: bool,
49    pub removed_output_id: Option<String>,
50}
51
52/// Directed mapping owned by a classifier assembly.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct ClassifierMapping {
55    pub src_area_id: String,
56    pub dst_area_id: String,
57    pub morphology_id: String,
58}
59
60/// One class input and the output area created for it.
61///
62/// The output shape matches the class input. Several bindings may each have
63/// one firing neuron. That combo is one class, not an error.
64#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
65pub struct ClassifierClassBinding {
66    pub class_area_id: String,
67    pub class_output_area_id: String,
68}
69
70/// First-class classifier record persisted in the genome.
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
72pub struct Classifier {
73    /// UUID string, genome map key.
74    pub classifier_id: String,
75    pub name: String,
76    /// Circuit region that contains this assembly.
77    ///
78    /// That region is a child of the region the user added the classifier to,
79    /// and that parent may be root. The memory and error areas belong to this
80    /// circuit, not to root.
81    pub parent_region_id: String,
82    pub coordinates_3d: [i32; 3],
83    /// Episodic sample inputs on the sample memory. Order is not significant.
84    #[serde(default)]
85    pub sample_area_ids: Vec<String>,
86    /// Episodic class inputs and the output created for each one.
87    #[serde(default)]
88    pub class_bindings: Vec<ClassifierClassBinding>,
89    /// Owned sample memory. Deleting it deletes the classifier.
90    pub sample_memory_id: String,
91    /// Owned class memory. Deleting it deletes the classifier.
92    pub class_memory_id: String,
93    /// Owned 1x1x1 area. Fires when any class output mismatches its class input
94    /// or has more than one firing neuron.
95    pub error_area_id: String,
96    /// Per-pattern pain and pleasure on the associative mapping.
97    #[serde(default)]
98    pub reward_training: bool,
99    /// Correct-answer area. Empty until the user selects one.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub answer_feedback_area_id: Option<String>,
102    /// Hidden area whose firing is this classifier's pain signal.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub pain_area_id: Option<String>,
105    /// Hidden area whose firing is this classifier's pleasure signal.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub pleasure_area_id: Option<String>,
108    /// Bursts between a decision and the answer that grades it. Zero grades the same burst.
109    #[serde(default)]
110    pub answer_latency_bursts: u32,
111    /// When set, pain and pleasure run only on bursts this area fires.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub learn_area_id: Option<String>,
114    /// OPU that receives one surplus value per class channel.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub confidence_area_id: Option<String>,
117    #[serde(default)]
118    pub properties: HashMap<String, serde_json::Value>,
119}
120
121impl Classifier {
122    /// Sample memory and class memory. Deleting either deletes the assembly.
123    pub fn assembly_core_ids(&self) -> Vec<String> {
124        vec![self.sample_memory_id.clone(), self.class_memory_id.clone()]
125    }
126
127    /// Owned internals deleted with the classifier.
128    pub fn owned_area_ids(&self) -> Vec<String> {
129        let mut owned = self.assembly_core_ids();
130        if !self.error_area_id.is_empty() {
131            owned.push(self.error_area_id.clone());
132        }
133        for binding in &self.class_bindings {
134            if !binding.class_output_area_id.is_empty() {
135                owned.push(binding.class_output_area_id.clone());
136            }
137        }
138        if let Some(pain_area_id) = &self.pain_area_id {
139            if !pain_area_id.is_empty() {
140                owned.push(pain_area_id.clone());
141            }
142        }
143        if let Some(pleasure_area_id) = &self.pleasure_area_id {
144            if !pleasure_area_id.is_empty() {
145                owned.push(pleasure_area_id.clone());
146            }
147        }
148        owned
149    }
150
151    /// Referenced input areas that may be cleared independently.
152    pub fn input_area_ids(&self) -> Vec<String> {
153        let mut inputs = self.sample_area_ids.clone();
154        for binding in &self.class_bindings {
155            inputs.push(binding.class_area_id.clone());
156        }
157        inputs
158    }
159
160    pub fn owns_assembly_core(&self, area_id: &str) -> bool {
161        self.sample_memory_id == area_id || self.class_memory_id == area_id
162    }
163
164    /// True when `area_id` is sample memory, class memory, the error area, or a class output.
165    pub fn owns_area(&self, area_id: &str) -> bool {
166        self.owns_assembly_core(area_id)
167            || self.error_area_id == area_id
168            || self.binding_for_output(area_id).is_some()
169    }
170
171    pub fn binding_for_class(&self, class_area_id: &str) -> Option<&ClassifierClassBinding> {
172        self.class_bindings
173            .iter()
174            .find(|binding| binding.class_area_id == class_area_id)
175    }
176
177    pub fn binding_for_output(&self, output_area_id: &str) -> Option<&ClassifierClassBinding> {
178        self.class_bindings
179            .iter()
180            .find(|binding| binding.class_output_area_id == output_area_id)
181    }
182
183    /// True when `area_id` is a referenced sample or class input of this classifier.
184    pub fn references_input(&self, area_id: &str) -> bool {
185        self.sample_area_ids.iter().any(|id| id == area_id)
186            || self.binding_for_class(area_id).is_some()
187    }
188
189    /// Apply classifier-level metadata. Does not change owned internals or inputs.
190    pub fn apply_metadata_update(
191        &mut self,
192        name: Option<String>,
193        coordinates_3d: Option<[i32; 3]>,
194    ) -> Result<(), String> {
195        self.apply_assembly_update(name, coordinates_3d, None)
196    }
197
198    /// Apply classifier-level edit. Does not replace owned internals or input bindings.
199    pub fn apply_assembly_update(
200        &mut self,
201        name: Option<String>,
202        coordinates_3d: Option<[i32; 3]>,
203        parent_region_id: Option<String>,
204    ) -> Result<(), String> {
205        if let Some(name) = name {
206            let trimmed = name.trim();
207            if trimmed.is_empty() {
208                return Err("Classifier name cannot be blank".to_string());
209            }
210            self.name = trimmed.to_string();
211        }
212        if let Some(coordinates_3d) = coordinates_3d {
213            self.coordinates_3d = coordinates_3d;
214        }
215        if let Some(parent_region_id) = parent_region_id {
216            let trimmed = parent_region_id.trim();
217            if trimmed.is_empty() {
218                return Err("parent_region_id cannot be blank".to_string());
219            }
220            self.parent_region_id = trimmed.to_string();
221        }
222        Ok(())
223    }
224
225    /// Mappings this classifier requires given its current inputs.
226    pub fn required_mappings(&self) -> Vec<ClassifierMapping> {
227        let mut mappings = Vec::new();
228        for sample_area_id in &self.sample_area_ids {
229            mappings.push(ClassifierMapping {
230                src_area_id: sample_area_id.clone(),
231                dst_area_id: self.sample_memory_id.clone(),
232                morphology_id: CLASSIFIER_SAMPLE_MORPHOLOGY.to_string(),
233            });
234        }
235        for binding in &self.class_bindings {
236            mappings.push(ClassifierMapping {
237                src_area_id: binding.class_area_id.clone(),
238                dst_area_id: self.class_memory_id.clone(),
239                morphology_id: CLASSIFIER_CLASS_MORPHOLOGY.to_string(),
240            });
241        }
242        mappings.push(ClassifierMapping {
243            src_area_id: self.sample_memory_id.clone(),
244            dst_area_id: self.class_memory_id.clone(),
245            morphology_id: CLASSIFIER_ASSOCIATIVE_MORPHOLOGY.to_string(),
246        });
247        mappings
248    }
249
250    /// Drop an input reference when that area is deleted.
251    ///
252    /// Returns the class output id that must be deleted with a removed class input.
253    pub fn clear_input(&mut self, area_id: &str) -> Option<String> {
254        self.sample_area_ids.retain(|id| id != area_id);
255        if self.answer_feedback_area_id.as_deref() == Some(area_id) {
256            self.answer_feedback_area_id = None;
257        }
258        if self.learn_area_id.as_deref() == Some(area_id) {
259            self.learn_area_id = None;
260        }
261        if self.confidence_area_id.as_deref() == Some(area_id) {
262            self.confidence_area_id = None;
263        }
264        self.detach_class(area_id)
265    }
266
267    /// Record one sample input. A second call for the same area is a no-op success.
268    pub fn attach_sample(&mut self, sample_area_id: String) -> Result<(), String> {
269        let sample_area_id = required_area_id(sample_area_id, "sample_area_id")?;
270        if !self.sample_area_ids.iter().any(|id| id == &sample_area_id) {
271            self.sample_area_ids.push(sample_area_id);
272        }
273        Ok(())
274    }
275
276    /// Remove one sample input.
277    pub fn detach_sample(&mut self, sample_area_id: &str) -> bool {
278        let before = self.sample_area_ids.len();
279        self.sample_area_ids.retain(|id| id != sample_area_id);
280        self.sample_area_ids.len() != before
281    }
282
283    /// Record one class input and its output. The output id may be filled after the area exists.
284    pub fn attach_class(
285        &mut self,
286        class_area_id: String,
287        class_output_area_id: String,
288    ) -> Result<(), String> {
289        let class_area_id = required_area_id(class_area_id, "class_area_id")?;
290        if self.binding_for_class(&class_area_id).is_some() {
291            return Err(format!(
292                "class_area_id {class_area_id} is already mapped to this classifier"
293            ));
294        }
295        self.class_bindings.push(ClassifierClassBinding {
296            class_area_id,
297            class_output_area_id,
298        });
299        Ok(())
300    }
301
302    /// Fill the output id for a class input that was recorded before the area existed.
303    pub fn set_class_output(
304        &mut self,
305        class_area_id: &str,
306        class_output_area_id: String,
307    ) -> Result<(), String> {
308        let class_output_area_id = required_area_id(class_output_area_id, "class_output_area_id")?;
309        let binding = self
310            .class_bindings
311            .iter_mut()
312            .find(|binding| binding.class_area_id == class_area_id)
313            .ok_or_else(|| format!("class_area_id {class_area_id} is not mapped"))?;
314        binding.class_output_area_id = class_output_area_id;
315        Ok(())
316    }
317
318    /// Remove one class binding. Returns the output area id when one was recorded.
319    pub fn detach_class(&mut self, class_area_id: &str) -> Option<String> {
320        let position = self
321            .class_bindings
322            .iter()
323            .position(|binding| binding.class_area_id == class_area_id)?;
324        let removed = self.class_bindings.remove(position);
325        if removed.class_output_area_id.is_empty() {
326            None
327        } else {
328            Some(removed.class_output_area_id)
329        }
330    }
331
332    /// Bind or clear sample and class inputs from a mapping change.
333    ///
334    /// `applied` is false when the edge does not belong to this classifier.
335    /// `removed_output_id` is set when a class input mapping is removed.
336    pub fn apply_mapping_change(
337        &mut self,
338        src_area_id: &str,
339        dst_area_id: &str,
340        morphology_id: &str,
341        removed: bool,
342    ) -> Result<ClassifierMappingEffect, String> {
343        if dst_area_id == self.sample_memory_id && morphology_id == CLASSIFIER_SAMPLE_MORPHOLOGY {
344            if removed {
345                self.detach_sample(src_area_id);
346            } else {
347                self.attach_sample(src_area_id.to_string())?;
348            }
349            return Ok(ClassifierMappingEffect {
350                applied: true,
351                removed_output_id: None,
352            });
353        }
354        if dst_area_id == self.class_memory_id && morphology_id == CLASSIFIER_CLASS_MORPHOLOGY {
355            if removed {
356                return Ok(ClassifierMappingEffect {
357                    applied: true,
358                    removed_output_id: self.detach_class(src_area_id),
359                });
360            }
361            if self.binding_for_class(src_area_id).is_none() {
362                self.attach_class(src_area_id.to_string(), String::new())?;
363            }
364            return Ok(ClassifierMappingEffect {
365                applied: true,
366                removed_output_id: None,
367            });
368        }
369        Ok(ClassifierMappingEffect {
370            applied: false,
371            removed_output_id: None,
372        })
373    }
374}
375
376/// One class input/output pair observed on a single burst.
377#[derive(Debug, Clone, PartialEq, Eq)]
378pub struct ClassPairFiring {
379    pub input_coords: Vec<(u32, u32, u32)>,
380    pub output_coords: Vec<(u32, u32, u32)>,
381}
382
383/// True when this class output should raise the classifier error.
384///
385/// More than one firing neuron is counted inside this output only. Other class
386/// outputs may each have one firing neuron in the same burst. A silent class
387/// input is not a mismatch. A firing class input mismatches when its coordinates
388/// differ from this output.
389pub fn class_output_reports_error(pair: &ClassPairFiring) -> bool {
390    if pair.output_coords.len() > 1 {
391        return true;
392    }
393    if pair.input_coords.is_empty() {
394        return false;
395    }
396    let mut input = pair.input_coords.clone();
397    let mut output = pair.output_coords.clone();
398    input.sort_unstable();
399    output.sort_unstable();
400    input.dedup();
401    output.dedup();
402    input != output
403}
404
405/// True when any class output of this classifier raises the error.
406pub fn classifier_reports_error(pairs: &[ClassPairFiring]) -> bool {
407    pairs.iter().any(class_output_reports_error)
408}
409
410/// Visible name of one class output.
411pub fn class_output_area_name(classifier_name: &str, class_area_name: &str) -> String {
412    format!("{classifier_name} {class_area_name} class output")
413}
414
415/// Mapping rule written for one classifier-owned edge.
416///
417/// Only `associative_memory` is plastic. Sample and class rules are episodic
418/// encodes and create no synapses.
419pub fn classifier_mapping_rule(morphology_id: &str, associative_window: u32) -> serde_json::Value {
420    let is_associative = morphology_id == CLASSIFIER_ASSOCIATIVE_MORPHOLOGY;
421    let plasticity_value = if is_associative { 1 } else { 0 };
422    serde_json::json!({
423        "morphology_id": morphology_id,
424        "morphology_scalar": [1, 1, 1],
425        "postSynapticCurrent_multiplier": 1,
426        "plasticity_flag": is_associative,
427        "plasticity_constant": plasticity_value,
428        "ltp_multiplier": plasticity_value,
429        "ltd_multiplier": plasticity_value,
430        "plasticity_window": if is_associative { associative_window } else { 0 },
431    })
432}
433
434fn required_area_id(area_id: String, field: &str) -> Result<String, String> {
435    let trimmed = area_id.trim();
436    if trimmed.is_empty() {
437        return Err(format!("{field} cannot be blank"));
438    }
439    Ok(trimmed.to_string())
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    fn sample() -> Classifier {
447        let mut classifier = Classifier {
448            classifier_id: "clf-1".to_string(),
449            name: "demo".to_string(),
450            parent_region_id: "region".to_string(),
451            coordinates_3d: [1, 2, 3],
452            sample_area_ids: vec!["sample-a".to_string()],
453            class_bindings: Vec::new(),
454            sample_memory_id: "smem".to_string(),
455            class_memory_id: "cmem".to_string(),
456            error_area_id: "err".to_string(),
457            reward_training: false,
458            answer_feedback_area_id: None,
459            pain_area_id: None,
460            pleasure_area_id: None,
461            answer_latency_bursts: 0,
462            learn_area_id: None,
463            confidence_area_id: None,
464            properties: HashMap::new(),
465        };
466        classifier
467            .attach_class("class-a".to_string(), "out-a".to_string())
468            .expect("class");
469        classifier
470    }
471
472    #[test]
473    fn required_mappings_cover_each_sample_class_and_the_associative_edge() {
474        let mut classifier = sample();
475        classifier
476            .attach_sample("sample-b".to_string())
477            .expect("second sample");
478        classifier
479            .attach_class("class-b".to_string(), "out-b".to_string())
480            .expect("second class");
481        let mappings = classifier.required_mappings();
482        assert_eq!(mappings.len(), 5);
483        assert!(mappings.iter().any(|mapping| {
484            mapping.src_area_id == "sample-b"
485                && mapping.dst_area_id == "smem"
486                && mapping.morphology_id == CLASSIFIER_SAMPLE_MORPHOLOGY
487        }));
488        assert!(mappings.iter().any(|mapping| {
489            mapping.src_area_id == "class-b"
490                && mapping.dst_area_id == "cmem"
491                && mapping.morphology_id == CLASSIFIER_CLASS_MORPHOLOGY
492        }));
493        assert!(mappings
494            .iter()
495            .any(|mapping| { mapping.morphology_id == CLASSIFIER_ASSOCIATIVE_MORPHOLOGY }));
496    }
497
498    #[test]
499    fn removing_one_class_keeps_the_other_output() {
500        let mut classifier = sample();
501        classifier
502            .attach_class("class-b".to_string(), "out-b".to_string())
503            .expect("second class");
504        assert_eq!(
505            classifier.detach_class("class-a"),
506            Some("out-a".to_string())
507        );
508        assert!(classifier.binding_for_class("class-a").is_none());
509        assert_eq!(
510            classifier
511                .binding_for_class("class-b")
512                .map(|binding| binding.class_output_area_id.as_str()),
513            Some("out-b")
514        );
515        assert!(classifier.owns_area("out-b"));
516        assert!(!classifier.owns_area("out-a"));
517    }
518
519    #[test]
520    fn duplicate_class_mapping_is_rejected() {
521        let mut classifier = sample();
522        let result = classifier.attach_class("class-a".to_string(), "other".to_string());
523        assert!(result.is_err());
524        assert_eq!(classifier.class_bindings.len(), 1);
525    }
526
527    #[test]
528    fn combo_class_with_one_neuron_per_area_is_not_an_error() {
529        let pairs = vec![
530            ClassPairFiring {
531                input_coords: vec![(0, 0, 1)],
532                output_coords: vec![(0, 0, 1)],
533            },
534            ClassPairFiring {
535                input_coords: vec![(0, 0, 0)],
536                output_coords: vec![(0, 0, 0)],
537            },
538            ClassPairFiring {
539                input_coords: vec![(0, 0, 3)],
540                output_coords: vec![(0, 0, 3)],
541            },
542            ClassPairFiring {
543                input_coords: vec![(0, 0, 2)],
544                output_coords: vec![(0, 0, 2)],
545            },
546        ];
547        assert!(!classifier_reports_error(&pairs));
548    }
549
550    #[test]
551    fn two_neurons_in_one_class_output_is_an_error() {
552        let pairs = vec![ClassPairFiring {
553            input_coords: vec![(0, 0, 1)],
554            output_coords: vec![(0, 0, 1), (0, 0, 4)],
555        }];
556        assert!(classifier_reports_error(&pairs));
557    }
558
559    #[test]
560    fn mismatch_is_per_class_area_and_silent_input_is_not_mismatch() {
561        assert!(class_output_reports_error(&ClassPairFiring {
562            input_coords: vec![(0, 0, 1)],
563            output_coords: vec![(0, 0, 2)],
564        }));
565        assert!(!class_output_reports_error(&ClassPairFiring {
566            input_coords: Vec::new(),
567            output_coords: vec![(0, 0, 2)],
568        }));
569    }
570}