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    /// Owned 1x1x1 area. Fires when every firing class input is answered by its class
97    /// output and no output raises the error. Empty on classifiers created before it existed.
98    #[serde(default)]
99    pub correct_area_id: String,
100    /// Per-pattern pain and pleasure on the associative mapping.
101    #[serde(default)]
102    pub reward_training: bool,
103    /// Correct-answer area. Empty until the user selects one.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub answer_feedback_area_id: Option<String>,
106    /// `synaptic.reward` modulators the associative mapping learns from (R-STDP).
107    /// The classifier's own right-answer and wrong-answer modulators are listed here
108    /// like any other source.
109    #[serde(default, skip_serializing_if = "Vec::is_empty")]
110    pub reward_modulator_ids: Vec<String>,
111    /// Bursts between a decision and the answer that grades it. Zero grades the same burst.
112    #[serde(default)]
113    pub answer_latency_bursts: u32,
114    /// When set, pain and pleasure run only on bursts this area fires.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub learn_area_id: Option<String>,
117    /// OPU that receives one surplus value per class channel.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub confidence_area_id: Option<String>,
120    #[serde(default)]
121    pub properties: HashMap<String, serde_json::Value>,
122}
123
124impl Classifier {
125    /// Sample memory and class memory. Deleting either deletes the assembly.
126    pub fn assembly_core_ids(&self) -> Vec<String> {
127        vec![self.sample_memory_id.clone(), self.class_memory_id.clone()]
128    }
129
130    /// Owned internals deleted with the classifier.
131    pub fn owned_area_ids(&self) -> Vec<String> {
132        let mut owned = self.assembly_core_ids();
133        if !self.error_area_id.is_empty() {
134            owned.push(self.error_area_id.clone());
135        }
136        if !self.correct_area_id.is_empty() {
137            owned.push(self.correct_area_id.clone());
138        }
139        for binding in &self.class_bindings {
140            if !binding.class_output_area_id.is_empty() {
141                owned.push(binding.class_output_area_id.clone());
142            }
143        }
144        owned
145    }
146
147    /// Referenced input areas that may be cleared independently.
148    pub fn input_area_ids(&self) -> Vec<String> {
149        let mut inputs = self.sample_area_ids.clone();
150        for binding in &self.class_bindings {
151            inputs.push(binding.class_area_id.clone());
152        }
153        inputs
154    }
155
156    /// Forget a deleted error or correct area. Returns true when `area_id` was one of them.
157    /// The next reward update creates the area again.
158    pub fn clear_answer_area(&mut self, area_id: &str) -> bool {
159        if area_id.is_empty() {
160            return false;
161        }
162        if self.error_area_id == area_id {
163            self.error_area_id.clear();
164            return true;
165        }
166        if self.correct_area_id == area_id {
167            self.correct_area_id.clear();
168            return true;
169        }
170        false
171    }
172
173    pub fn owns_assembly_core(&self, area_id: &str) -> bool {
174        self.sample_memory_id == area_id || self.class_memory_id == area_id
175    }
176
177    /// True when `area_id` is sample memory, class memory, the error area, or a class output.
178    pub fn owns_area(&self, area_id: &str) -> bool {
179        self.owns_assembly_core(area_id)
180            || self.error_area_id == area_id
181            || (!self.correct_area_id.is_empty() && self.correct_area_id == area_id)
182            || self.binding_for_output(area_id).is_some()
183    }
184
185    pub fn binding_for_class(&self, class_area_id: &str) -> Option<&ClassifierClassBinding> {
186        self.class_bindings
187            .iter()
188            .find(|binding| binding.class_area_id == class_area_id)
189    }
190
191    pub fn binding_for_output(&self, output_area_id: &str) -> Option<&ClassifierClassBinding> {
192        self.class_bindings
193            .iter()
194            .find(|binding| binding.class_output_area_id == output_area_id)
195    }
196
197    /// True when `area_id` is a referenced sample or class input of this classifier.
198    pub fn references_input(&self, area_id: &str) -> bool {
199        self.sample_area_ids.iter().any(|id| id == area_id)
200            || self.binding_for_class(area_id).is_some()
201    }
202
203    /// Apply classifier-level metadata. Does not change owned internals or inputs.
204    pub fn apply_metadata_update(
205        &mut self,
206        name: Option<String>,
207        coordinates_3d: Option<[i32; 3]>,
208    ) -> Result<(), String> {
209        self.apply_assembly_update(name, coordinates_3d, None)
210    }
211
212    /// Apply classifier-level edit. Does not replace owned internals or input bindings.
213    pub fn apply_assembly_update(
214        &mut self,
215        name: Option<String>,
216        coordinates_3d: Option<[i32; 3]>,
217        parent_region_id: Option<String>,
218    ) -> Result<(), String> {
219        if let Some(name) = name {
220            let trimmed = name.trim();
221            if trimmed.is_empty() {
222                return Err("Classifier name cannot be blank".to_string());
223            }
224            self.name = trimmed.to_string();
225        }
226        if let Some(coordinates_3d) = coordinates_3d {
227            self.coordinates_3d = coordinates_3d;
228        }
229        if let Some(parent_region_id) = parent_region_id {
230            let trimmed = parent_region_id.trim();
231            if trimmed.is_empty() {
232                return Err("parent_region_id cannot be blank".to_string());
233            }
234            self.parent_region_id = trimmed.to_string();
235        }
236        Ok(())
237    }
238
239    /// Mappings this classifier requires given its current inputs.
240    pub fn required_mappings(&self) -> Vec<ClassifierMapping> {
241        let mut mappings = Vec::new();
242        for sample_area_id in &self.sample_area_ids {
243            mappings.push(ClassifierMapping {
244                src_area_id: sample_area_id.clone(),
245                dst_area_id: self.sample_memory_id.clone(),
246                morphology_id: CLASSIFIER_SAMPLE_MORPHOLOGY.to_string(),
247            });
248        }
249        for binding in &self.class_bindings {
250            mappings.push(ClassifierMapping {
251                src_area_id: binding.class_area_id.clone(),
252                dst_area_id: self.class_memory_id.clone(),
253                morphology_id: CLASSIFIER_CLASS_MORPHOLOGY.to_string(),
254            });
255        }
256        mappings.push(ClassifierMapping {
257            src_area_id: self.sample_memory_id.clone(),
258            dst_area_id: self.class_memory_id.clone(),
259            morphology_id: CLASSIFIER_ASSOCIATIVE_MORPHOLOGY.to_string(),
260        });
261        mappings
262    }
263
264    /// Drop an input reference when that area is deleted.
265    ///
266    /// Returns the class output id that must be deleted with a removed class input.
267    pub fn clear_input(&mut self, area_id: &str) -> Option<String> {
268        self.sample_area_ids.retain(|id| id != area_id);
269        if self.answer_feedback_area_id.as_deref() == Some(area_id) {
270            self.answer_feedback_area_id = None;
271        }
272        if self.learn_area_id.as_deref() == Some(area_id) {
273            self.learn_area_id = None;
274        }
275        if self.confidence_area_id.as_deref() == Some(area_id) {
276            self.confidence_area_id = None;
277        }
278        self.detach_class(area_id)
279    }
280
281    /// Record one sample input. A second call for the same area is a no-op success.
282    pub fn attach_sample(&mut self, sample_area_id: String) -> Result<(), String> {
283        let sample_area_id = required_area_id(sample_area_id, "sample_area_id")?;
284        if !self.sample_area_ids.iter().any(|id| id == &sample_area_id) {
285            self.sample_area_ids.push(sample_area_id);
286        }
287        Ok(())
288    }
289
290    /// Remove one sample input.
291    pub fn detach_sample(&mut self, sample_area_id: &str) -> bool {
292        let before = self.sample_area_ids.len();
293        self.sample_area_ids.retain(|id| id != sample_area_id);
294        self.sample_area_ids.len() != before
295    }
296
297    /// Record one class input and its output. The output id may be filled after the area exists.
298    pub fn attach_class(
299        &mut self,
300        class_area_id: String,
301        class_output_area_id: String,
302    ) -> Result<(), String> {
303        let class_area_id = required_area_id(class_area_id, "class_area_id")?;
304        if self.binding_for_class(&class_area_id).is_some() {
305            return Err(format!(
306                "class_area_id {class_area_id} is already mapped to this classifier"
307            ));
308        }
309        self.class_bindings.push(ClassifierClassBinding {
310            class_area_id,
311            class_output_area_id,
312        });
313        Ok(())
314    }
315
316    /// Fill the output id for a class input that was recorded before the area existed.
317    pub fn set_class_output(
318        &mut self,
319        class_area_id: &str,
320        class_output_area_id: String,
321    ) -> Result<(), String> {
322        let class_output_area_id = required_area_id(class_output_area_id, "class_output_area_id")?;
323        let binding = self
324            .class_bindings
325            .iter_mut()
326            .find(|binding| binding.class_area_id == class_area_id)
327            .ok_or_else(|| format!("class_area_id {class_area_id} is not mapped"))?;
328        binding.class_output_area_id = class_output_area_id;
329        Ok(())
330    }
331
332    /// Remove one class binding. Returns the output area id when one was recorded.
333    pub fn detach_class(&mut self, class_area_id: &str) -> Option<String> {
334        let position = self
335            .class_bindings
336            .iter()
337            .position(|binding| binding.class_area_id == class_area_id)?;
338        let removed = self.class_bindings.remove(position);
339        if removed.class_output_area_id.is_empty() {
340            None
341        } else {
342            Some(removed.class_output_area_id)
343        }
344    }
345
346    /// Bind or clear sample and class inputs from a mapping change.
347    ///
348    /// `applied` is false when the edge does not belong to this classifier.
349    /// `removed_output_id` is set when a class input mapping is removed.
350    pub fn apply_mapping_change(
351        &mut self,
352        src_area_id: &str,
353        dst_area_id: &str,
354        morphology_id: &str,
355        removed: bool,
356    ) -> Result<ClassifierMappingEffect, String> {
357        if dst_area_id == self.sample_memory_id && morphology_id == CLASSIFIER_SAMPLE_MORPHOLOGY {
358            if removed {
359                self.detach_sample(src_area_id);
360            } else {
361                self.attach_sample(src_area_id.to_string())?;
362            }
363            return Ok(ClassifierMappingEffect {
364                applied: true,
365                removed_output_id: None,
366            });
367        }
368        if dst_area_id == self.class_memory_id && morphology_id == CLASSIFIER_CLASS_MORPHOLOGY {
369            if removed {
370                return Ok(ClassifierMappingEffect {
371                    applied: true,
372                    removed_output_id: self.detach_class(src_area_id),
373                });
374            }
375            if self.binding_for_class(src_area_id).is_none() {
376                self.attach_class(src_area_id.to_string(), String::new())?;
377            }
378            return Ok(ClassifierMappingEffect {
379                applied: true,
380                removed_output_id: None,
381            });
382        }
383        Ok(ClassifierMappingEffect {
384            applied: false,
385            removed_output_id: None,
386        })
387    }
388}
389
390/// One class input/output pair observed on a single burst.
391#[derive(Debug, Clone, PartialEq, Eq)]
392pub struct ClassPairFiring {
393    pub input_coords: Vec<(u32, u32, u32)>,
394    pub output_coords: Vec<(u32, u32, u32)>,
395}
396
397/// True when this class output should raise the classifier error.
398///
399/// More than one firing neuron is counted inside this output only. Other class
400/// outputs may each have one firing neuron in the same burst. A silent class
401/// input is not a mismatch. A firing class input mismatches when its coordinates
402/// differ from this output.
403pub fn class_output_reports_error(pair: &ClassPairFiring) -> bool {
404    if pair.output_coords.len() > 1 {
405        return true;
406    }
407    if pair.input_coords.is_empty() {
408        return false;
409    }
410    let mut input = pair.input_coords.clone();
411    let mut output = pair.output_coords.clone();
412    input.sort_unstable();
413    output.sort_unstable();
414    input.dedup();
415    output.dedup();
416    input != output
417}
418
419/// True when any class output of this classifier raises the error.
420pub fn classifier_reports_error(pairs: &[ClassPairFiring]) -> bool {
421    pairs.iter().any(class_output_reports_error)
422}
423
424/// True when at least one class input fired and every class output answered it without error.
425pub fn classifier_reports_correct(pairs: &[ClassPairFiring]) -> bool {
426    pairs.iter().any(|pair| !pair.input_coords.is_empty()) && !classifier_reports_error(pairs)
427}
428
429/// Bursts between a memory area receiving its upstream input and its memory neuron firing.
430///
431/// Pattern detection runs after the input arrives, and the memory neuron is injected into the
432/// next burst's FCL (the memory-neuron injection path in the burst engine, `npu.rs`, and
433/// plasticity `inject_memory_neuron_to_fcl`), so it fires one burst later.
434pub const MEMORY_NEURON_FIRING_LATENCY_BURSTS: u32 = 1;
435
436/// Bursts from the class-memory replay neuron firing to the class output firing.
437pub const CLASS_MEMORY_REPLAY_BURSTS: u32 = 1;
438
439/// Bursts from a class input firing to its class output answering it: the hop into class
440/// memory (at least one burst), plus the memory neuron's firing latency, plus the replay
441/// onto the output. With the default one-burst rule delay this is 3.
442pub fn classifier_answer_delay_bursts(input_to_memory_delay_bursts: u32) -> u32 {
443    input_to_memory_delay_bursts.max(1)
444        + MEMORY_NEURON_FIRING_LATENCY_BURSTS
445        + CLASS_MEMORY_REPLAY_BURSTS
446}
447
448/// Bursts from co-activity on the associative mapping until the answer reaches it as reward:
449/// the answer delay, one burst for the correct or error area to fire, and one for the
450/// modulator to commit its signal.
451pub fn classifier_reward_eligibility_bursts(answer_delay_bursts: u32) -> u32 {
452    answer_delay_bursts + 2
453}
454
455/// Visible name of one class output.
456pub fn class_output_area_name(classifier_name: &str, class_area_name: &str) -> String {
457    format!("{classifier_name} {class_area_name} class output")
458}
459
460/// Mapping rule written for one classifier-owned edge.
461///
462/// Only `associative_memory` is plastic. Sample and class rules are episodic
463/// encodes and create no synapses.
464pub fn classifier_mapping_rule(morphology_id: &str, associative_window: u32) -> serde_json::Value {
465    let is_associative = morphology_id == CLASSIFIER_ASSOCIATIVE_MORPHOLOGY;
466    let plasticity_value = if is_associative { 1 } else { 0 };
467    serde_json::json!({
468        "morphology_id": morphology_id,
469        "morphology_scalar": [1, 1, 1],
470        "postSynapticCurrent_multiplier": 1,
471        "plasticity_flag": is_associative,
472        "plasticity_constant": plasticity_value,
473        "ltp_multiplier": plasticity_value,
474        "ltd_multiplier": plasticity_value,
475        "plasticity_window": if is_associative { associative_window } else { 0 },
476    })
477}
478
479fn required_area_id(area_id: String, field: &str) -> Result<String, String> {
480    let trimmed = area_id.trim();
481    if trimmed.is_empty() {
482        return Err(format!("{field} cannot be blank"));
483    }
484    Ok(trimmed.to_string())
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490
491    fn sample() -> Classifier {
492        let mut classifier = Classifier {
493            classifier_id: "clf-1".to_string(),
494            name: "demo".to_string(),
495            parent_region_id: "region".to_string(),
496            coordinates_3d: [1, 2, 3],
497            sample_area_ids: vec!["sample-a".to_string()],
498            class_bindings: Vec::new(),
499            sample_memory_id: "smem".to_string(),
500            class_memory_id: "cmem".to_string(),
501            error_area_id: "err".to_string(),
502            reward_training: false,
503            answer_feedback_area_id: None,
504            correct_area_id: String::new(),
505            reward_modulator_ids: Vec::new(),
506            answer_latency_bursts: 0,
507            learn_area_id: None,
508            confidence_area_id: None,
509            properties: HashMap::new(),
510        };
511        classifier
512            .attach_class("class-a".to_string(), "out-a".to_string())
513            .expect("class");
514        classifier
515    }
516
517    #[test]
518    fn deleted_answer_area_is_forgotten_and_no_longer_owned() {
519        let mut classifier = sample();
520        classifier.correct_area_id = "ok".to_string();
521        assert!(classifier.owns_area("ok"));
522        assert!(classifier.clear_answer_area("ok"));
523        assert!(classifier.correct_area_id.is_empty());
524        assert!(!classifier.owned_area_ids().contains(&"ok".to_string()));
525        assert!(classifier.clear_answer_area("err"));
526        assert!(classifier.error_area_id.is_empty());
527        assert!(
528            !classifier.clear_answer_area("smem"),
529            "only answer areas are cleared"
530        );
531        assert!(!classifier.clear_answer_area(""));
532    }
533
534    #[test]
535    fn answer_delay_and_eligibility_follow_the_circuit() {
536        assert_eq!(classifier_answer_delay_bursts(1), 3);
537        assert_eq!(
538            classifier_answer_delay_bursts(0),
539            3,
540            "a zero rule delay still takes one burst"
541        );
542        assert_eq!(classifier_answer_delay_bursts(3), 5);
543        assert_eq!(classifier_reward_eligibility_bursts(3), 5);
544    }
545
546    #[test]
547    fn correct_needs_an_answered_input_and_no_error() {
548        let answered = ClassPairFiring {
549            input_coords: vec![(0, 0, 0)],
550            output_coords: vec![(0, 0, 0)],
551        };
552        let silent = ClassPairFiring {
553            input_coords: Vec::new(),
554            output_coords: Vec::new(),
555        };
556        let wrong = ClassPairFiring {
557            input_coords: vec![(0, 0, 0)],
558            output_coords: vec![(1, 0, 0)],
559        };
560        assert!(classifier_reports_correct(&[
561            answered.clone(),
562            silent.clone()
563        ]));
564        assert!(!classifier_reports_correct(&[silent]));
565        assert!(!classifier_reports_correct(&[answered, wrong]));
566    }
567
568    #[test]
569    fn legacy_affect_fields_are_ignored() {
570        let json = serde_json::json!({
571            "classifier_id": "clf-1",
572            "name": "demo",
573            "parent_region_id": "region",
574            "coordinates_3d": [0, 0, 0],
575            "sample_memory_id": "smem",
576            "class_memory_id": "cmem",
577            "error_area_id": "err",
578            "pain_area_id": "legacy-pain",
579            "properties": {}
580        });
581        let classifier: Classifier = serde_json::from_value(json).expect("classifier");
582        assert!(classifier.correct_area_id.is_empty());
583        assert!(classifier.reward_modulator_ids.is_empty());
584        assert!(!classifier
585            .owned_area_ids()
586            .contains(&"legacy-pain".to_string()));
587    }
588
589    #[test]
590    fn required_mappings_cover_each_sample_class_and_the_associative_edge() {
591        let mut classifier = sample();
592        classifier
593            .attach_sample("sample-b".to_string())
594            .expect("second sample");
595        classifier
596            .attach_class("class-b".to_string(), "out-b".to_string())
597            .expect("second class");
598        let mappings = classifier.required_mappings();
599        assert_eq!(mappings.len(), 5);
600        assert!(mappings.iter().any(|mapping| {
601            mapping.src_area_id == "sample-b"
602                && mapping.dst_area_id == "smem"
603                && mapping.morphology_id == CLASSIFIER_SAMPLE_MORPHOLOGY
604        }));
605        assert!(mappings.iter().any(|mapping| {
606            mapping.src_area_id == "class-b"
607                && mapping.dst_area_id == "cmem"
608                && mapping.morphology_id == CLASSIFIER_CLASS_MORPHOLOGY
609        }));
610        assert!(mappings
611            .iter()
612            .any(|mapping| { mapping.morphology_id == CLASSIFIER_ASSOCIATIVE_MORPHOLOGY }));
613    }
614
615    #[test]
616    fn removing_one_class_keeps_the_other_output() {
617        let mut classifier = sample();
618        classifier
619            .attach_class("class-b".to_string(), "out-b".to_string())
620            .expect("second class");
621        assert_eq!(
622            classifier.detach_class("class-a"),
623            Some("out-a".to_string())
624        );
625        assert!(classifier.binding_for_class("class-a").is_none());
626        assert_eq!(
627            classifier
628                .binding_for_class("class-b")
629                .map(|binding| binding.class_output_area_id.as_str()),
630            Some("out-b")
631        );
632        assert!(classifier.owns_area("out-b"));
633        assert!(!classifier.owns_area("out-a"));
634    }
635
636    #[test]
637    fn duplicate_class_mapping_is_rejected() {
638        let mut classifier = sample();
639        let result = classifier.attach_class("class-a".to_string(), "other".to_string());
640        assert!(result.is_err());
641        assert_eq!(classifier.class_bindings.len(), 1);
642    }
643
644    #[test]
645    fn combo_class_with_one_neuron_per_area_is_not_an_error() {
646        let pairs = vec![
647            ClassPairFiring {
648                input_coords: vec![(0, 0, 1)],
649                output_coords: vec![(0, 0, 1)],
650            },
651            ClassPairFiring {
652                input_coords: vec![(0, 0, 0)],
653                output_coords: vec![(0, 0, 0)],
654            },
655            ClassPairFiring {
656                input_coords: vec![(0, 0, 3)],
657                output_coords: vec![(0, 0, 3)],
658            },
659            ClassPairFiring {
660                input_coords: vec![(0, 0, 2)],
661                output_coords: vec![(0, 0, 2)],
662            },
663        ];
664        assert!(!classifier_reports_error(&pairs));
665    }
666
667    #[test]
668    fn two_neurons_in_one_class_output_is_an_error() {
669        let pairs = vec![ClassPairFiring {
670            input_coords: vec![(0, 0, 1)],
671            output_coords: vec![(0, 0, 1), (0, 0, 4)],
672        }];
673        assert!(classifier_reports_error(&pairs));
674    }
675
676    #[test]
677    fn mismatch_is_per_class_area_and_silent_input_is_not_mismatch() {
678        assert!(class_output_reports_error(&ClassPairFiring {
679            input_coords: vec![(0, 0, 1)],
680            output_coords: vec![(0, 0, 2)],
681        }));
682        assert!(!class_output_reports_error(&ClassPairFiring {
683            input_coords: Vec::new(),
684            output_coords: vec![(0, 0, 2)],
685        }));
686    }
687}