Skip to main content

feagi_api/common/
agent_registration.rs

1// Copyright 2025 Neuraville Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Shared registration helpers used across transports.
5
6use crate::common::ApiState;
7use base64::{engine::general_purpose, Engine as _};
8use feagi_config::load_config;
9use feagi_services::types::CreateCorticalAreaParams;
10use feagi_structures::genomic::cortical_area::descriptors::{
11    CorticalSubUnitIndex, CorticalUnitIndex,
12};
13use feagi_structures::genomic::cortical_area::io_cortical_area_configuration_flag::{
14    FrameChangeHandling, PercentageNeuronPositioning,
15};
16use feagi_structures::genomic::{MotorCorticalUnit, SensoryCorticalUnit};
17use serde_json::Value;
18use std::collections::{HashMap, HashSet};
19use tracing::{info, warn};
20
21const MOTOR_AREA_X_GAP_VOXELS: i32 = 10;
22const SEGMENTED_VISION_GROUP_X_GAP_VOXELS: i32 = 10;
23
24fn build_friendly_unit_name(unit_label: &str, group: u8, sub_unit_index: usize) -> String {
25    format!("{unit_label}-{}-{}", group, sub_unit_index)
26}
27
28fn non_empty_string(value: Option<&Value>) -> Option<String> {
29    value
30        .and_then(|v| v.as_str())
31        .map(str::trim)
32        .filter(|s| !s.is_empty())
33        .map(ToString::to_string)
34}
35
36fn extract_grouping_array(unit_def: &Value) -> &[Value] {
37    unit_def
38        .get("device_grouping")
39        .and_then(|v| v.as_array())
40        .map(Vec::as_slice)
41        .unwrap_or(&[])
42}
43
44fn first_grouping_property(unit_def: &Value, key: &str) -> Option<String> {
45    extract_grouping_array(unit_def)
46        .iter()
47        .find_map(|grouping| {
48            non_empty_string(
49                grouping
50                    .get("device_properties")
51                    .and_then(|v| v.as_object())
52                    .and_then(|props| props.get(key)),
53            )
54        })
55}
56
57fn resolve_registration_name(unit_def: &Value, default_name: &str) -> String {
58    non_empty_string(unit_def.get("friendly_name"))
59        .or_else(|| first_grouping_property(unit_def, "bundle_id"))
60        .or_else(|| {
61            non_empty_string(
62                extract_grouping_array(unit_def)
63                    .first()?
64                    .get("friendly_name"),
65            )
66        })
67        .unwrap_or_else(|| default_name.to_string())
68}
69
70fn should_auto_rename(current_name: &str, cortical_id: &str, legacy_default_name: &str) -> bool {
71    current_name == cortical_id || current_name == legacy_default_name
72}
73
74fn build_io_config_map() -> Result<serde_json::Map<String, serde_json::Value>, String> {
75    let mut config = serde_json::Map::new();
76    config.insert(
77        "frame_change_handling".to_string(),
78        serde_json::to_value(FrameChangeHandling::Absolute)
79            .map_err(|e| format!("Failed to serialize FrameChangeHandling: {}", e))?,
80    );
81    config.insert(
82        "percentage_neuron_positioning".to_string(),
83        serde_json::to_value(PercentageNeuronPositioning::Linear)
84            .map_err(|e| format!("Failed to serialize PercentageNeuronPositioning: {}", e))?,
85    );
86    Ok(config)
87}
88
89fn build_io_config_map_from_unit_def(
90    unit_def: &Value,
91) -> Result<serde_json::Map<String, serde_json::Value>, String> {
92    let io_flags = unit_def
93        .get("io_configuration_flags")
94        .and_then(|v| v.as_object());
95
96    let frame_value = io_flags
97        .and_then(|flags| flags.get("frame_change_handling"))
98        .cloned()
99        .or_else(|| unit_def.get("frame_change_handling").cloned())
100        .ok_or_else(|| "unit_def missing frame_change_handling".to_string())?;
101    let positioning_value = io_flags
102        .and_then(|flags| flags.get("percentage_neuron_positioning"))
103        .cloned()
104        .or_else(|| unit_def.get("percentage_neuron_positioning").cloned())
105        // Some legacy registration payloads omit this field on motor units.
106        // Use the deterministic default used elsewhere in this module.
107        .unwrap_or_else(|| serde_json::json!(PercentageNeuronPositioning::Linear));
108
109    let frame: FrameChangeHandling = serde_json::from_value(frame_value)
110        .map_err(|e| format!("Invalid frame_change_handling value: {}", e))?;
111    let positioning: PercentageNeuronPositioning = serde_json::from_value(positioning_value)
112        .map_err(|e| format!("Invalid percentage_neuron_positioning value: {}", e))?;
113
114    let mut config = serde_json::Map::new();
115    config.insert(
116        "frame_change_handling".to_string(),
117        serde_json::to_value(frame)
118            .map_err(|e| format!("Failed to serialize FrameChangeHandling: {}", e))?,
119    );
120    config.insert(
121        "percentage_neuron_positioning".to_string(),
122        serde_json::to_value(positioning)
123            .map_err(|e| format!("Failed to serialize PercentageNeuronPositioning: {}", e))?,
124    );
125    Ok(config)
126}
127
128fn as_nonzero_usize(value: Option<&Value>) -> Option<usize> {
129    value
130        .and_then(|v| v.as_u64())
131        .map(|v| v as usize)
132        .filter(|v| *v > 0)
133}
134
135fn color_channel_count_from_value(value: Option<&Value>) -> Option<usize> {
136    match value {
137        Some(Value::String(layout)) => match layout.as_str() {
138            "GrayScale" => Some(1),
139            "RG" => Some(2),
140            "RGB" => Some(3),
141            "RGBA" => Some(4),
142            _ => None,
143        },
144        Some(Value::Number(number)) => number
145            .as_u64()
146            .map(|v| v as usize)
147            .filter(|v| (1..=4).contains(v)),
148        _ => None,
149    }
150}
151
152fn encoder_variant_payload<'a>(encoder_properties: &'a Value, variant: &str) -> Option<&'a Value> {
153    let object = encoder_properties.as_object()?;
154    if let Some(payload) = object.get(variant) {
155        return Some(payload);
156    }
157    // Accept already-unwrapped payloads when variant tagging is stripped.
158    if variant == "CartesianPlane" && object.contains_key("image_resolution") {
159        return Some(encoder_properties);
160    }
161    if variant == "SegmentedImageFrame" && object.contains_key("segment_xy_resolutions") {
162        return Some(encoder_properties);
163    }
164    None
165}
166
167fn extract_cartesian_plane_dimensions(encoder_properties: &Value) -> Option<(usize, usize, usize)> {
168    let payload = encoder_variant_payload(encoder_properties, "CartesianPlane")?;
169    let resolution = payload.get("image_resolution")?;
170    let width = as_nonzero_usize(resolution.get("width"))?;
171    let height = as_nonzero_usize(resolution.get("height"))?;
172    let channels = color_channel_count_from_value(payload.get("color_channel_layout"))?;
173    Some((width, height, channels))
174}
175
176fn extract_segmented_vision_dimensions(
177    encoder_properties: &Value,
178    sub_unit_index: usize,
179) -> Option<(usize, usize, usize)> {
180    let payload = encoder_variant_payload(encoder_properties, "SegmentedImageFrame")?;
181    let resolutions = payload.get("segment_xy_resolutions")?.as_object()?;
182    let segment_key = match sub_unit_index {
183        0 => "lower_left",
184        1 => "lower_middle",
185        2 => "lower_right",
186        3 => "middle_left",
187        4 => "center",
188        5 => "middle_right",
189        6 => "upper_left",
190        7 => "upper_middle",
191        8 => "upper_right",
192        _ => return None,
193    };
194    let segment_resolution = resolutions.get(segment_key)?;
195    let width = as_nonzero_usize(segment_resolution.get("width"))?;
196    let height = as_nonzero_usize(segment_resolution.get("height"))?;
197    let channels = if sub_unit_index == 4 {
198        color_channel_count_from_value(payload.get("center_color_channel"))?
199    } else {
200        color_channel_count_from_value(payload.get("peripheral_color_channels"))?
201    };
202    Some((width, height, channels))
203}
204
205fn resolve_sensory_dimensions_from_encoder_properties(
206    encoder_properties: Option<&Value>,
207    sub_unit_index: usize,
208    fallback: (usize, usize, usize),
209) -> (usize, usize, usize) {
210    let Some(encoder_properties) = encoder_properties else {
211        return fallback;
212    };
213    extract_cartesian_plane_dimensions(encoder_properties)
214        .or_else(|| extract_segmented_vision_dimensions(encoder_properties, sub_unit_index))
215        .unwrap_or(fallback)
216}
217
218pub async fn auto_create_cortical_areas_from_device_registrations(
219    state: &ApiState,
220    device_registrations: &serde_json::Value,
221) {
222    let config = match load_config(None, None) {
223        Ok(config) => config,
224        Err(e) => {
225            warn!(
226                "⚠️ [API] Failed to load FEAGI configuration for auto-create: {}",
227                e
228            );
229            return;
230        }
231    };
232
233    if !config.agent.auto_create_missing_cortical_areas {
234        return;
235    }
236
237    let connectome_service = state.connectome_service.as_ref();
238    let genome_service = state.genome_service.as_ref();
239
240    // Get root region ID so auto-created OPU/IPU areas appear in root (fixes power area disappearing in BV)
241    let root_region_id = connectome_service.get_root_region_id().await.ok().flatten();
242    let existing_segmented_vision_yz_by_subunit = connectome_service
243        .list_cortical_areas()
244        .await
245        .ok()
246        .and_then(|areas| {
247            let mut grouped_yz_by_group: HashMap<u8, HashMap<u8, (i32, i32)>> = HashMap::new();
248
249            for area in areas {
250                let Ok(bytes) = general_purpose::STANDARD.decode(&area.cortical_id) else {
251                    continue;
252                };
253                if bytes.len() != 8 || bytes[0] != b'i' || &bytes[1..4] != b"svi" {
254                    continue;
255                }
256                let subunit_index = bytes[6];
257                let group_index = bytes[7];
258                grouped_yz_by_group
259                    .entry(group_index)
260                    .or_default()
261                    .insert(subunit_index, (area.position.1, area.position.2));
262            }
263
264            // Deterministically pick one existing segmented-vision group as alignment anchor:
265            // prefer the group with most subunits; ties resolved by lower group index.
266            let selected_group = grouped_yz_by_group
267                .iter()
268                .max_by(|(group_a, map_a), (group_b, map_b)| {
269                    map_a
270                        .len()
271                        .cmp(&map_b.len())
272                        .then_with(|| group_b.cmp(group_a))
273                })
274                .map(|(group_index, _)| *group_index)?;
275            let selected_map = grouped_yz_by_group.remove(&selected_group)?;
276            if selected_map.len()
277                == SensoryCorticalUnit::SegmentedVision.get_number_cortical_areas()
278            {
279                Some(selected_map)
280            } else {
281                None
282            }
283        });
284
285    let output_units = device_registrations
286        .get("output_units_and_decoder_properties")
287        .and_then(|v| v.as_object());
288    let input_units = device_registrations
289        .get("input_units_and_encoder_properties")
290        .and_then(|v| v.as_object());
291    if output_units.is_none() && input_units.is_none() {
292        return;
293    }
294
295    // Build creation params for missing OPU areas based on default topologies.
296    let mut to_create: Vec<CreateCorticalAreaParams> = Vec::new();
297
298    if let Some(output_units) = output_units {
299        for (motor_unit_key, unit_defs) in output_units {
300            // MotorCorticalUnit is serde-deserializable from its string representation.
301            let motor_unit: MotorCorticalUnit = match serde_json::from_value::<MotorCorticalUnit>(
302                serde_json::Value::String(motor_unit_key.clone()),
303            ) {
304                Ok(v) => v,
305                Err(e) => {
306                    warn!(
307                    "⚠️ [API] Unable to parse MotorCorticalUnit key '{}' from device_registrations: {}",
308                    motor_unit_key, e
309                );
310                    continue;
311                }
312            };
313
314            let Some(unit_defs_arr) = unit_defs.as_array() else {
315                continue;
316            };
317
318            for entry in unit_defs_arr {
319                // Expected shape: [<unit_definition>, <decoder_properties>]
320                let Some(pair) = entry.as_array() else {
321                    continue;
322                };
323                let Some(unit_def) = pair.first() else {
324                    continue;
325                };
326                let Some(group_u64) = unit_def.get("cortical_unit_index").and_then(|v| v.as_u64())
327                else {
328                    continue;
329                };
330                let group_u8: u8 = match group_u64.try_into() {
331                    Ok(v) => v,
332                    Err(_) => continue,
333                };
334                let group: CorticalUnitIndex = group_u8.into();
335
336                let device_count = unit_def
337                    .get("device_grouping")
338                    .and_then(|v| v.as_array())
339                    .map(|a| a.len())
340                    .unwrap_or(0);
341                if device_count == 0 {
342                    warn!(
343                    "⚠️ [API] device_grouping is empty for motor unit '{}' group {}; skipping auto-create",
344                    motor_unit_key, group_u8
345                );
346                    continue;
347                }
348
349                let config_map = match build_io_config_map_from_unit_def(unit_def) {
350                    Ok(map) => map,
351                    Err(e) => {
352                        warn!(
353                            "⚠️ [API] Failed to build motor IO config map from registration for '{}' group {}: {}",
354                            motor_unit_key, group_u8, e
355                        );
356                        continue;
357                    }
358                };
359                let topology = motor_unit.get_unit_default_topology();
360
361                let cortical_ids = match motor_unit
362                    .get_cortical_id_vector_from_index_and_serde_io_configuration_flags(
363                        group, config_map,
364                    ) {
365                    Ok(ids) => ids,
366                    Err(e) => {
367                        warn!(
368                            "⚠️ [API] Failed to derive motor cortical IDs for '{}' group {}: {}",
369                            motor_unit_key, group_u8, e
370                        );
371                        continue;
372                    }
373                };
374
375                // Precompute dimensions and positions for all sub-areas in this
376                // motor unit/group. Keep a guaranteed X-gap between neighboring
377                // areas regardless of their computed width.
378                let mut expected_dimensions_by_sub: Vec<Option<(usize, usize, usize)>> =
379                    vec![None; cortical_ids.len()];
380                let mut expected_position_by_sub: Vec<Option<(i32, i32, i32)>> =
381                    vec![None; cortical_ids.len()];
382                let mut previous_position_x: Option<i32> = None;
383                let mut previous_width: Option<i32> = None;
384                for i in 0..cortical_ids.len() {
385                    let sub_index = CorticalSubUnitIndex::from(i as u8);
386                    let Some(unit_topology) = topology.get(&sub_index) else {
387                        continue;
388                    };
389                    let per_channel_width = unit_topology.channel_dimensions_default[0] as usize;
390                    let per_channel_height = unit_topology.channel_dimensions_default[1] as usize;
391                    let per_channel_depth = unit_topology.channel_dimensions_default[2] as usize;
392                    let expected_dimensions = (
393                        (per_channel_width * device_count).max(1),
394                        per_channel_height,
395                        per_channel_depth,
396                    );
397                    expected_dimensions_by_sub[i] = Some(expected_dimensions);
398
399                    let y = unit_topology.relative_position[1] + (group_u8 as i32 * 20);
400                    let z = unit_topology.relative_position[2];
401                    let width_i32 = expected_dimensions.0 as i32;
402                    let x = if let (Some(prev_x), Some(_prev_w)) =
403                        (previous_position_x, previous_width)
404                    {
405                        // Areas are anchored from their minimum X and extend to +X.
406                        // To keep a fixed empty gap when placing current area on the
407                        // left of previous area:
408                        // current_x + current_width + gap <= previous_x
409                        prev_x - width_i32 - MOTOR_AREA_X_GAP_VOXELS
410                    } else {
411                        unit_topology.relative_position[0]
412                    };
413                    expected_position_by_sub[i] = Some((x, y, z));
414                    previous_position_x = Some(x);
415                    previous_width = Some(width_i32);
416                }
417
418                for (i, cortical_id) in cortical_ids.iter().enumerate() {
419                    let cortical_id_b64 = cortical_id.as_base_64();
420                    let legacy_default_name =
421                        build_friendly_unit_name(motor_unit.get_friendly_name(), group_u8, i);
422                    let resolved_base_name =
423                        resolve_registration_name(unit_def, &legacy_default_name);
424                    let resolved_name =
425                        if resolved_base_name == legacy_default_name || cortical_ids.len() == 1 {
426                            resolved_base_name.clone()
427                        } else {
428                            format!("{}-{}", resolved_base_name, i)
429                        };
430                    let exists = match connectome_service
431                        .cortical_area_exists(&cortical_id_b64)
432                        .await
433                    {
434                        Ok(v) => v,
435                        Err(e) => {
436                            warn!(
437                                "⚠️ [API] Failed to check cortical area existence for '{}': {}",
438                                cortical_id_b64, e
439                            );
440                            continue;
441                        }
442                    };
443
444                    let sub_index = CorticalSubUnitIndex::from(i as u8);
445                    let unit_topology = match topology.get(&sub_index) {
446                        Some(t) => t,
447                        None => {
448                            warn!(
449                                "⚠️ [API] Missing unit topology for motor unit '{}' subunit {}; skipping",
450                                motor_unit_key, i
451                            );
452                            continue;
453                        }
454                    };
455                    let expected_position = match expected_position_by_sub.get(i).and_then(|v| *v) {
456                        Some(pos) => pos,
457                        None => {
458                            warn!(
459                                "⚠️ [API] Missing precomputed motor position for '{}' subunit {}; skipping",
460                                motor_unit_key, i
461                            );
462                            continue;
463                        }
464                    };
465                    let expected_dimensions = match expected_dimensions_by_sub
466                        .get(i)
467                        .and_then(|v| *v)
468                    {
469                        Some(dims) => dims,
470                        None => {
471                            warn!(
472                                "⚠️ [API] Missing precomputed motor dimensions for '{}' subunit {}; skipping",
473                                motor_unit_key, i
474                            );
475                            continue;
476                        }
477                    };
478
479                    if exists {
480                        // Area exists: reconcile structural properties from registrations.
481                        // Preserve user-defined layout by not mutating existing position.
482                        // If a genome was loaded with wrong dimensions (e.g. 1 channel per limb),
483                        // update to the correct channel count from device_grouping.
484                        let current =
485                            match connectome_service.get_cortical_area(&cortical_id_b64).await {
486                                Ok(v) => v,
487                                Err(e) => {
488                                    warn!(
489                                        "⚠️ [API] Failed to fetch existing cortical area '{}': {}",
490                                        cortical_id_b64, e
491                                    );
492                                    continue;
493                                }
494                            };
495
496                        let current_dev_count = current
497                            .properties
498                            .get("dev_count")
499                            .and_then(|v| v.as_u64())
500                            .map(|u| u as usize)
501                            .or(current.dev_count);
502                        let dimensions_mismatch = current.dimensions != expected_dimensions;
503                        let dev_count_mismatch = current_dev_count != Some(device_count);
504
505                        if dimensions_mismatch || dev_count_mismatch {
506                            let mut changes: HashMap<String, serde_json::Value> = HashMap::new();
507                            // Pass total dimensions. Do NOT pass cortical_dimensions_per_device here:
508                            // genome service would treat it as per-device and multiply depth by dev_count.
509                            changes.insert(
510                                "dimensions".to_string(),
511                                serde_json::json!([
512                                    expected_dimensions.0,
513                                    expected_dimensions.1,
514                                    expected_dimensions.2
515                                ]),
516                            );
517                            changes.insert(
518                                "dev_count".to_string(),
519                                serde_json::Value::Number(serde_json::Number::from(device_count)),
520                            );
521                            if let Err(e) = genome_service
522                                .update_cortical_area(&cortical_id_b64, changes)
523                                .await
524                            {
525                                warn!(
526                                    "⚠️ [API] Failed to update cortical area '{}' dimensions/dev_count: {}",
527                                    cortical_id_b64, e
528                                );
529                            } else {
530                                info!(
531                                    "[API] Updated cortical area '{}' to {} channels (dimensions {:?})",
532                                    cortical_id_b64, device_count, expected_dimensions
533                                );
534                            }
535                        }
536
537                        // Auto-rename if current name is placeholder (== cortical_id).
538                        if should_auto_rename(&current.name, &cortical_id_b64, &legacy_default_name)
539                        {
540                            let desired_name = resolved_name.clone();
541                            let mut changes: HashMap<String, serde_json::Value> = HashMap::new();
542                            changes.insert(
543                                "name".to_string(),
544                                serde_json::Value::String(desired_name),
545                            );
546                            if let Err(e) = genome_service
547                                .update_cortical_area(&cortical_id_b64, changes)
548                                .await
549                            {
550                                warn!(
551                                    "⚠️ [API] Failed to auto-rename existing motor cortical area '{}': {}",
552                                    cortical_id_b64, e
553                                );
554                            }
555                        }
556                        continue;
557                    }
558
559                    let friendly_name = resolved_name;
560                    // Use template-defined per-channel topology and scale by device_count.
561                    // This keeps sizing fully template-driven and consistent across all unit types.
562                    let (per_channel_width, per_channel_height, per_channel_depth) = (
563                        unit_topology.channel_dimensions_default[0] as usize,
564                        unit_topology.channel_dimensions_default[1] as usize,
565                        unit_topology.channel_dimensions_default[2] as usize,
566                    );
567                    let dimensions = expected_dimensions;
568                    let per_device_dims =
569                        (per_channel_width, per_channel_height, per_channel_depth);
570                    let position = expected_position;
571
572                    let mut properties = HashMap::new();
573                    properties.insert(
574                        "dev_count".to_string(),
575                        serde_json::Value::Number(serde_json::Number::from(device_count)),
576                    );
577                    properties.insert(
578                        "cortical_dimensions_per_device".to_string(),
579                        serde_json::json!([
580                            per_device_dims.0,
581                            per_device_dims.1,
582                            per_device_dims.2
583                        ]),
584                    );
585                    if let Some(unit_name) = non_empty_string(unit_def.get("friendly_name")) {
586                        properties.insert(
587                            "registration_unit_friendly_name".to_string(),
588                            serde_json::Value::String(unit_name),
589                        );
590                    }
591                    if let Some(bundle_id) = first_grouping_property(unit_def, "bundle_id") {
592                        properties.insert(
593                            "registration_bundle_id".to_string(),
594                            serde_json::Value::String(bundle_id),
595                        );
596                    }
597                    if let Some(bundle_type) = first_grouping_property(unit_def, "bundle_type") {
598                        properties.insert(
599                            "registration_bundle_type".to_string(),
600                            serde_json::Value::String(bundle_type),
601                        );
602                    }
603                    if let Some(ref rid) = root_region_id {
604                        properties.insert(
605                            "parent_region_id".to_string(),
606                            serde_json::Value::String(rid.clone()),
607                        );
608                    }
609
610                    to_create.push(CreateCorticalAreaParams {
611                        cortical_id: cortical_id_b64.clone(),
612                        name: friendly_name,
613                        dimensions,
614                        position,
615                        area_type: "motor".to_string(),
616                        visible: None,
617                        sub_group: None,
618                        neurons_per_voxel: None,
619                        postsynaptic_current: None,
620                        plasticity_constant: None,
621                        degeneration: None,
622                        psp_uniform_distribution: None,
623                        firing_threshold_increment: None,
624                        firing_threshold_limit: None,
625                        consecutive_fire_count: None,
626                        snooze_period: None,
627                        refractory_period: None,
628                        leak_coefficient: None,
629                        leak_variability: None,
630                        burst_engine_active: None,
631                        properties: Some(properties),
632                    });
633                }
634            }
635        }
636    }
637
638    // Build creation params for missing IPU areas based on default topologies.
639    if let Some(input_units) = input_units {
640        for (sensory_unit_key, unit_defs) in input_units {
641            let sensory_unit: SensoryCorticalUnit = match serde_json::from_value::<
642                SensoryCorticalUnit,
643            >(serde_json::Value::String(
644                sensory_unit_key.clone(),
645            )) {
646                Ok(v) => v,
647                Err(e) => {
648                    warn!(
649                            "⚠️ [API] Unable to parse SensoryCorticalUnit key '{}' from device_registrations: {}",
650                            sensory_unit_key, e
651                        );
652                    continue;
653                }
654            };
655
656            let Some(unit_defs_arr) = unit_defs.as_array() else {
657                continue;
658            };
659
660            for entry in unit_defs_arr {
661                // Expected shape: [<unit_definition>, <encoder_properties>]
662                let Some(pair) = entry.as_array() else {
663                    continue;
664                };
665                let Some(unit_def) = pair.first() else {
666                    continue;
667                };
668                let encoder_properties = pair.get(1);
669                let Some(group_u64) = unit_def.get("cortical_unit_index").and_then(|v| v.as_u64())
670                else {
671                    continue;
672                };
673                let group_u8: u8 = match group_u64.try_into() {
674                    Ok(v) => v,
675                    Err(_) => continue,
676                };
677                let group: CorticalUnitIndex = group_u8.into();
678
679                let device_count = unit_def
680                    .get("device_grouping")
681                    .and_then(|v| v.as_array())
682                    .map(|a| a.len())
683                    .unwrap_or(0);
684                if device_count == 0 {
685                    warn!(
686                        "⚠️ [API] device_grouping is empty for sensory unit '{}' group {}; skipping auto-create",
687                        sensory_unit_key, group_u8
688                    );
689                    continue;
690                }
691
692                let config_map = match build_io_config_map() {
693                    Ok(map) => map,
694                    Err(e) => {
695                        warn!(
696                            "⚠️ [API] Failed to build sensory IO config map for '{}' group {}: {}",
697                            sensory_unit_key, group_u8, e
698                        );
699                        continue;
700                    }
701                };
702                let cortical_ids = match sensory_unit
703                    .get_cortical_id_vector_from_index_and_serde_io_configuration_flags(
704                        group, config_map,
705                    ) {
706                    Ok(ids) => ids,
707                    Err(e) => {
708                        warn!(
709                            "⚠️ [API] Failed to derive sensory cortical IDs for '{}' group {}: {}",
710                            sensory_unit_key, group_u8, e
711                        );
712                        continue;
713                    }
714                };
715                let topology = sensory_unit.get_unit_default_topology();
716                let segmented_group_x_offsets =
717                    if sensory_unit == SensoryCorticalUnit::SegmentedVision {
718                        // For each segmented-vision group, compute the assembly min/max X bounds based on
719                        // template relative positions and effective per-subunit dimensions.
720                        let mut bounds_by_group: Vec<(u8, i32, i32)> = Vec::new();
721                        for grouped_entry in unit_defs_arr {
722                            let Some(grouped_pair) = grouped_entry.as_array() else {
723                                continue;
724                            };
725                            let Some(grouped_def) = grouped_pair.first() else {
726                                continue;
727                            };
728                            let Some(grouped_u64) = grouped_def
729                                .get("cortical_unit_index")
730                                .and_then(|v| v.as_u64())
731                            else {
732                                continue;
733                            };
734                            let Ok(grouped_u8) = u8::try_from(grouped_u64) else {
735                                continue;
736                            };
737                            let grouped_encoder_properties = grouped_pair.get(1);
738
739                            let mut assembly_min_x: Option<i32> = None;
740                            let mut assembly_max_x: Option<i32> = None;
741                            for (sub_index, unit_topology) in &topology {
742                                let sub_idx_usize = sub_index.get() as usize;
743                                let dimensions = resolve_sensory_dimensions_from_encoder_properties(
744                                    grouped_encoder_properties,
745                                    sub_idx_usize,
746                                    (
747                                        unit_topology.channel_dimensions_default[0] as usize,
748                                        unit_topology.channel_dimensions_default[1] as usize,
749                                        unit_topology.channel_dimensions_default[2] as usize,
750                                    ),
751                                );
752                                let rel_x = unit_topology.relative_position[0];
753                                let right_edge_x = rel_x.saturating_add(dimensions.0 as i32);
754
755                                assembly_min_x = Some(match assembly_min_x {
756                                    Some(current) => current.min(rel_x),
757                                    None => rel_x,
758                                });
759                                assembly_max_x = Some(match assembly_max_x {
760                                    Some(current) => current.max(right_edge_x),
761                                    None => right_edge_x,
762                                });
763                            }
764
765                            if let (Some(min_x), Some(max_x)) = (assembly_min_x, assembly_max_x) {
766                                bounds_by_group.push((grouped_u8, min_x, max_x));
767                            }
768                        }
769
770                        // Sort by cortical unit index so lower-index segmented assemblies stay left and
771                        // higher-index assemblies are shifted to the right with a fixed gap.
772                        bounds_by_group.sort_by_key(|(grouped_u8, _, _)| *grouped_u8);
773
774                        let mut offsets: HashMap<u8, i32> = HashMap::new();
775                        let mut previous_shifted_max_x: Option<i32> = None;
776                        for (grouped_u8, min_x, max_x) in bounds_by_group {
777                            let offset_x = if let Some(prev_max_x) = previous_shifted_max_x {
778                                prev_max_x + SEGMENTED_VISION_GROUP_X_GAP_VOXELS - min_x
779                            } else {
780                                0
781                            };
782                            previous_shifted_max_x = Some(max_x.saturating_add(offset_x));
783                            offsets.insert(grouped_u8, offset_x);
784                        }
785                        offsets
786                    } else {
787                        HashMap::new()
788                    };
789
790                for (i, cortical_id) in cortical_ids.iter().enumerate() {
791                    let cortical_id_b64 = cortical_id.as_base_64();
792                    let sub_index = CorticalSubUnitIndex::from(i as u8);
793                    let unit_topology = match topology.get(&sub_index) {
794                        Some(topology) => topology,
795                        None => {
796                            warn!(
797                                "⚠️ [API] Missing unit topology for sensory unit '{}' subunit {} (agent device_registrations); cannot auto-create/update '{}'",
798                                sensory_unit_key, i, cortical_id_b64
799                            );
800                            continue;
801                        }
802                    };
803                    let expected_dimensions = resolve_sensory_dimensions_from_encoder_properties(
804                        encoder_properties,
805                        i,
806                        (
807                            unit_topology.channel_dimensions_default[0] as usize,
808                            unit_topology.channel_dimensions_default[1] as usize,
809                            unit_topology.channel_dimensions_default[2] as usize,
810                        ),
811                    );
812                    let group_x_offset = *segmented_group_x_offsets.get(&group_u8).unwrap_or(&0);
813                    let existing_segmented_yz =
814                        if sensory_unit == SensoryCorticalUnit::SegmentedVision {
815                            existing_segmented_vision_yz_by_subunit
816                                .as_ref()
817                                .and_then(|yz_by_subunit| yz_by_subunit.get(&(i as u8)).copied())
818                        } else {
819                            None
820                        };
821                    let expected_position = (
822                        unit_topology.relative_position[0] + group_x_offset,
823                        existing_segmented_yz
824                            .map(|(y, _)| y)
825                            .unwrap_or(unit_topology.relative_position[1]),
826                        existing_segmented_yz
827                            .map(|(_, z)| z)
828                            .unwrap_or(unit_topology.relative_position[2]),
829                    );
830                    let legacy_default_name =
831                        build_friendly_unit_name(sensory_unit.get_friendly_name(), group_u8, i);
832                    let resolved_base_name =
833                        resolve_registration_name(unit_def, &legacy_default_name);
834                    let resolved_name =
835                        if resolved_base_name == legacy_default_name || cortical_ids.len() == 1 {
836                            resolved_base_name.clone()
837                        } else {
838                            format!("{}-{}", resolved_base_name, i)
839                        };
840                    let exists = match connectome_service
841                        .cortical_area_exists(&cortical_id_b64)
842                        .await
843                    {
844                        Ok(v) => v,
845                        Err(e) => {
846                            warn!(
847                                "⚠️ [API] Failed to check cortical area existence for '{}': {}",
848                                cortical_id_b64, e
849                            );
850                            continue;
851                        }
852                    };
853
854                    if exists {
855                        // Area exists: reconcile structural properties from registrations.
856                        // Preserve user-defined layout by not mutating existing position.
857                        // This keeps pre-existing sensory areas aligned with declared capabilities.
858                        let current = match connectome_service
859                            .get_cortical_area(&cortical_id_b64)
860                            .await
861                        {
862                            Ok(v) => v,
863                            Err(e) => {
864                                warn!(
865                                    "⚠️ [API] Failed to fetch existing cortical area '{}' for potential rename: {}",
866                                    cortical_id_b64, e
867                                );
868                                continue;
869                            }
870                        };
871                        let current_dev_count = current
872                            .properties
873                            .get("dev_count")
874                            .and_then(|v| v.as_u64())
875                            .map(|u| u as usize)
876                            .or(current.dev_count);
877                        let dimensions_mismatch = current.dimensions != expected_dimensions;
878                        let dev_count_mismatch = current_dev_count != Some(device_count);
879                        if dimensions_mismatch || dev_count_mismatch {
880                            let mut changes: HashMap<String, serde_json::Value> = HashMap::new();
881                            changes.insert(
882                                "dimensions".to_string(),
883                                serde_json::json!([
884                                    expected_dimensions.0,
885                                    expected_dimensions.1,
886                                    expected_dimensions.2
887                                ]),
888                            );
889                            changes.insert(
890                                "dev_count".to_string(),
891                                serde_json::Value::Number(serde_json::Number::from(device_count)),
892                            );
893                            if let Err(e) = genome_service
894                                .update_cortical_area(&cortical_id_b64, changes)
895                                .await
896                            {
897                                warn!(
898                                    "⚠️ [API] Failed to update sensory cortical area '{}' dimensions/dev_count: {}",
899                                    cortical_id_b64, e
900                                );
901                            } else {
902                                info!(
903                                    "[API] Updated sensory cortical area '{}' to registration dimensions {:?} (dev_count {})",
904                                    cortical_id_b64, expected_dimensions, device_count
905                                );
906                            }
907                        }
908
909                        // If the area already exists but still has a placeholder name (often equal to the cortical_id),
910                        // update it to a deterministic friendly name so UIs (e.g., Brain Visualizer) show readable labels.
911                        // IMPORTANT: We only auto-rename if the current name is clearly a placeholder.
912                        if should_auto_rename(&current.name, &cortical_id_b64, &legacy_default_name)
913                        {
914                            let desired_name = resolved_name.clone();
915                            let mut changes: HashMap<String, serde_json::Value> = HashMap::new();
916                            changes.insert(
917                                "name".to_string(),
918                                serde_json::Value::String(desired_name),
919                            );
920                            if let Err(e) = genome_service
921                                .update_cortical_area(&cortical_id_b64, changes)
922                                .await
923                            {
924                                warn!(
925                                    "⚠️ [API] Failed to auto-rename existing sensory cortical area '{}': {}",
926                                    cortical_id_b64, e
927                                );
928                            }
929                        }
930                        continue;
931                    }
932
933                    let friendly_name = resolved_name;
934                    let dimensions = expected_dimensions;
935                    let position = expected_position;
936                    let mut properties: HashMap<String, serde_json::Value> = HashMap::new();
937                    properties.insert(
938                        "cortical_subunit_index".to_string(),
939                        serde_json::Value::Number(serde_json::Number::from(sub_index.get())),
940                    );
941                    properties.insert(
942                        "dev_count".to_string(),
943                        serde_json::Value::Number(serde_json::Number::from(device_count)),
944                    );
945                    if let Some(unit_name) = non_empty_string(unit_def.get("friendly_name")) {
946                        properties.insert(
947                            "registration_unit_friendly_name".to_string(),
948                            serde_json::Value::String(unit_name),
949                        );
950                    }
951                    if let Some(bundle_id) = first_grouping_property(unit_def, "bundle_id") {
952                        properties.insert(
953                            "registration_bundle_id".to_string(),
954                            serde_json::Value::String(bundle_id),
955                        );
956                    }
957                    if let Some(bundle_type) = first_grouping_property(unit_def, "bundle_type") {
958                        properties.insert(
959                            "registration_bundle_type".to_string(),
960                            serde_json::Value::String(bundle_type),
961                        );
962                    }
963                    if let Some(ref rid) = root_region_id {
964                        properties.insert(
965                            "parent_region_id".to_string(),
966                            serde_json::Value::String(rid.clone()),
967                        );
968                    }
969                    if let Some(default_firing_threshold) =
970                        sensory_unit.get_default_firing_threshold()
971                    {
972                        properties.insert(
973                            "firing_threshold".to_string(),
974                            serde_json::json!(default_firing_threshold),
975                        );
976                    }
977                    if let Some(default_mp_charge_accumulation) =
978                        sensory_unit.get_default_mp_charge_accumulation()
979                    {
980                        properties.insert(
981                            "mp_charge_accumulation".to_string(),
982                            serde_json::json!(default_mp_charge_accumulation),
983                        );
984                    }
985
986                    to_create.push(CreateCorticalAreaParams {
987                        cortical_id: cortical_id_b64.clone(),
988                        name: friendly_name,
989                        dimensions,
990                        position,
991                        area_type: "sensory".to_string(),
992                        visible: None,
993                        sub_group: None,
994                        neurons_per_voxel: None,
995                        postsynaptic_current: None,
996                        plasticity_constant: None,
997                        degeneration: None,
998                        psp_uniform_distribution: None,
999                        firing_threshold_increment: None,
1000                        firing_threshold_limit: None,
1001                        consecutive_fire_count: None,
1002                        snooze_period: None,
1003                        refractory_period: None,
1004                        leak_coefficient: None,
1005                        leak_variability: None,
1006                        burst_engine_active: None,
1007                        properties: Some(properties),
1008                    });
1009                }
1010            }
1011        }
1012    }
1013
1014    if to_create.is_empty() {
1015        return;
1016    }
1017
1018    info!(
1019        "🦀 [API] Auto-creating {} missing cortical areas from device registrations",
1020        to_create.len()
1021    );
1022
1023    if let Err(e) = genome_service.create_cortical_areas(to_create).await {
1024        warn!(
1025            "⚠️ [API] Failed to auto-create cortical areas from device registrations: {}",
1026            e
1027        );
1028    }
1029}
1030
1031pub fn derive_motor_cortical_ids_from_device_registrations(
1032    device_registrations: &serde_json::Value,
1033) -> Result<HashSet<String>, String> {
1034    let output_units = device_registrations
1035        .get("output_units_and_decoder_properties")
1036        .and_then(|v| v.as_object())
1037        .ok_or_else(|| {
1038            "device_registrations missing output_units_and_decoder_properties".to_string()
1039        })?;
1040
1041    let mut cortical_ids: HashSet<String> = HashSet::new();
1042
1043    for (motor_unit_key, unit_defs) in output_units {
1044        let motor_unit: MotorCorticalUnit = serde_json::from_value::<MotorCorticalUnit>(
1045            serde_json::Value::String(motor_unit_key.clone()),
1046        )
1047        .map_err(|e| {
1048            format!(
1049                "Unable to parse MotorCorticalUnit key '{}': {}",
1050                motor_unit_key, e
1051            )
1052        })?;
1053
1054        let unit_defs_arr = unit_defs
1055            .as_array()
1056            .ok_or_else(|| "Motor unit definitions must be an array".to_string())?;
1057
1058        for entry in unit_defs_arr {
1059            let pair = entry
1060                .as_array()
1061                .ok_or_else(|| "Motor unit definition entries must be arrays".to_string())?;
1062            let unit_def = pair
1063                .first()
1064                .ok_or_else(|| "Motor unit definition entry missing unit_def".to_string())?;
1065            let group_u64 = unit_def
1066                .get("cortical_unit_index")
1067                .and_then(|v| v.as_u64())
1068                .ok_or_else(|| "Motor unit definition missing cortical_unit_index".to_string())?;
1069            let group_u8: u8 = group_u64
1070                .try_into()
1071                .map_err(|_| "Motor unit cortical_unit_index out of range for u8".to_string())?;
1072            let group: CorticalUnitIndex = group_u8.into();
1073
1074            let device_count = unit_def
1075                .get("device_grouping")
1076                .and_then(|v| v.as_array())
1077                .map(|a| a.len())
1078                .unwrap_or(0);
1079            if device_count == 0 {
1080                return Err(format!(
1081                    "device_grouping is empty for motor unit '{}' group {}",
1082                    motor_unit_key, group_u8
1083                ));
1084            }
1085
1086            let config = build_io_config_map_from_unit_def(unit_def).map_err(|e| {
1087                format!(
1088                    "Failed to build motor IO config map from registration for '{}' group {}: {}",
1089                    motor_unit_key, group_u8, e
1090                )
1091            })?;
1092            let unit_cortical_ids = motor_unit
1093                .get_cortical_id_vector_from_index_and_serde_io_configuration_flags(group, config)
1094                .map_err(|e| format!("Failed to derive cortical IDs: {}", e))?;
1095            for cortical_id in unit_cortical_ids {
1096                cortical_ids.insert(cortical_id.as_base_64());
1097            }
1098        }
1099    }
1100
1101    Ok(cortical_ids)
1102}
1103
1104pub fn derive_sensory_cortical_ids_from_device_registrations(
1105    device_registrations: &serde_json::Value,
1106) -> Result<HashSet<String>, String> {
1107    let input_units = device_registrations
1108        .get("input_units_and_encoder_properties")
1109        .and_then(|v| v.as_object())
1110        .ok_or_else(|| {
1111            "device_registrations missing input_units_and_encoder_properties".to_string()
1112        })?;
1113
1114    let mut cortical_ids: HashSet<String> = HashSet::new();
1115
1116    for (sensory_unit_key, unit_defs) in input_units {
1117        let sensory_unit: SensoryCorticalUnit = serde_json::from_value::<SensoryCorticalUnit>(
1118            serde_json::Value::String(sensory_unit_key.clone()),
1119        )
1120        .map_err(|e| {
1121            format!(
1122                "Unable to parse SensoryCorticalUnit key '{}': {}",
1123                sensory_unit_key, e
1124            )
1125        })?;
1126
1127        let unit_defs_arr = unit_defs
1128            .as_array()
1129            .ok_or_else(|| "Sensory unit definitions must be an array".to_string())?;
1130
1131        for entry in unit_defs_arr {
1132            let pair = entry
1133                .as_array()
1134                .ok_or_else(|| "Sensory unit definition entries must be arrays".to_string())?;
1135            let unit_def = pair
1136                .first()
1137                .ok_or_else(|| "Sensory unit definition entry missing unit_def".to_string())?;
1138            let group_u64 = unit_def
1139                .get("cortical_unit_index")
1140                .and_then(|v| v.as_u64())
1141                .ok_or_else(|| "Sensory unit definition missing cortical_unit_index".to_string())?;
1142            let group_u8: u8 = group_u64
1143                .try_into()
1144                .map_err(|_| "Sensory unit cortical_unit_index out of range for u8".to_string())?;
1145            let group: CorticalUnitIndex = group_u8.into();
1146
1147            let device_count = unit_def
1148                .get("device_grouping")
1149                .and_then(|v| v.as_array())
1150                .map(|a| a.len())
1151                .unwrap_or(0);
1152            if device_count == 0 {
1153                return Err(format!(
1154                    "device_grouping is empty for sensory unit '{}' group {}",
1155                    sensory_unit_key, group_u8
1156                ));
1157            }
1158
1159            let config = build_io_config_map()
1160                .map_err(|e| format!("Failed to build sensory IO config map: {}", e))?;
1161            let unit_cortical_ids = sensory_unit
1162                .get_cortical_id_vector_from_index_and_serde_io_configuration_flags(group, config)
1163                .map_err(|e| format!("Failed to derive cortical IDs: {}", e))?;
1164            for cortical_id in unit_cortical_ids {
1165                cortical_ids.insert(cortical_id.as_base_64());
1166            }
1167        }
1168    }
1169
1170    Ok(cortical_ids)
1171}