1use serde::{Deserialize, Serialize};
24use std::collections::HashMap;
25
26pub const CLASSIFIER_SAMPLE_MORPHOLOGY: &str = "classifier_sample";
28pub const CLASSIFIER_CLASS_MORPHOLOGY: &str = "classifier_class";
30pub const CLASSIFIER_ASSOCIATIVE_MORPHOLOGY: &str = "associative_memory";
32
33pub 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#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct ClassifierMappingEffect {
48 pub applied: bool,
49 pub removed_output_id: Option<String>,
50}
51
52#[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#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
72pub struct Classifier {
73 pub classifier_id: String,
75 pub name: String,
76 pub parent_region_id: String,
82 pub coordinates_3d: [i32; 3],
83 #[serde(default)]
85 pub sample_area_ids: Vec<String>,
86 #[serde(default)]
88 pub class_bindings: Vec<ClassifierClassBinding>,
89 pub sample_memory_id: String,
91 pub class_memory_id: String,
93 pub error_area_id: String,
96 #[serde(default)]
99 pub correct_area_id: String,
100 #[serde(default)]
102 pub reward_training: bool,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub answer_feedback_area_id: Option<String>,
106 #[serde(default, skip_serializing_if = "Vec::is_empty")]
110 pub reward_modulator_ids: Vec<String>,
111 #[serde(default)]
113 pub answer_latency_bursts: u32,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub learn_area_id: Option<String>,
117 #[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 pub fn assembly_core_ids(&self) -> Vec<String> {
127 vec![self.sample_memory_id.clone(), self.class_memory_id.clone()]
128 }
129
130 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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#[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
397pub 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
419pub fn classifier_reports_error(pairs: &[ClassPairFiring]) -> bool {
421 pairs.iter().any(class_output_reports_error)
422}
423
424pub fn classifier_reports_correct(pairs: &[ClassPairFiring]) -> bool {
426 pairs.iter().any(|pair| !pair.input_coords.is_empty()) && !classifier_reports_error(pairs)
427}
428
429pub const MEMORY_NEURON_FIRING_LATENCY_BURSTS: u32 = 1;
435
436pub const CLASS_MEMORY_REPLAY_BURSTS: u32 = 1;
438
439pub 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
448pub fn classifier_reward_eligibility_bursts(answer_delay_bursts: u32) -> u32 {
452 answer_delay_bursts + 2
453}
454
455pub fn class_output_area_name(classifier_name: &str, class_area_name: &str) -> String {
457 format!("{classifier_name} {class_area_name} class output")
458}
459
460pub 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}