use serde::{Deserialize, Serialize};
use std::collections::HashMap;
pub const CLASSIFIER_SAMPLE_MORPHOLOGY: &str = "classifier_sample";
pub const CLASSIFIER_CLASS_MORPHOLOGY: &str = "classifier_class";
pub const CLASSIFIER_ASSOCIATIVE_MORPHOLOGY: &str = "associative_memory";
pub fn morphology_is_episodic_input(morphology_id: &str) -> bool {
matches!(
morphology_id,
"episodic_memory" | CLASSIFIER_SAMPLE_MORPHOLOGY | CLASSIFIER_CLASS_MORPHOLOGY
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClassifierMappingEffect {
pub applied: bool,
pub removed_output_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClassifierMapping {
pub src_area_id: String,
pub dst_area_id: String,
pub morphology_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ClassifierClassBinding {
pub class_area_id: String,
pub class_output_area_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Classifier {
pub classifier_id: String,
pub name: String,
pub parent_region_id: String,
pub coordinates_3d: [i32; 3],
#[serde(default)]
pub sample_area_ids: Vec<String>,
#[serde(default)]
pub class_bindings: Vec<ClassifierClassBinding>,
pub sample_memory_id: String,
pub class_memory_id: String,
pub error_area_id: String,
#[serde(default)]
pub reward_training: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub answer_feedback_area_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pain_area_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pleasure_area_id: Option<String>,
#[serde(default)]
pub answer_latency_bursts: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub learn_area_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub confidence_area_id: Option<String>,
#[serde(default)]
pub properties: HashMap<String, serde_json::Value>,
}
impl Classifier {
pub fn assembly_core_ids(&self) -> Vec<String> {
vec![self.sample_memory_id.clone(), self.class_memory_id.clone()]
}
pub fn owned_area_ids(&self) -> Vec<String> {
let mut owned = self.assembly_core_ids();
if !self.error_area_id.is_empty() {
owned.push(self.error_area_id.clone());
}
for binding in &self.class_bindings {
if !binding.class_output_area_id.is_empty() {
owned.push(binding.class_output_area_id.clone());
}
}
if let Some(pain_area_id) = &self.pain_area_id {
if !pain_area_id.is_empty() {
owned.push(pain_area_id.clone());
}
}
if let Some(pleasure_area_id) = &self.pleasure_area_id {
if !pleasure_area_id.is_empty() {
owned.push(pleasure_area_id.clone());
}
}
owned
}
pub fn input_area_ids(&self) -> Vec<String> {
let mut inputs = self.sample_area_ids.clone();
for binding in &self.class_bindings {
inputs.push(binding.class_area_id.clone());
}
inputs
}
pub fn owns_assembly_core(&self, area_id: &str) -> bool {
self.sample_memory_id == area_id || self.class_memory_id == area_id
}
pub fn owns_area(&self, area_id: &str) -> bool {
self.owns_assembly_core(area_id)
|| self.error_area_id == area_id
|| self.binding_for_output(area_id).is_some()
}
pub fn binding_for_class(&self, class_area_id: &str) -> Option<&ClassifierClassBinding> {
self.class_bindings
.iter()
.find(|binding| binding.class_area_id == class_area_id)
}
pub fn binding_for_output(&self, output_area_id: &str) -> Option<&ClassifierClassBinding> {
self.class_bindings
.iter()
.find(|binding| binding.class_output_area_id == output_area_id)
}
pub fn references_input(&self, area_id: &str) -> bool {
self.sample_area_ids.iter().any(|id| id == area_id)
|| self.binding_for_class(area_id).is_some()
}
pub fn apply_metadata_update(
&mut self,
name: Option<String>,
coordinates_3d: Option<[i32; 3]>,
) -> Result<(), String> {
self.apply_assembly_update(name, coordinates_3d, None)
}
pub fn apply_assembly_update(
&mut self,
name: Option<String>,
coordinates_3d: Option<[i32; 3]>,
parent_region_id: Option<String>,
) -> Result<(), String> {
if let Some(name) = name {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err("Classifier name cannot be blank".to_string());
}
self.name = trimmed.to_string();
}
if let Some(coordinates_3d) = coordinates_3d {
self.coordinates_3d = coordinates_3d;
}
if let Some(parent_region_id) = parent_region_id {
let trimmed = parent_region_id.trim();
if trimmed.is_empty() {
return Err("parent_region_id cannot be blank".to_string());
}
self.parent_region_id = trimmed.to_string();
}
Ok(())
}
pub fn required_mappings(&self) -> Vec<ClassifierMapping> {
let mut mappings = Vec::new();
for sample_area_id in &self.sample_area_ids {
mappings.push(ClassifierMapping {
src_area_id: sample_area_id.clone(),
dst_area_id: self.sample_memory_id.clone(),
morphology_id: CLASSIFIER_SAMPLE_MORPHOLOGY.to_string(),
});
}
for binding in &self.class_bindings {
mappings.push(ClassifierMapping {
src_area_id: binding.class_area_id.clone(),
dst_area_id: self.class_memory_id.clone(),
morphology_id: CLASSIFIER_CLASS_MORPHOLOGY.to_string(),
});
}
mappings.push(ClassifierMapping {
src_area_id: self.sample_memory_id.clone(),
dst_area_id: self.class_memory_id.clone(),
morphology_id: CLASSIFIER_ASSOCIATIVE_MORPHOLOGY.to_string(),
});
mappings
}
pub fn clear_input(&mut self, area_id: &str) -> Option<String> {
self.sample_area_ids.retain(|id| id != area_id);
if self.answer_feedback_area_id.as_deref() == Some(area_id) {
self.answer_feedback_area_id = None;
}
if self.learn_area_id.as_deref() == Some(area_id) {
self.learn_area_id = None;
}
if self.confidence_area_id.as_deref() == Some(area_id) {
self.confidence_area_id = None;
}
self.detach_class(area_id)
}
pub fn attach_sample(&mut self, sample_area_id: String) -> Result<(), String> {
let sample_area_id = required_area_id(sample_area_id, "sample_area_id")?;
if !self.sample_area_ids.iter().any(|id| id == &sample_area_id) {
self.sample_area_ids.push(sample_area_id);
}
Ok(())
}
pub fn detach_sample(&mut self, sample_area_id: &str) -> bool {
let before = self.sample_area_ids.len();
self.sample_area_ids.retain(|id| id != sample_area_id);
self.sample_area_ids.len() != before
}
pub fn attach_class(
&mut self,
class_area_id: String,
class_output_area_id: String,
) -> Result<(), String> {
let class_area_id = required_area_id(class_area_id, "class_area_id")?;
if self.binding_for_class(&class_area_id).is_some() {
return Err(format!(
"class_area_id {class_area_id} is already mapped to this classifier"
));
}
self.class_bindings.push(ClassifierClassBinding {
class_area_id,
class_output_area_id,
});
Ok(())
}
pub fn set_class_output(
&mut self,
class_area_id: &str,
class_output_area_id: String,
) -> Result<(), String> {
let class_output_area_id = required_area_id(class_output_area_id, "class_output_area_id")?;
let binding = self
.class_bindings
.iter_mut()
.find(|binding| binding.class_area_id == class_area_id)
.ok_or_else(|| format!("class_area_id {class_area_id} is not mapped"))?;
binding.class_output_area_id = class_output_area_id;
Ok(())
}
pub fn detach_class(&mut self, class_area_id: &str) -> Option<String> {
let position = self
.class_bindings
.iter()
.position(|binding| binding.class_area_id == class_area_id)?;
let removed = self.class_bindings.remove(position);
if removed.class_output_area_id.is_empty() {
None
} else {
Some(removed.class_output_area_id)
}
}
pub fn apply_mapping_change(
&mut self,
src_area_id: &str,
dst_area_id: &str,
morphology_id: &str,
removed: bool,
) -> Result<ClassifierMappingEffect, String> {
if dst_area_id == self.sample_memory_id && morphology_id == CLASSIFIER_SAMPLE_MORPHOLOGY {
if removed {
self.detach_sample(src_area_id);
} else {
self.attach_sample(src_area_id.to_string())?;
}
return Ok(ClassifierMappingEffect {
applied: true,
removed_output_id: None,
});
}
if dst_area_id == self.class_memory_id && morphology_id == CLASSIFIER_CLASS_MORPHOLOGY {
if removed {
return Ok(ClassifierMappingEffect {
applied: true,
removed_output_id: self.detach_class(src_area_id),
});
}
if self.binding_for_class(src_area_id).is_none() {
self.attach_class(src_area_id.to_string(), String::new())?;
}
return Ok(ClassifierMappingEffect {
applied: true,
removed_output_id: None,
});
}
Ok(ClassifierMappingEffect {
applied: false,
removed_output_id: None,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClassPairFiring {
pub input_coords: Vec<(u32, u32, u32)>,
pub output_coords: Vec<(u32, u32, u32)>,
}
pub fn class_output_reports_error(pair: &ClassPairFiring) -> bool {
if pair.output_coords.len() > 1 {
return true;
}
if pair.input_coords.is_empty() {
return false;
}
let mut input = pair.input_coords.clone();
let mut output = pair.output_coords.clone();
input.sort_unstable();
output.sort_unstable();
input.dedup();
output.dedup();
input != output
}
pub fn classifier_reports_error(pairs: &[ClassPairFiring]) -> bool {
pairs.iter().any(class_output_reports_error)
}
pub fn class_output_area_name(classifier_name: &str, class_area_name: &str) -> String {
format!("{classifier_name} {class_area_name} class output")
}
pub fn classifier_mapping_rule(morphology_id: &str, associative_window: u32) -> serde_json::Value {
let is_associative = morphology_id == CLASSIFIER_ASSOCIATIVE_MORPHOLOGY;
let plasticity_value = if is_associative { 1 } else { 0 };
serde_json::json!({
"morphology_id": morphology_id,
"morphology_scalar": [1, 1, 1],
"postSynapticCurrent_multiplier": 1,
"plasticity_flag": is_associative,
"plasticity_constant": plasticity_value,
"ltp_multiplier": plasticity_value,
"ltd_multiplier": plasticity_value,
"plasticity_window": if is_associative { associative_window } else { 0 },
})
}
fn required_area_id(area_id: String, field: &str) -> Result<String, String> {
let trimmed = area_id.trim();
if trimmed.is_empty() {
return Err(format!("{field} cannot be blank"));
}
Ok(trimmed.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Classifier {
let mut classifier = Classifier {
classifier_id: "clf-1".to_string(),
name: "demo".to_string(),
parent_region_id: "region".to_string(),
coordinates_3d: [1, 2, 3],
sample_area_ids: vec!["sample-a".to_string()],
class_bindings: Vec::new(),
sample_memory_id: "smem".to_string(),
class_memory_id: "cmem".to_string(),
error_area_id: "err".to_string(),
reward_training: false,
answer_feedback_area_id: None,
pain_area_id: None,
pleasure_area_id: None,
answer_latency_bursts: 0,
learn_area_id: None,
confidence_area_id: None,
properties: HashMap::new(),
};
classifier
.attach_class("class-a".to_string(), "out-a".to_string())
.expect("class");
classifier
}
#[test]
fn required_mappings_cover_each_sample_class_and_the_associative_edge() {
let mut classifier = sample();
classifier
.attach_sample("sample-b".to_string())
.expect("second sample");
classifier
.attach_class("class-b".to_string(), "out-b".to_string())
.expect("second class");
let mappings = classifier.required_mappings();
assert_eq!(mappings.len(), 5);
assert!(mappings.iter().any(|mapping| {
mapping.src_area_id == "sample-b"
&& mapping.dst_area_id == "smem"
&& mapping.morphology_id == CLASSIFIER_SAMPLE_MORPHOLOGY
}));
assert!(mappings.iter().any(|mapping| {
mapping.src_area_id == "class-b"
&& mapping.dst_area_id == "cmem"
&& mapping.morphology_id == CLASSIFIER_CLASS_MORPHOLOGY
}));
assert!(mappings
.iter()
.any(|mapping| { mapping.morphology_id == CLASSIFIER_ASSOCIATIVE_MORPHOLOGY }));
}
#[test]
fn removing_one_class_keeps_the_other_output() {
let mut classifier = sample();
classifier
.attach_class("class-b".to_string(), "out-b".to_string())
.expect("second class");
assert_eq!(
classifier.detach_class("class-a"),
Some("out-a".to_string())
);
assert!(classifier.binding_for_class("class-a").is_none());
assert_eq!(
classifier
.binding_for_class("class-b")
.map(|binding| binding.class_output_area_id.as_str()),
Some("out-b")
);
assert!(classifier.owns_area("out-b"));
assert!(!classifier.owns_area("out-a"));
}
#[test]
fn duplicate_class_mapping_is_rejected() {
let mut classifier = sample();
let result = classifier.attach_class("class-a".to_string(), "other".to_string());
assert!(result.is_err());
assert_eq!(classifier.class_bindings.len(), 1);
}
#[test]
fn combo_class_with_one_neuron_per_area_is_not_an_error() {
let pairs = vec![
ClassPairFiring {
input_coords: vec![(0, 0, 1)],
output_coords: vec![(0, 0, 1)],
},
ClassPairFiring {
input_coords: vec![(0, 0, 0)],
output_coords: vec![(0, 0, 0)],
},
ClassPairFiring {
input_coords: vec![(0, 0, 3)],
output_coords: vec![(0, 0, 3)],
},
ClassPairFiring {
input_coords: vec![(0, 0, 2)],
output_coords: vec![(0, 0, 2)],
},
];
assert!(!classifier_reports_error(&pairs));
}
#[test]
fn two_neurons_in_one_class_output_is_an_error() {
let pairs = vec![ClassPairFiring {
input_coords: vec![(0, 0, 1)],
output_coords: vec![(0, 0, 1), (0, 0, 4)],
}];
assert!(classifier_reports_error(&pairs));
}
#[test]
fn mismatch_is_per_class_area_and_silent_input_is_not_mismatch() {
assert!(class_output_reports_error(&ClassPairFiring {
input_coords: vec![(0, 0, 1)],
output_coords: vec![(0, 0, 2)],
}));
assert!(!class_output_reports_error(&ClassPairFiring {
input_coords: Vec::new(),
output_coords: vec![(0, 0, 2)],
}));
}
}