Skip to main content

nusy_arrow_core/
cognitive_params.rs

1//! Cognitive parameter store for the `self` namespace.
2//!
3//! Stores a being's tunable cognitive parameters as an Arrow RecordBatch.
4//! Parameters include signal fusion weights, learning rate configs, schema
5//! match thresholds, and consolidation triggers.
6//!
7//! # V15 Self-Evolution
8//!
9//! This is the foundation for HDD self-modification:
10//! - Being reads its own parameters via `get()` / `list()`
11//! - HDD loop modifies parameters via `set()`
12//! - Changes are committed via graph-native git (`snapshot()` → Parquet)
13//! - Reverts are atomic via `checkout(previous)`
14//!
15//! # Autonomy Tiers
16//!
17//! Each parameter has an autonomy tier controlling who can modify it:
18//! - **Tier 1 (auto):** HDD loop can adjust freely (signal weights, learning rates)
19//! - **Tier 2 (being-approved):** Being must confirm before applying (pipeline composition)
20//! - **Tier 3 (captain-only):** Requires Captain approval (safety rules, core thresholds)
21
22use std::sync::Arc;
23
24use arrow::array::{Array, Float64Array, RecordBatch, StringArray, UInt8Array};
25use arrow::datatypes::{DataType, Field, Schema};
26
27// ── Schema ─────────────────────────────────────────────────────────────
28
29/// Schema version for the cognitive parameters table.
30pub const COGNITIVE_PARAMS_SCHEMA_VERSION: &str = "1.0.0";
31
32/// Named column indices for the cognitive parameters table.
33pub mod param_col {
34    pub const PARAM_ID: usize = 0;
35    pub const CATEGORY: usize = 1;
36    pub const VALUE_F64: usize = 2;
37    pub const VALUE_STR: usize = 3;
38    pub const MIN_BOUND: usize = 4;
39    pub const MAX_BOUND: usize = 5;
40    pub const AUTONOMY_TIER: usize = 6;
41    pub const MODIFIED_BY: usize = 7;
42}
43
44/// Parameter categories.
45pub mod category {
46    pub const SIGNAL_WEIGHT: &str = "signal_weight";
47    pub const THRESHOLD: &str = "threshold";
48    pub const LEARNING_CONFIG: &str = "learning_config";
49    pub const TRIGGER: &str = "trigger";
50    pub const LOSS_COEFFICIENT: &str = "loss_coefficient";
51    pub const CONSOLIDATION_TRIGGER: &str = "consolidation_trigger";
52}
53
54/// Autonomy tier levels.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
56#[repr(u8)]
57pub enum AutonomyTier {
58    /// HDD loop can adjust freely.
59    Auto = 1,
60    /// Being must confirm before applying.
61    BeingApproved = 2,
62    /// Requires Captain approval.
63    CaptainOnly = 3,
64}
65
66impl AutonomyTier {
67    pub fn from_u8(v: u8) -> Option<Self> {
68        match v {
69            1 => Some(Self::Auto),
70            2 => Some(Self::BeingApproved),
71            3 => Some(Self::CaptainOnly),
72            _ => None,
73        }
74    }
75}
76
77/// Arrow schema for the cognitive parameters table.
78///
79/// 8 columns. Lives in the `self` namespace.
80pub fn cognitive_params_schema() -> Schema {
81    Schema::new(vec![
82        Field::new("param_id", DataType::Utf8, false),
83        Field::new("category", DataType::Utf8, false),
84        Field::new("value_f64", DataType::Float64, true),
85        Field::new("value_str", DataType::Utf8, true),
86        Field::new("min_bound", DataType::Float64, true),
87        Field::new("max_bound", DataType::Float64, true),
88        Field::new("autonomy_tier", DataType::UInt8, false),
89        Field::new("modified_by", DataType::Utf8, false),
90    ])
91}
92
93// ── Typed parameter view ───────────────────────────────────────────────
94
95/// A single cognitive parameter, extracted from the RecordBatch.
96#[derive(Debug, Clone, PartialEq)]
97pub struct CognitiveParameter {
98    pub param_id: String,
99    pub category: String,
100    pub value_f64: Option<f64>,
101    pub value_str: Option<String>,
102    pub min_bound: Option<f64>,
103    pub max_bound: Option<f64>,
104    pub autonomy_tier: AutonomyTier,
105    pub modified_by: String,
106}
107
108// ── Error type ─────────────────────────────────────────────────────────
109
110#[derive(Debug, thiserror::Error)]
111pub enum ParamStoreError {
112    #[error("parameter not found: {0}")]
113    NotFound(String),
114
115    #[error("value {value} out of bounds [{min}, {max}] for parameter {param_id}")]
116    OutOfBounds {
117        param_id: String,
118        value: f64,
119        min: f64,
120        max: f64,
121    },
122
123    #[error(
124        "autonomy tier violation: parameter {param_id} requires tier {required:?}, caller is {caller:?}"
125    )]
126    TierViolation {
127        param_id: String,
128        required: AutonomyTier,
129        caller: AutonomyTier,
130    },
131
132    #[error("arrow error: {0}")]
133    Arrow(#[from] arrow::error::ArrowError),
134
135    #[error("parquet error: {0}")]
136    Parquet(#[from] parquet::errors::ParquetError),
137
138    #[error("invalid schema: {0}")]
139    InvalidSchema(String),
140}
141
142// ── Store ──────────────────────────────────────────────────────────────
143
144/// Arrow-backed store for cognitive parameters.
145///
146/// Wraps a RecordBatch with the `cognitive_params_schema()`. All operations
147/// rebuild the batch (Arrow RecordBatches are immutable); this is cheap for
148/// the ~50-100 parameters a being has.
149#[derive(Debug, Clone)]
150pub struct CognitiveParameterStore {
151    batch: RecordBatch,
152}
153
154impl CognitiveParameterStore {
155    /// Create an empty store.
156    pub fn new() -> Self {
157        let schema = Arc::new(cognitive_params_schema());
158        let batch = RecordBatch::new_empty(schema);
159        Self { batch }
160    }
161
162    /// Create from an existing Arrow RecordBatch.
163    ///
164    /// Validates that the batch has the correct schema.
165    pub fn from_batch(batch: RecordBatch) -> Result<Self, ParamStoreError> {
166        let expected = cognitive_params_schema();
167        if batch.schema().fields().len() != expected.fields().len() {
168            return Err(ParamStoreError::InvalidSchema(format!(
169                "expected {} columns, got {}",
170                expected.fields().len(),
171                batch.schema().fields().len()
172            )));
173        }
174        let actual_schema = batch.schema();
175        for (i, field) in expected.fields().iter().enumerate() {
176            let actual = actual_schema.field(i);
177            if actual.data_type() != field.data_type() {
178                return Err(ParamStoreError::InvalidSchema(format!(
179                    "column {}: expected {:?}, got {:?}",
180                    i,
181                    field.data_type(),
182                    actual.data_type()
183                )));
184            }
185        }
186        Ok(Self { batch })
187    }
188
189    /// Number of parameters in the store.
190    pub fn len(&self) -> usize {
191        self.batch.num_rows()
192    }
193
194    /// Whether the store is empty.
195    pub fn is_empty(&self) -> bool {
196        self.batch.num_rows() == 0
197    }
198
199    /// Get a parameter by ID.
200    pub fn get(&self, param_id: &str) -> Option<CognitiveParameter> {
201        let ids = self.param_id_col();
202        for i in 0..self.batch.num_rows() {
203            if ids.value(i) == param_id {
204                return Some(self.row_to_param(i));
205            }
206        }
207        None
208    }
209
210    /// List all parameters in a category.
211    pub fn list(&self, cat: &str) -> Vec<CognitiveParameter> {
212        let categories = self.category_col();
213        let mut result = Vec::new();
214        for i in 0..self.batch.num_rows() {
215            if categories.value(i) == cat {
216                result.push(self.row_to_param(i));
217            }
218        }
219        result
220    }
221
222    /// List all parameters.
223    pub fn list_all(&self) -> Vec<CognitiveParameter> {
224        (0..self.batch.num_rows())
225            .map(|i| self.row_to_param(i))
226            .collect()
227    }
228
229    /// Set a numeric parameter value with bounds checking and tier enforcement.
230    ///
231    /// `caller_tier` is the autonomy tier of the entity making the change.
232    /// The caller's tier must be >= the parameter's required tier.
233    pub fn set(
234        &mut self,
235        param_id: &str,
236        value: f64,
237        modified_by: &str,
238        caller_tier: AutonomyTier,
239    ) -> Result<(), ParamStoreError> {
240        let idx = self.find_index(param_id)?;
241        let param = self.row_to_param(idx);
242
243        // Tier enforcement
244        if caller_tier < param.autonomy_tier {
245            return Err(ParamStoreError::TierViolation {
246                param_id: param_id.to_string(),
247                required: param.autonomy_tier,
248                caller: caller_tier,
249            });
250        }
251
252        // Bounds checking
253        if let (Some(min), Some(max)) = (param.min_bound, param.max_bound)
254            && (value < min || value > max)
255        {
256            return Err(ParamStoreError::OutOfBounds {
257                param_id: param_id.to_string(),
258                value,
259                min,
260                max,
261            });
262        }
263
264        // Rebuild the batch with the updated value
265        self.batch = self.rebuild_with_f64_update(idx, value, modified_by)?;
266        Ok(())
267    }
268
269    /// Insert a new parameter. Replaces if param_id already exists.
270    pub fn insert(&mut self, param: &CognitiveParameter) -> Result<(), ParamStoreError> {
271        // Remove existing if present
272        let existing_idx = {
273            let ids = self.param_id_col();
274            (0..self.batch.num_rows()).find(|&i| ids.value(i) == param.param_id)
275        };
276
277        let batch = if let Some(idx) = existing_idx {
278            self.remove_row(idx)?
279        } else {
280            self.batch.clone()
281        };
282
283        // Append the new row
284        self.batch = append_row(&batch, param)?;
285        Ok(())
286    }
287
288    /// Get the underlying RecordBatch for persistence / graph-native git.
289    pub fn snapshot(&self) -> &RecordBatch {
290        &self.batch
291    }
292
293    /// Consume the store and return the RecordBatch.
294    pub fn into_batch(self) -> RecordBatch {
295        self.batch
296    }
297
298    // ── Internal helpers ───────────────────────────────────────────────
299
300    fn param_id_col(&self) -> &StringArray {
301        self.batch
302            .column(param_col::PARAM_ID)
303            .as_any()
304            .downcast_ref::<StringArray>()
305            .expect("param_id column is StringArray")
306    }
307
308    fn category_col(&self) -> &StringArray {
309        self.batch
310            .column(param_col::CATEGORY)
311            .as_any()
312            .downcast_ref::<StringArray>()
313            .expect("category column is StringArray")
314    }
315
316    fn find_index(&self, param_id: &str) -> Result<usize, ParamStoreError> {
317        let ids = self.param_id_col();
318        for i in 0..self.batch.num_rows() {
319            if ids.value(i) == param_id {
320                return Ok(i);
321            }
322        }
323        Err(ParamStoreError::NotFound(param_id.to_string()))
324    }
325
326    fn row_to_param(&self, idx: usize) -> CognitiveParameter {
327        let ids = self.param_id_col();
328        let categories = self.category_col();
329        let values_f64 = self
330            .batch
331            .column(param_col::VALUE_F64)
332            .as_any()
333            .downcast_ref::<Float64Array>()
334            .expect("value_f64 column");
335        let values_str = self
336            .batch
337            .column(param_col::VALUE_STR)
338            .as_any()
339            .downcast_ref::<StringArray>()
340            .expect("value_str column");
341        let min_bounds = self
342            .batch
343            .column(param_col::MIN_BOUND)
344            .as_any()
345            .downcast_ref::<Float64Array>()
346            .expect("min_bound column");
347        let max_bounds = self
348            .batch
349            .column(param_col::MAX_BOUND)
350            .as_any()
351            .downcast_ref::<Float64Array>()
352            .expect("max_bound column");
353        let tiers = self
354            .batch
355            .column(param_col::AUTONOMY_TIER)
356            .as_any()
357            .downcast_ref::<UInt8Array>()
358            .expect("autonomy_tier column");
359        let modified = self
360            .batch
361            .column(param_col::MODIFIED_BY)
362            .as_any()
363            .downcast_ref::<StringArray>()
364            .expect("modified_by column");
365
366        CognitiveParameter {
367            param_id: ids.value(idx).to_string(),
368            category: categories.value(idx).to_string(),
369            value_f64: if values_f64.is_null(idx) {
370                None
371            } else {
372                Some(values_f64.value(idx))
373            },
374            value_str: if values_str.is_null(idx) {
375                None
376            } else {
377                Some(values_str.value(idx).to_string())
378            },
379            min_bound: if min_bounds.is_null(idx) {
380                None
381            } else {
382                Some(min_bounds.value(idx))
383            },
384            max_bound: if max_bounds.is_null(idx) {
385                None
386            } else {
387                Some(max_bounds.value(idx))
388            },
389            autonomy_tier: AutonomyTier::from_u8(tiers.value(idx))
390                .unwrap_or(AutonomyTier::CaptainOnly),
391            modified_by: modified.value(idx).to_string(),
392        }
393    }
394
395    fn rebuild_with_f64_update(
396        &self,
397        idx: usize,
398        new_value: f64,
399        new_modified_by: &str,
400    ) -> Result<RecordBatch, ParamStoreError> {
401        let n = self.batch.num_rows();
402
403        // Copy all columns, modifying value_f64 and modified_by at `idx`
404        let ids = self.param_id_col();
405        let cats = self.category_col();
406        let vals = self
407            .batch
408            .column(param_col::VALUE_F64)
409            .as_any()
410            .downcast_ref::<Float64Array>()
411            .expect("value_f64");
412        let strs = self
413            .batch
414            .column(param_col::VALUE_STR)
415            .as_any()
416            .downcast_ref::<StringArray>()
417            .expect("value_str");
418        let mins = self
419            .batch
420            .column(param_col::MIN_BOUND)
421            .as_any()
422            .downcast_ref::<Float64Array>()
423            .expect("min_bound");
424        let maxs = self
425            .batch
426            .column(param_col::MAX_BOUND)
427            .as_any()
428            .downcast_ref::<Float64Array>()
429            .expect("max_bound");
430        let tiers = self
431            .batch
432            .column(param_col::AUTONOMY_TIER)
433            .as_any()
434            .downcast_ref::<UInt8Array>()
435            .expect("autonomy_tier");
436        let mods = self
437            .batch
438            .column(param_col::MODIFIED_BY)
439            .as_any()
440            .downcast_ref::<StringArray>()
441            .expect("modified_by");
442
443        let new_ids: Vec<&str> = (0..n).map(|i| ids.value(i)).collect();
444        let new_cats: Vec<&str> = (0..n).map(|i| cats.value(i)).collect();
445        let new_vals: Vec<Option<f64>> = (0..n)
446            .map(|i| {
447                if i == idx {
448                    Some(new_value)
449                } else if vals.is_null(i) {
450                    None
451                } else {
452                    Some(vals.value(i))
453                }
454            })
455            .collect();
456        let new_strs: Vec<Option<&str>> = (0..n)
457            .map(|i| {
458                if strs.is_null(i) {
459                    None
460                } else {
461                    Some(strs.value(i))
462                }
463            })
464            .collect();
465        let new_mins: Vec<Option<f64>> = (0..n)
466            .map(|i| {
467                if mins.is_null(i) {
468                    None
469                } else {
470                    Some(mins.value(i))
471                }
472            })
473            .collect();
474        let new_maxs: Vec<Option<f64>> = (0..n)
475            .map(|i| {
476                if maxs.is_null(i) {
477                    None
478                } else {
479                    Some(maxs.value(i))
480                }
481            })
482            .collect();
483        let new_tiers: Vec<u8> = (0..n).map(|i| tiers.value(i)).collect();
484        let new_mods: Vec<&str> = (0..n)
485            .map(|i| {
486                if i == idx {
487                    new_modified_by
488                } else {
489                    mods.value(i)
490                }
491            })
492            .collect();
493
494        Ok(RecordBatch::try_new(
495            Arc::new(cognitive_params_schema()),
496            vec![
497                Arc::new(StringArray::from(new_ids)),
498                Arc::new(StringArray::from(new_cats)),
499                Arc::new(Float64Array::from(new_vals)),
500                Arc::new(StringArray::from(new_strs)),
501                Arc::new(Float64Array::from(new_mins)),
502                Arc::new(Float64Array::from(new_maxs)),
503                Arc::new(UInt8Array::from(new_tiers)),
504                Arc::new(StringArray::from(new_mods)),
505            ],
506        )?)
507    }
508
509    fn remove_row(&self, idx: usize) -> Result<RecordBatch, ParamStoreError> {
510        let n = self.batch.num_rows();
511        if n == 0 {
512            return Ok(self.batch.clone());
513        }
514
515        let ids = self.param_id_col();
516        let cats = self.category_col();
517        let vals = self
518            .batch
519            .column(param_col::VALUE_F64)
520            .as_any()
521            .downcast_ref::<Float64Array>()
522            .expect("value_f64");
523        let strs = self
524            .batch
525            .column(param_col::VALUE_STR)
526            .as_any()
527            .downcast_ref::<StringArray>()
528            .expect("value_str");
529        let mins = self
530            .batch
531            .column(param_col::MIN_BOUND)
532            .as_any()
533            .downcast_ref::<Float64Array>()
534            .expect("min_bound");
535        let maxs = self
536            .batch
537            .column(param_col::MAX_BOUND)
538            .as_any()
539            .downcast_ref::<Float64Array>()
540            .expect("max_bound");
541        let tiers = self
542            .batch
543            .column(param_col::AUTONOMY_TIER)
544            .as_any()
545            .downcast_ref::<UInt8Array>()
546            .expect("autonomy_tier");
547        let mods = self
548            .batch
549            .column(param_col::MODIFIED_BY)
550            .as_any()
551            .downcast_ref::<StringArray>()
552            .expect("modified_by");
553
554        let keep: Vec<usize> = (0..n).filter(|&i| i != idx).collect();
555        if keep.is_empty() {
556            return Ok(RecordBatch::new_empty(Arc::new(cognitive_params_schema())));
557        }
558
559        let new_ids: Vec<&str> = keep.iter().map(|&i| ids.value(i)).collect();
560        let new_cats: Vec<&str> = keep.iter().map(|&i| cats.value(i)).collect();
561        let new_vals: Vec<Option<f64>> = keep
562            .iter()
563            .map(|&i| {
564                if vals.is_null(i) {
565                    None
566                } else {
567                    Some(vals.value(i))
568                }
569            })
570            .collect();
571        let new_strs: Vec<Option<&str>> = keep
572            .iter()
573            .map(|&i| {
574                if strs.is_null(i) {
575                    None
576                } else {
577                    Some(strs.value(i))
578                }
579            })
580            .collect();
581        let new_mins: Vec<Option<f64>> = keep
582            .iter()
583            .map(|&i| {
584                if mins.is_null(i) {
585                    None
586                } else {
587                    Some(mins.value(i))
588                }
589            })
590            .collect();
591        let new_maxs: Vec<Option<f64>> = keep
592            .iter()
593            .map(|&i| {
594                if maxs.is_null(i) {
595                    None
596                } else {
597                    Some(maxs.value(i))
598                }
599            })
600            .collect();
601        let new_tiers: Vec<u8> = keep.iter().map(|&i| tiers.value(i)).collect();
602        let new_mods: Vec<&str> = keep.iter().map(|&i| mods.value(i)).collect();
603
604        Ok(RecordBatch::try_new(
605            Arc::new(cognitive_params_schema()),
606            vec![
607                Arc::new(StringArray::from(new_ids)),
608                Arc::new(StringArray::from(new_cats)),
609                Arc::new(Float64Array::from(new_vals)),
610                Arc::new(StringArray::from(new_strs)),
611                Arc::new(Float64Array::from(new_mins)),
612                Arc::new(Float64Array::from(new_maxs)),
613                Arc::new(UInt8Array::from(new_tiers)),
614                Arc::new(StringArray::from(new_mods)),
615            ],
616        )?)
617    }
618
619    // ── Signal weight helpers ──────────────────────────────────────────
620
621    /// Insert a signal weight entry.
622    ///
623    /// Uses convention: param_id = `sw.{dimension}.{path}`, category = `signal_weight`,
624    /// bounds = [-1.5, 1.5], tier = Auto.
625    /// The value is clipped to [-1.5, 1.5] before insertion.
626    pub fn insert_signal_weight(
627        &mut self,
628        dimension: &str,
629        path: &str,
630        value: f64,
631    ) -> Result<(), ParamStoreError> {
632        let clipped = value.clamp(-1.5, 1.5);
633        let param = CognitiveParameter {
634            param_id: format!("sw.{dimension}.{path}"),
635            category: category::SIGNAL_WEIGHT.to_string(),
636            value_f64: Some(clipped),
637            value_str: None,
638            min_bound: Some(-1.5),
639            max_bound: Some(1.5),
640            autonomy_tier: AutonomyTier::Auto,
641            modified_by: "init".to_string(),
642        };
643        self.insert(&param)
644    }
645
646    /// Get a signal weight by dimension and path.
647    pub fn get_signal_weight(&self, dimension: &str, path: &str) -> Option<f64> {
648        let param_id = format!("sw.{dimension}.{path}");
649        self.get(&param_id).and_then(|p| p.value_f64)
650    }
651
652    /// List all signal weights as (dimension, path, value) tuples.
653    pub fn signal_weights(&self) -> Vec<(String, String, f64)> {
654        self.list(category::SIGNAL_WEIGHT)
655            .into_iter()
656            .filter_map(|p| {
657                if let Some(rest) = p.param_id.strip_prefix("sw.") {
658                    // Split on first '.' to get dimension, rest is path
659                    if let Some(dot_pos) = rest.find('.') {
660                        let dimension = rest[..dot_pos].to_string();
661                        let path = rest[dot_pos + 1..].to_string();
662                        return p.value_f64.map(|v| (dimension, path, v));
663                    }
664                }
665                None
666            })
667            .collect()
668    }
669
670    /// Number of signal weight entries.
671    pub fn signal_weight_count(&self) -> usize {
672        self.list(category::SIGNAL_WEIGHT)
673            .iter()
674            .filter(|p| p.param_id.starts_with("sw."))
675            .count()
676    }
677
678    // ── Loss coefficient helpers ────────────────────────────────────────
679
680    /// Insert a loss coefficient parameter.
681    ///
682    /// Uses convention: param_id = `loss.{name}`, category = `loss_coefficient`,
683    /// tier = Auto (Tier 1 — modifiable by HDD loop).
684    pub fn insert_loss_coefficient(
685        &mut self,
686        name: &str,
687        value: f64,
688    ) -> Result<(), ParamStoreError> {
689        let param = CognitiveParameter {
690            param_id: format!("loss.{name}"),
691            category: category::LOSS_COEFFICIENT.to_string(),
692            value_f64: Some(value),
693            value_str: None,
694            min_bound: Some(0.0),
695            max_bound: Some(2.0),
696            autonomy_tier: AutonomyTier::Auto,
697            modified_by: "init".to_string(),
698        };
699        self.insert(&param)
700    }
701
702    /// Get a loss coefficient by name.
703    pub fn get_loss_coefficient(&self, name: &str) -> Option<f64> {
704        let param_id = format!("loss.{name}");
705        self.get(&param_id).and_then(|p| p.value_f64)
706    }
707
708    /// List all loss coefficients as (name, value) pairs.
709    pub fn loss_coefficients(&self) -> Vec<(String, f64)> {
710        self.list(category::LOSS_COEFFICIENT)
711            .into_iter()
712            .filter_map(|p| {
713                let name = p.param_id.strip_prefix("loss.")?.to_string();
714                p.value_f64.map(|v| (name, v))
715            })
716            .collect()
717    }
718
719    // ── Consolidation trigger helpers ───────────────────────────────────
720
721    /// Insert a consolidation trigger parameter.
722    ///
723    /// Uses convention: param_id = `consol.{name}`, category = `consolidation_trigger`,
724    /// tier = BeingApproved (Tier 2 — requires being confirmation before modification).
725    pub fn insert_consolidation_trigger(
726        &mut self,
727        name: &str,
728        value: f64,
729    ) -> Result<(), ParamStoreError> {
730        let param = CognitiveParameter {
731            param_id: format!("consol.{name}"),
732            category: category::CONSOLIDATION_TRIGGER.to_string(),
733            value_f64: Some(value),
734            value_str: None,
735            min_bound: Some(0.0),
736            max_bound: None,
737            autonomy_tier: AutonomyTier::BeingApproved,
738            modified_by: "init".to_string(),
739        };
740        self.insert(&param)
741    }
742
743    /// Get a consolidation trigger by name.
744    pub fn get_consolidation_trigger(&self, name: &str) -> Option<f64> {
745        let param_id = format!("consol.{name}");
746        self.get(&param_id).and_then(|p| p.value_f64)
747    }
748
749    /// List all consolidation triggers as (name, value) pairs.
750    pub fn consolidation_triggers(&self) -> Vec<(String, f64)> {
751        self.list(category::CONSOLIDATION_TRIGGER)
752            .into_iter()
753            .filter_map(|p| {
754                let name = p.param_id.strip_prefix("consol.")?.to_string();
755                p.value_f64.map(|v| (name, v))
756            })
757            .collect()
758    }
759}
760
761impl Default for CognitiveParameterStore {
762    fn default() -> Self {
763        Self::new()
764    }
765}
766
767// ── Helpers ────────────────────────────────────────────────────────────
768
769/// Append a single parameter row to an existing RecordBatch.
770fn append_row(
771    batch: &RecordBatch,
772    param: &CognitiveParameter,
773) -> Result<RecordBatch, ParamStoreError> {
774    let n = batch.num_rows();
775
776    if n == 0 {
777        // Build a single-row batch
778        return Ok(RecordBatch::try_new(
779            Arc::new(cognitive_params_schema()),
780            vec![
781                Arc::new(StringArray::from(vec![param.param_id.as_str()])),
782                Arc::new(StringArray::from(vec![param.category.as_str()])),
783                Arc::new(Float64Array::from(vec![param.value_f64])),
784                Arc::new(StringArray::from(vec![param.value_str.as_deref()])),
785                Arc::new(Float64Array::from(vec![param.min_bound])),
786                Arc::new(Float64Array::from(vec![param.max_bound])),
787                Arc::new(UInt8Array::from(vec![param.autonomy_tier as u8])),
788                Arc::new(StringArray::from(vec![param.modified_by.as_str()])),
789            ],
790        )?);
791    }
792
793    // Extract existing columns and append
794    let ids = batch
795        .column(param_col::PARAM_ID)
796        .as_any()
797        .downcast_ref::<StringArray>()
798        .expect("param_id");
799    let cats = batch
800        .column(param_col::CATEGORY)
801        .as_any()
802        .downcast_ref::<StringArray>()
803        .expect("category");
804    let vals = batch
805        .column(param_col::VALUE_F64)
806        .as_any()
807        .downcast_ref::<Float64Array>()
808        .expect("value_f64");
809    let strs = batch
810        .column(param_col::VALUE_STR)
811        .as_any()
812        .downcast_ref::<StringArray>()
813        .expect("value_str");
814    let mins = batch
815        .column(param_col::MIN_BOUND)
816        .as_any()
817        .downcast_ref::<Float64Array>()
818        .expect("min_bound");
819    let maxs = batch
820        .column(param_col::MAX_BOUND)
821        .as_any()
822        .downcast_ref::<Float64Array>()
823        .expect("max_bound");
824    let tiers = batch
825        .column(param_col::AUTONOMY_TIER)
826        .as_any()
827        .downcast_ref::<UInt8Array>()
828        .expect("autonomy_tier");
829    let mods = batch
830        .column(param_col::MODIFIED_BY)
831        .as_any()
832        .downcast_ref::<StringArray>()
833        .expect("modified_by");
834
835    let mut new_ids: Vec<&str> = (0..n).map(|i| ids.value(i)).collect();
836    new_ids.push(&param.param_id);
837
838    let mut new_cats: Vec<&str> = (0..n).map(|i| cats.value(i)).collect();
839    new_cats.push(&param.category);
840
841    let mut new_vals: Vec<Option<f64>> = (0..n)
842        .map(|i| {
843            if vals.is_null(i) {
844                None
845            } else {
846                Some(vals.value(i))
847            }
848        })
849        .collect();
850    new_vals.push(param.value_f64);
851
852    let value_str_owned: Vec<Option<String>> = (0..n)
853        .map(|i| {
854            if strs.is_null(i) {
855                None
856            } else {
857                Some(strs.value(i).to_string())
858            }
859        })
860        .chain(std::iter::once(param.value_str.clone()))
861        .collect();
862    let new_strs: Vec<Option<&str>> = value_str_owned.iter().map(|s| s.as_deref()).collect();
863
864    let mut new_mins: Vec<Option<f64>> = (0..n)
865        .map(|i| {
866            if mins.is_null(i) {
867                None
868            } else {
869                Some(mins.value(i))
870            }
871        })
872        .collect();
873    new_mins.push(param.min_bound);
874
875    let mut new_maxs: Vec<Option<f64>> = (0..n)
876        .map(|i| {
877            if maxs.is_null(i) {
878                None
879            } else {
880                Some(maxs.value(i))
881            }
882        })
883        .collect();
884    new_maxs.push(param.max_bound);
885
886    let mut new_tiers: Vec<u8> = (0..n).map(|i| tiers.value(i)).collect();
887    new_tiers.push(param.autonomy_tier as u8);
888
889    let mut new_mods: Vec<&str> = (0..n).map(|i| mods.value(i)).collect();
890    new_mods.push(&param.modified_by);
891
892    Ok(RecordBatch::try_new(
893        Arc::new(cognitive_params_schema()),
894        vec![
895            Arc::new(StringArray::from(new_ids)),
896            Arc::new(StringArray::from(new_cats)),
897            Arc::new(Float64Array::from(new_vals)),
898            Arc::new(StringArray::from(new_strs)),
899            Arc::new(Float64Array::from(new_mins)),
900            Arc::new(Float64Array::from(new_maxs)),
901            Arc::new(UInt8Array::from(new_tiers)),
902            Arc::new(StringArray::from(new_mods)),
903        ],
904    )?)
905}
906
907// ── Default parameters ─────────────────────────────────────────────────
908
909/// Build a store populated with default cognitive parameters.
910///
911/// This captures the current hardcoded values from:
912/// - `nusy-signal-fusion::weight_learner::LearningConfig::default()`
913/// - Signal fusion softmax temperature
914/// - Schema match thresholds
915///
916/// Each parameter is classified by category and autonomy tier.
917pub fn default_cognitive_params() -> CognitiveParameterStore {
918    let mut store = CognitiveParameterStore::new();
919
920    // ── Learning config (from weight_learner.rs) ───────────────────
921    let lc = |id: &str, val: f64, min: f64, max: f64| CognitiveParameter {
922        param_id: format!("learning.{id}"),
923        category: category::LEARNING_CONFIG.to_string(),
924        value_f64: Some(val),
925        value_str: None,
926        min_bound: Some(min),
927        max_bound: Some(max),
928        autonomy_tier: AutonomyTier::Auto,
929        modified_by: "init".to_string(),
930    };
931
932    let params = vec![
933        lc("initial_learning_rate", 0.1, 0.001, 1.0),
934        lc("decayed_learning_rate", 0.01, 0.0001, 0.5),
935        lc("error_spike_learning_rate", 0.05, 0.001, 0.5),
936        lc("decay_after_decisions", 100.0, 10.0, 10000.0),
937        lc("weight_decay_lambda", 0.001, 0.0, 0.1),
938        lc("max_delta_per_step", 0.1, 0.01, 1.0),
939        lc("min_weight", -1.5, -10.0, 0.0),
940        lc("max_weight", 1.5, 0.0, 10.0),
941        lc("hebbian_lr_multiplier", 0.5, 0.0, 2.0),
942        lc("error_spike_window", 20.0, 5.0, 100.0),
943        lc("error_spike_threshold", 0.5, 0.1, 1.0),
944    ];
945
946    for p in &params {
947        store
948            .insert(p)
949            .expect("default learning config insert should not fail");
950    }
951
952    // ── Signal fusion thresholds ───────────────────────────────────
953    let th = |id: &str, val: f64, min: f64, max: f64| CognitiveParameter {
954        param_id: format!("fusion.{id}"),
955        category: category::THRESHOLD.to_string(),
956        value_f64: Some(val),
957        value_str: None,
958        min_bound: Some(min),
959        max_bound: Some(max),
960        autonomy_tier: AutonomyTier::Auto,
961        modified_by: "init".to_string(),
962    };
963
964    let thresholds = vec![
965        th("softmax_temperature", 1.0, 0.01, 10.0),
966        th("min_signal_strength", 0.01, 0.0, 1.0),
967        th("refuse_low_coverage_threshold", 0.7, 0.0, 1.0),
968    ];
969
970    for p in &thresholds {
971        store
972            .insert(p)
973            .expect("default threshold insert should not fail");
974    }
975
976    // ── Schema match thresholds ─────────────────────────────────────
977    // Tier 1 (Auto) for thresholds HDD can tune; Tier 2 for structural.
978    let sm_auto = |id: &str, val: f64, min: f64, max: f64| CognitiveParameter {
979        param_id: format!("schema_match.{id}"),
980        category: category::THRESHOLD.to_string(),
981        value_f64: Some(val),
982        value_str: None,
983        min_bound: Some(min),
984        max_bound: Some(max),
985        autonomy_tier: AutonomyTier::Auto,
986        modified_by: "init".to_string(),
987    };
988    let sm_being = |id: &str, val: f64, min: f64, max: f64| CognitiveParameter {
989        param_id: format!("schema_match.{id}"),
990        category: category::THRESHOLD.to_string(),
991        value_f64: Some(val),
992        value_str: None,
993        min_bound: Some(min),
994        max_bound: Some(max),
995        autonomy_tier: AutonomyTier::BeingApproved,
996        modified_by: "init".to_string(),
997    };
998
999    let schema_thresholds = vec![
1000        sm_auto("assimilate_threshold", 0.7, 0.0, 1.0),
1001        sm_auto("accommodate_threshold", 0.3, 0.0, 1.0),
1002        sm_being("novelty_high_threshold", 0.8, 0.0, 1.0),
1003        sm_auto("assimilation_boost", 0.05, 0.0, 0.5),
1004        sm_auto("coverage_saturation", 5.0, 1.0, 50.0),
1005    ];
1006
1007    for p in &schema_thresholds {
1008        store
1009            .insert(p)
1010            .expect("default schema threshold insert should not fail");
1011    }
1012
1013    // ── Consolidation triggers (Tier 2) ────────────────────────────
1014    let tr = |id: &str, val: f64, min: f64, max: f64| CognitiveParameter {
1015        param_id: format!("consolidation.{id}"),
1016        category: category::TRIGGER.to_string(),
1017        value_f64: Some(val),
1018        value_str: None,
1019        min_bound: Some(min),
1020        max_bound: Some(max),
1021        autonomy_tier: AutonomyTier::BeingApproved,
1022        modified_by: "init".to_string(),
1023    };
1024
1025    let triggers = vec![
1026        tr("min_triples_for_training", 200.0, 10.0, 10000.0),
1027        tr("consolidation_cycle_interval_hours", 24.0, 1.0, 168.0),
1028        tr("kl_divergence_budget", 0.5, 0.01, 5.0),
1029    ];
1030
1031    for p in &triggers {
1032        store
1033            .insert(p)
1034            .expect("default trigger insert should not fail");
1035    }
1036
1037    // ── Signal weights (merged from default_signal_weights) ─────────
1038    let sw_store = default_signal_weights();
1039    for p in sw_store.list_all() {
1040        store
1041            .insert(&p)
1042            .expect("default signal weight insert should not fail");
1043    }
1044
1045    // ── Loss coefficients (from DualLossConfig defaults) ─────────
1046    // Values sourced from crates/nusy-training/src/dual_loss.rs::DualLossConfig::default()
1047    store
1048        .insert_loss_coefficient("alpha_lm", 1.0)
1049        .expect("default loss coefficient insert");
1050    store
1051        .insert_loss_coefficient("alpha_rel", 0.1)
1052        .expect("default loss coefficient insert");
1053    store
1054        .insert_loss_coefficient("alpha_causal", 0.05)
1055        .expect("default loss coefficient insert");
1056    store
1057        .insert_loss_coefficient("rel_warmup_fraction", 0.1)
1058        .expect("default loss coefficient insert");
1059    store
1060        .insert_loss_coefficient("causal_warmup_fraction", 0.2)
1061        .expect("default loss coefficient insert");
1062
1063    // ── Consolidation triggers (from ConsolidationConfig + TrainingTriggerConfig defaults) ──
1064    // Values sourced from crates/nusy-dual-store/src/consolidation.rs::ConsolidationConfig::default()
1065    // and crates/nusy-consolidation/src/trigger.rs::TrainingTriggerConfig::default()
1066    store
1067        .insert_consolidation_trigger("dedup_threshold", 0.95)
1068        .expect("default consolidation trigger insert");
1069    store
1070        .insert_consolidation_trigger("max_triples_per_cycle", 500.0)
1071        .expect("default consolidation trigger insert");
1072    store
1073        .insert_consolidation_trigger("min_promoted_triples", 50.0)
1074        .expect("default consolidation trigger insert");
1075    store
1076        .insert_consolidation_trigger("training_threshold", 200.0)
1077        .expect("default consolidation trigger insert");
1078    store
1079        .insert_consolidation_trigger("max_pairs_per_batch", 500.0)
1080        .expect("default consolidation trigger insert");
1081
1082    store
1083}
1084
1085/// Build a store populated with default signal fusion weights.
1086///
1087/// Ports the 200+ weight entries from `nusy-signal-fusion::weights::default_weight_entries()`.
1088/// Each entry is stored as category="signal_weight" with param_id="sw.{dim}.{path}".
1089pub fn default_signal_weights() -> CognitiveParameterStore {
1090    let mut store = CognitiveParameterStore::new();
1091
1092    let mut add = |dim: &str, path: &str, weight: f64| {
1093        store
1094            .insert_signal_weight(dim, path, weight)
1095            .expect("default signal weight insert should not fail");
1096    };
1097
1098    // Fractal confidence/ambiguity
1099    add("fractal_confidence", "FAST", 0.9);
1100    add("fractal_confidence", "STANDARD", 0.3);
1101    add("fractal_confidence", "DEEP", -0.5);
1102    add("fractal_confidence", "REFUSE_LOW_COVERAGE", -0.5);
1103    add("fractal_ambiguity", "FAST", -0.5);
1104    add("fractal_ambiguity", "STANDARD", 0.2);
1105    add("fractal_ambiguity", "DEEP", 0.8);
1106
1107    // Novelty
1108    add("novelty_surprise", "FAST", -0.3);
1109    add("novelty_surprise", "STANDARD", 0.1);
1110    add("novelty_surprise", "DEEP", 0.7);
1111    add("novelty_surprise", "FAST_LEARNING", 0.5);
1112
1113    // FOV
1114    add("fov_temperature", "FAST", 0.4);
1115    add("fov_temperature", "STANDARD", 0.2);
1116    add("fov_temperature", "DEEP", -0.1);
1117
1118    // Emotion
1119    add("emotion_confusion", "FAST", -0.2);
1120    add("emotion_confusion", "STANDARD", 0.3);
1121    add("emotion_confusion", "DEEP", 0.5);
1122
1123    // State
1124    add("state_importance", "FAST", -0.1);
1125    add("state_importance", "STANDARD", 0.2);
1126    add("state_importance", "DEEP", 0.5);
1127
1128    // Input type
1129    add("is_document", "TRAINING", 1.0);
1130    add("urgency", "FAST_LEARNING", 0.8);
1131
1132    // Provenance (EXP-869)
1133    add("provenance_support", "FAST", 0.8);
1134    add("provenance_support", "STANDARD", 0.2);
1135    add("provenance_support", "DEEP", -0.3);
1136    add("provenance_coverage", "FAST", 0.5);
1137    add("provenance_coverage", "STANDARD", 0.3);
1138    add("provenance_coverage", "DEEP", -0.2);
1139    add("provenance_coverage", "FAST_LEARNING", -0.4);
1140
1141    // Action (EXP-879)
1142    add("action_pending", "FAST", -0.2);
1143    add("action_pending", "STANDARD", 0.4);
1144    add("action_pending", "DEEP", 0.3);
1145    add("action_intent_level", "FAST", 0.6);
1146    add("action_intent_level", "STANDARD", 0.1);
1147    add("action_intent_level", "DEEP", -0.3);
1148    add("action_coverage", "FAST", 0.5);
1149    add("action_coverage", "STANDARD", 0.2);
1150    add("action_coverage", "DEEP", -0.2);
1151
1152    // KBDD (EXP-899)
1153    add("kbdd_coverage", "FAST", 0.7);
1154    add("kbdd_coverage", "STANDARD", 0.1);
1155    add("kbdd_coverage", "DEEP", -0.4);
1156    add("kbdd_coverage", "FAST_LEARNING", -0.3);
1157    add("kbdd_gap_density", "FAST", -0.5);
1158    add("kbdd_gap_density", "STANDARD", 0.2);
1159    add("kbdd_gap_density", "DEEP", 0.6);
1160    add("kbdd_gap_density", "TRAINING", 0.4);
1161    add("kbdd_gap_density", "CRYSTALLIZE", 0.6);
1162
1163    // Query understanding (EXP-951)
1164    add("query_complexity", "FAST", -0.4);
1165    add("query_complexity", "STANDARD", 0.2);
1166    add("query_complexity", "DEEP", 0.6);
1167    add("query_expected_depth", "FAST", -0.3);
1168    add("query_expected_depth", "STANDARD", 0.1);
1169    add("query_expected_depth", "DEEP", 0.5);
1170    add("query_domain_match", "FAST", 0.3);
1171    add("query_domain_match", "STANDARD", 0.1);
1172    add("query_domain_match", "REFUSE_LOW_COVERAGE", -0.3);
1173
1174    // Entity grounding (EXP-932)
1175    add("entity_gap", "REFUSE_LOW_COVERAGE", 0.9);
1176    add("entity_gap", "CRYSTALLIZE", 0.3);
1177    add("entity_gap", "FAST", -0.4);
1178    add("entity_gap", "STANDARD", -0.2);
1179    add("coverage_gap", "REFUSE_LOW_COVERAGE", 0.7);
1180    add("coverage_gap", "CRYSTALLIZE", 0.4);
1181    add("coverage_gap", "FAST", -0.3);
1182    add("prose_available", "CRYSTALLIZE", 0.8);
1183    add("prose_available", "REFUSE_LOW_COVERAGE", -0.7);
1184    add("entity_grounding", "FAST", 0.3);
1185    add("entity_grounding", "REFUSE_LOW_COVERAGE", -0.5);
1186
1187    // Competency (EXP-958)
1188    add("competency_match", "FAST", 0.2);
1189    add("competency_match", "STANDARD", 0.3);
1190    add("competency_match", "REFUSE_LOW_COVERAGE", -0.6);
1191    add("competency_expected_quality", "FAST", 0.3);
1192    add("competency_expected_quality", "STANDARD", 0.1);
1193    add("competency_expected_quality", "DEEP", -0.2);
1194
1195    // Pattern (EXP-958)
1196    add("pattern_applicability", "FAST", -0.3);
1197    add("pattern_applicability", "STANDARD", 0.1);
1198    add("pattern_applicability", "DEEP", 0.5);
1199    add("pattern_structured_reasoning", "FAST", -0.2);
1200    add("pattern_structured_reasoning", "DEEP", 0.4);
1201
1202    // Goal (EXP-958)
1203    add("goal_alignment", "FAST", -0.1);
1204    add("goal_alignment", "STANDARD", 0.4);
1205    add("goal_alignment", "REFUSE_LOW_COVERAGE", -0.3);
1206    add("goal_response_priority", "STANDARD", 0.3);
1207    add("goal_response_priority", "DEEP", 0.2);
1208
1209    // Conversation (EXP-965)
1210    add("conv_turn_depth", "STANDARD", 0.2);
1211    add("conv_turn_depth", "DEEP", 0.3);
1212    add("conv_entity_continuity", "FAST", 0.3);
1213    add("conv_entity_continuity", "STANDARD", 0.1);
1214    add("conv_entity_continuity", "REFUSE_LOW_COVERAGE", -0.3);
1215    add("conv_topic_repetition", "DEEP", 0.4);
1216    add("conv_topic_repetition", "STANDARD", 0.1);
1217    add("conv_is_followup", "STANDARD", 0.3);
1218    add("conv_is_followup", "FAST", 0.1);
1219    add("conv_is_followup", "REFUSE_LOW_COVERAGE", -0.3);
1220
1221    // Growth awareness (EXP-995)
1222    add("growth_depth", "FAST", -0.3);
1223    add("growth_depth", "STANDARD", 0.2);
1224    add("growth_depth", "DEEP", 0.7);
1225    add("growth_richness", "FAST", -0.2);
1226    add("growth_richness", "STANDARD", 0.4);
1227    add("growth_richness", "DEEP", 0.5);
1228    add("growth_layers", "FAST", -0.1);
1229    add("growth_layers", "STANDARD", 0.5);
1230    add("growth_layers", "DEEP", 0.3);
1231    add("growth_connections", "FAST", -0.2);
1232    add("growth_connections", "STANDARD", 0.3);
1233    add("growth_connections", "DEEP", 0.5);
1234
1235    // LLM explore (EXP-1082)
1236    add("llm_explore_confidence", "STANDARD", 0.3);
1237    add("llm_explore_confidence", "DEEP", 0.4);
1238    add("llm_explore_confidence", "CRYSTALLIZE", 0.3);
1239    add("llm_structure_signal", "STANDARD", 0.2);
1240    add("llm_structure_signal", "DEEP", 0.5);
1241    add("llm_domain_signal", "STANDARD", 0.3);
1242    add("llm_domain_signal", "DEEP", 0.3);
1243    add("llm_relationship_richness", "STANDARD", 0.3);
1244    add("llm_relationship_richness", "DEEP", 0.4);
1245    add("llm_expansion_signal", "STANDARD", 0.2);
1246    add("llm_expansion_signal", "DEEP", 0.3);
1247    add("llm_expansion_signal", "CRYSTALLIZE", 0.2);
1248    add("llm_unknown_structure", "DEEP", 0.5);
1249    add("llm_unknown_structure", "CRYSTALLIZE", 0.4);
1250
1251    // Schema match (EXP-1218)
1252    add("schema_match", "FAST", 0.5);
1253    add("schema_match", "STANDARD", 0.2);
1254    add("schema_match", "DEEP", -0.3);
1255    add("schema_match", "REFUSE_LOW_COVERAGE", -0.3);
1256    add("schema_novelty", "FAST", -0.4);
1257    add("schema_novelty", "STANDARD", 0.1);
1258    add("schema_novelty", "DEEP", 0.6);
1259    add("schema_novelty", "FAST_LEARNING", 0.4);
1260    add("schema_novelty", "TRAINING", 0.3);
1261
1262    // Tool use (EXP-1133)
1263    add("tool_needed", "TOOL_USE", 0.9);
1264    add("tool_needed", "FAST", -0.2);
1265    add("tool_needed", "STANDARD", -0.1);
1266    add("tool_needed", "REFUSE_LOW_COVERAGE", -0.7);
1267    add("tool_computation", "TOOL_USE", 0.7);
1268    add("tool_computation", "DEEP", -0.3);
1269    add("tool_action", "TOOL_USE", 0.6);
1270    add("tool_match", "TOOL_USE", 0.8);
1271    add("tool_match", "REFUSE_LOW_COVERAGE", -0.5);
1272    add("tool_count", "TOOL_USE", 0.3);
1273    add("tool_auto_approve", "TOOL_USE", 0.4);
1274    add("tool_auto_approve", "FAST", 0.2);
1275    add("tool_safety_score", "TOOL_USE", 0.5);
1276    add("tool_sandbox_ok", "TOOL_USE", 0.3);
1277
1278    // Embedding (EXP-1169, EXP-1170)
1279    add("embedding_computation", "TOOL_USE", 0.8);
1280    add("embedding_computation", "FAST", -0.2);
1281    add("embedding_computation", "REFUSE_LOW_COVERAGE", -0.5);
1282    add("embedding_mean_similarity", "TOOL_USE", 0.3);
1283    add("embedding_tool_match", "TOOL_USE", 0.8);
1284    add("embedding_tool_match", "FAST", -0.2);
1285    add("embedding_tool_match", "REFUSE_LOW_COVERAGE", -0.5);
1286    add("embedding_tool_mean", "TOOL_USE", 0.3);
1287
1288    // Recency
1289    add("recency_weight", "FAST", 0.3);
1290    add("recency_weight", "STANDARD", 0.1);
1291
1292    // Working Memory (EX-3239, EX-3380)
1293    add("wm_repetition", "FAST", 0.4);
1294    add("wm_repetition", "STANDARD", -0.1);
1295    add("wm_topic_continuity", "STANDARD", 0.3);
1296    add("wm_topic_continuity", "DEEP", 0.2);
1297
1298    // Calibration / Competence (EX-3437)
1299    add("calibration_accuracy", "FAST", 0.6);
1300    add("calibration_accuracy", "STANDARD", 0.4);
1301    add("calibration_accuracy", "REFUSE_LOW_COVERAGE", -2.5);
1302    add("calibration_confidence", "FAST", 0.3);
1303    add("calibration_confidence", "DEEP", -0.2);
1304    add("calibration_confidence", "REFUSE_LOW_COVERAGE", -0.6);
1305    add("certification_gap", "FAST", -0.5);
1306    add("certification_gap", "STANDARD", 0.2);
1307    add("certification_gap", "REFUSE_LOW_COVERAGE", 0.3);
1308    add("calibration_maturity", "FAST", 0.2);
1309    add("calibration_maturity", "REFUSE_LOW_COVERAGE", -0.3);
1310
1311    store
1312}
1313
1314// ── Parquet persistence (Phase 4: Graph-Native Git Integration) ────────
1315
1316/// Save the parameter store to a Parquet file.
1317///
1318/// Uses the `nusy-arrow-git` save_named_batches pattern: the store is
1319/// serialized as a single Parquet file in the `self` namespace directory.
1320pub fn save_params_to_parquet(
1321    store: &CognitiveParameterStore,
1322    path: &std::path::Path,
1323) -> Result<(), ParamStoreError> {
1324    use parquet::arrow::ArrowWriter;
1325    use std::fs;
1326
1327    if let Some(parent) = path.parent() {
1328        fs::create_dir_all(parent).map_err(|e| {
1329            ParamStoreError::InvalidSchema(format!("failed to create directory: {e}"))
1330        })?;
1331    }
1332
1333    let file = fs::File::create(path)
1334        .map_err(|e| ParamStoreError::InvalidSchema(format!("failed to create file: {e}")))?;
1335
1336    let batch = store.snapshot();
1337    let schema = Arc::new(cognitive_params_schema());
1338    let mut writer = ArrowWriter::try_new(file, schema, None)?;
1339
1340    if batch.num_rows() > 0 {
1341        writer.write(batch)?;
1342    }
1343    writer.close()?;
1344
1345    Ok(())
1346}
1347
1348/// Load a parameter store from a Parquet file.
1349///
1350/// Returns `None` if the file doesn't exist (first-run case).
1351pub fn load_params_from_parquet(
1352    path: &std::path::Path,
1353) -> Result<Option<CognitiveParameterStore>, ParamStoreError> {
1354    use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
1355
1356    if !path.exists() {
1357        return Ok(None);
1358    }
1359
1360    let file = std::fs::File::open(path)
1361        .map_err(|e| ParamStoreError::InvalidSchema(format!("failed to open file: {e}")))?;
1362
1363    let reader = ParquetRecordBatchReaderBuilder::try_new(file)?.build()?;
1364    let mut batches: Vec<RecordBatch> = Vec::new();
1365    for batch_result in reader {
1366        batches.push(batch_result?);
1367    }
1368
1369    if batches.is_empty() {
1370        return Ok(Some(CognitiveParameterStore::new()));
1371    }
1372
1373    // Concatenate all batches (typically just one)
1374    if batches.len() == 1 {
1375        return Ok(Some(CognitiveParameterStore::from_batch(
1376            batches.into_iter().next().unwrap(),
1377        )?));
1378    }
1379    let schema = batches[0].schema();
1380    let combined = arrow::compute::concat_batches(&schema, &batches)?;
1381    Ok(Some(CognitiveParameterStore::from_batch(combined)?))
1382}
1383
1384// ── Tests ──────────────────────────────────────────────────────────────
1385
1386#[cfg(test)]
1387mod tests {
1388    use super::*;
1389
1390    #[test]
1391    fn test_schema_has_correct_columns() {
1392        let schema = cognitive_params_schema();
1393        assert_eq!(schema.fields().len(), 8);
1394        assert_eq!(schema.field(param_col::PARAM_ID).name(), "param_id");
1395        assert_eq!(schema.field(param_col::CATEGORY).name(), "category");
1396        assert_eq!(schema.field(param_col::VALUE_F64).name(), "value_f64");
1397        assert_eq!(schema.field(param_col::VALUE_STR).name(), "value_str");
1398        assert_eq!(schema.field(param_col::MIN_BOUND).name(), "min_bound");
1399        assert_eq!(schema.field(param_col::MAX_BOUND).name(), "max_bound");
1400        assert_eq!(
1401            schema.field(param_col::AUTONOMY_TIER).name(),
1402            "autonomy_tier"
1403        );
1404        assert_eq!(schema.field(param_col::MODIFIED_BY).name(), "modified_by");
1405    }
1406
1407    #[test]
1408    fn test_empty_store() {
1409        let store = CognitiveParameterStore::new();
1410        assert!(store.is_empty());
1411        assert_eq!(store.len(), 0);
1412        assert!(store.get("anything").is_none());
1413        assert!(store.list(category::SIGNAL_WEIGHT).is_empty());
1414    }
1415
1416    #[test]
1417    fn test_insert_and_get() {
1418        let mut store = CognitiveParameterStore::new();
1419        let param = CognitiveParameter {
1420            param_id: "signal.novelty.weight".into(),
1421            category: category::SIGNAL_WEIGHT.into(),
1422            value_f64: Some(0.15),
1423            value_str: None,
1424            min_bound: Some(0.0),
1425            max_bound: Some(1.0),
1426            autonomy_tier: AutonomyTier::Auto,
1427            modified_by: "init".into(),
1428        };
1429        store.insert(&param).unwrap();
1430
1431        assert_eq!(store.len(), 1);
1432        let got = store.get("signal.novelty.weight").unwrap();
1433        assert_eq!(got.value_f64, Some(0.15));
1434        assert_eq!(got.category, category::SIGNAL_WEIGHT);
1435        assert_eq!(got.autonomy_tier, AutonomyTier::Auto);
1436    }
1437
1438    #[test]
1439    fn test_insert_replaces_existing() {
1440        let mut store = CognitiveParameterStore::new();
1441        let p1 = CognitiveParameter {
1442            param_id: "test.param".into(),
1443            category: category::THRESHOLD.into(),
1444            value_f64: Some(1.0),
1445            value_str: None,
1446            min_bound: None,
1447            max_bound: None,
1448            autonomy_tier: AutonomyTier::Auto,
1449            modified_by: "init".into(),
1450        };
1451        store.insert(&p1).unwrap();
1452        assert_eq!(store.len(), 1);
1453
1454        let p2 = CognitiveParameter {
1455            value_f64: Some(2.0),
1456            modified_by: "hdd_loop".into(),
1457            ..p1.clone()
1458        };
1459        store.insert(&p2).unwrap();
1460        assert_eq!(store.len(), 1);
1461        assert_eq!(store.get("test.param").unwrap().value_f64, Some(2.0));
1462        assert_eq!(store.get("test.param").unwrap().modified_by, "hdd_loop");
1463    }
1464
1465    #[test]
1466    fn test_set_with_bounds_checking() {
1467        let mut store = CognitiveParameterStore::new();
1468        let param = CognitiveParameter {
1469            param_id: "bounded.param".into(),
1470            category: category::THRESHOLD.into(),
1471            value_f64: Some(0.5),
1472            value_str: None,
1473            min_bound: Some(0.0),
1474            max_bound: Some(1.0),
1475            autonomy_tier: AutonomyTier::Auto,
1476            modified_by: "init".into(),
1477        };
1478        store.insert(&param).unwrap();
1479
1480        // Valid update
1481        store
1482            .set("bounded.param", 0.8, "hdd_loop", AutonomyTier::Auto)
1483            .unwrap();
1484        assert_eq!(store.get("bounded.param").unwrap().value_f64, Some(0.8));
1485
1486        // Out of bounds — too high
1487        let err = store
1488            .set("bounded.param", 1.5, "hdd_loop", AutonomyTier::Auto)
1489            .unwrap_err();
1490        assert!(matches!(err, ParamStoreError::OutOfBounds { .. }));
1491
1492        // Out of bounds — too low
1493        let err = store
1494            .set("bounded.param", -0.1, "hdd_loop", AutonomyTier::Auto)
1495            .unwrap_err();
1496        assert!(matches!(err, ParamStoreError::OutOfBounds { .. }));
1497
1498        // Value unchanged after failed set
1499        assert_eq!(store.get("bounded.param").unwrap().value_f64, Some(0.8));
1500    }
1501
1502    #[test]
1503    fn test_set_not_found() {
1504        let mut store = CognitiveParameterStore::new();
1505        let err = store
1506            .set("nonexistent", 1.0, "test", AutonomyTier::Auto)
1507            .unwrap_err();
1508        assert!(matches!(err, ParamStoreError::NotFound(_)));
1509    }
1510
1511    #[test]
1512    fn test_autonomy_tier_enforcement() {
1513        let mut store = CognitiveParameterStore::new();
1514        let param = CognitiveParameter {
1515            param_id: "captain.safety_rule".into(),
1516            category: category::THRESHOLD.into(),
1517            value_f64: Some(0.5),
1518            value_str: None,
1519            min_bound: Some(0.0),
1520            max_bound: Some(1.0),
1521            autonomy_tier: AutonomyTier::CaptainOnly,
1522            modified_by: "init".into(),
1523        };
1524        store.insert(&param).unwrap();
1525
1526        // Tier 1 (Auto) cannot modify Tier 3 (CaptainOnly)
1527        let err = store
1528            .set("captain.safety_rule", 0.8, "hdd_loop", AutonomyTier::Auto)
1529            .unwrap_err();
1530        assert!(matches!(err, ParamStoreError::TierViolation { .. }));
1531
1532        // Tier 2 (BeingApproved) cannot modify Tier 3
1533        let err = store
1534            .set(
1535                "captain.safety_rule",
1536                0.8,
1537                "being",
1538                AutonomyTier::BeingApproved,
1539            )
1540            .unwrap_err();
1541        assert!(matches!(err, ParamStoreError::TierViolation { .. }));
1542
1543        // Tier 3 (CaptainOnly) can modify Tier 3
1544        store
1545            .set(
1546                "captain.safety_rule",
1547                0.8,
1548                "captain",
1549                AutonomyTier::CaptainOnly,
1550            )
1551            .unwrap();
1552        assert_eq!(
1553            store.get("captain.safety_rule").unwrap().value_f64,
1554            Some(0.8)
1555        );
1556    }
1557
1558    #[test]
1559    fn test_list_by_category() {
1560        let mut store = CognitiveParameterStore::new();
1561
1562        let sw = |id: &str, val: f64| CognitiveParameter {
1563            param_id: id.into(),
1564            category: category::SIGNAL_WEIGHT.into(),
1565            value_f64: Some(val),
1566            value_str: None,
1567            min_bound: None,
1568            max_bound: None,
1569            autonomy_tier: AutonomyTier::Auto,
1570            modified_by: "init".into(),
1571        };
1572
1573        store.insert(&sw("w1", 0.1)).unwrap();
1574        store.insert(&sw("w2", 0.2)).unwrap();
1575        store
1576            .insert(&CognitiveParameter {
1577                param_id: "t1".into(),
1578                category: category::THRESHOLD.into(),
1579                value_f64: Some(0.5),
1580                value_str: None,
1581                min_bound: None,
1582                max_bound: None,
1583                autonomy_tier: AutonomyTier::Auto,
1584                modified_by: "init".into(),
1585            })
1586            .unwrap();
1587
1588        let weights = store.list(category::SIGNAL_WEIGHT);
1589        assert_eq!(weights.len(), 2);
1590
1591        let thresholds = store.list(category::THRESHOLD);
1592        assert_eq!(thresholds.len(), 1);
1593
1594        let all = store.list_all();
1595        assert_eq!(all.len(), 3);
1596    }
1597
1598    #[test]
1599    fn test_default_params_populated() {
1600        let store = default_cognitive_params();
1601        assert!(!store.is_empty());
1602
1603        // Verify learning config params exist
1604        let lr = store.get("learning.initial_learning_rate").unwrap();
1605        assert_eq!(lr.value_f64, Some(0.1));
1606        assert_eq!(lr.category, category::LEARNING_CONFIG);
1607        assert_eq!(lr.autonomy_tier, AutonomyTier::Auto);
1608        assert_eq!(lr.min_bound, Some(0.001));
1609        assert_eq!(lr.max_bound, Some(1.0));
1610
1611        // Verify threshold params
1612        let softmax = store.get("fusion.softmax_temperature").unwrap();
1613        assert_eq!(softmax.value_f64, Some(1.0));
1614
1615        // Verify schema match params (Tier 1 Auto for HDD-tunable thresholds)
1616        let assim = store.get("schema_match.assimilate_threshold").unwrap();
1617        assert_eq!(assim.autonomy_tier, AutonomyTier::Auto);
1618        // novelty_high_threshold stays Tier 2
1619        let novelty = store.get("schema_match.novelty_high_threshold").unwrap();
1620        assert_eq!(novelty.autonomy_tier, AutonomyTier::BeingApproved);
1621
1622        // Verify trigger params
1623        let min_triples = store.get("consolidation.min_triples_for_training").unwrap();
1624        assert_eq!(min_triples.value_f64, Some(200.0));
1625        assert_eq!(min_triples.autonomy_tier, AutonomyTier::BeingApproved);
1626    }
1627
1628    #[test]
1629    fn test_default_params_bounds_enforced() {
1630        let mut store = default_cognitive_params();
1631
1632        // Try to set learning rate out of bounds
1633        let err = store
1634            .set(
1635                "learning.initial_learning_rate",
1636                5.0,
1637                "test",
1638                AutonomyTier::Auto,
1639            )
1640            .unwrap_err();
1641        assert!(matches!(err, ParamStoreError::OutOfBounds { .. }));
1642
1643        // Valid update within bounds
1644        store
1645            .set(
1646                "learning.initial_learning_rate",
1647                0.5,
1648                "hdd_loop",
1649                AutonomyTier::Auto,
1650            )
1651            .unwrap();
1652        assert_eq!(
1653            store
1654                .get("learning.initial_learning_rate")
1655                .unwrap()
1656                .value_f64,
1657            Some(0.5)
1658        );
1659    }
1660
1661    #[test]
1662    fn test_from_batch_validates_schema() {
1663        // Wrong number of columns
1664        let bad_schema = Arc::new(Schema::new(vec![
1665            Field::new("a", DataType::Utf8, false),
1666            Field::new("b", DataType::Utf8, false),
1667        ]));
1668        let bad_batch = RecordBatch::try_new(
1669            bad_schema,
1670            vec![
1671                Arc::new(StringArray::from(vec!["x"])),
1672                Arc::new(StringArray::from(vec!["y"])),
1673            ],
1674        )
1675        .unwrap();
1676
1677        let err = CognitiveParameterStore::from_batch(bad_batch).unwrap_err();
1678        assert!(matches!(err, ParamStoreError::InvalidSchema(_)));
1679    }
1680
1681    #[test]
1682    fn test_snapshot_roundtrip() {
1683        let mut store = CognitiveParameterStore::new();
1684        let param = CognitiveParameter {
1685            param_id: "test.roundtrip".into(),
1686            category: category::THRESHOLD.into(),
1687            value_f64: Some(0.42),
1688            value_str: Some("test_value".into()),
1689            min_bound: Some(0.0),
1690            max_bound: Some(1.0),
1691            autonomy_tier: AutonomyTier::BeingApproved,
1692            modified_by: "test".into(),
1693        };
1694        store.insert(&param).unwrap();
1695
1696        // Snapshot and recreate
1697        let batch = store.snapshot().clone();
1698        let store2 = CognitiveParameterStore::from_batch(batch).unwrap();
1699        let got = store2.get("test.roundtrip").unwrap();
1700        assert_eq!(got.value_f64, Some(0.42));
1701        assert_eq!(got.value_str.as_deref(), Some("test_value"));
1702        assert_eq!(got.autonomy_tier, AutonomyTier::BeingApproved);
1703    }
1704
1705    #[test]
1706    fn test_parquet_roundtrip() {
1707        let store = default_cognitive_params();
1708        let original_count = store.len();
1709
1710        let dir = tempfile::tempdir().unwrap();
1711        let path = dir.path().join("self_params.parquet");
1712
1713        // Save
1714        save_params_to_parquet(&store, &path).unwrap();
1715        assert!(path.exists());
1716
1717        // Load
1718        let loaded = load_params_from_parquet(&path).unwrap().unwrap();
1719        assert_eq!(loaded.len(), original_count);
1720
1721        // Verify a specific parameter survived
1722        let lr = loaded.get("learning.initial_learning_rate").unwrap();
1723        assert_eq!(lr.value_f64, Some(0.1));
1724        assert_eq!(lr.min_bound, Some(0.001));
1725        assert_eq!(lr.autonomy_tier, AutonomyTier::Auto);
1726    }
1727
1728    #[test]
1729    fn test_parquet_load_missing_file() {
1730        let result =
1731            load_params_from_parquet(std::path::Path::new("/nonexistent/params.parquet")).unwrap();
1732        assert!(result.is_none());
1733    }
1734
1735    #[test]
1736    fn test_value_str_parameter() {
1737        let mut store = CognitiveParameterStore::new();
1738        let param = CognitiveParameter {
1739            param_id: "fusion.default_path".into(),
1740            category: category::THRESHOLD.into(),
1741            value_f64: None,
1742            value_str: Some("STANDARD".into()),
1743            min_bound: None,
1744            max_bound: None,
1745            autonomy_tier: AutonomyTier::Auto,
1746            modified_by: "init".into(),
1747        };
1748        store.insert(&param).unwrap();
1749
1750        let got = store.get("fusion.default_path").unwrap();
1751        assert!(got.value_f64.is_none());
1752        assert_eq!(got.value_str.as_deref(), Some("STANDARD"));
1753    }
1754
1755    #[test]
1756    fn test_autonomy_tier_ordering() {
1757        assert!(AutonomyTier::Auto < AutonomyTier::BeingApproved);
1758        assert!(AutonomyTier::BeingApproved < AutonomyTier::CaptainOnly);
1759    }
1760
1761    #[test]
1762    fn test_set_updates_modified_by() {
1763        let mut store = CognitiveParameterStore::new();
1764        let param = CognitiveParameter {
1765            param_id: "track.modifier".into(),
1766            category: category::THRESHOLD.into(),
1767            value_f64: Some(0.5),
1768            value_str: None,
1769            min_bound: Some(0.0),
1770            max_bound: Some(1.0),
1771            autonomy_tier: AutonomyTier::Auto,
1772            modified_by: "init".into(),
1773        };
1774        store.insert(&param).unwrap();
1775        assert_eq!(store.get("track.modifier").unwrap().modified_by, "init");
1776
1777        store
1778            .set("track.modifier", 0.7, "hdd_loop_v2", AutonomyTier::Auto)
1779            .unwrap();
1780        assert_eq!(
1781            store.get("track.modifier").unwrap().modified_by,
1782            "hdd_loop_v2"
1783        );
1784    }
1785
1786    #[test]
1787    fn test_unbounded_param_accepts_any_value() {
1788        let mut store = CognitiveParameterStore::new();
1789        let param = CognitiveParameter {
1790            param_id: "unbounded.param".into(),
1791            category: category::THRESHOLD.into(),
1792            value_f64: Some(0.0),
1793            value_str: None,
1794            min_bound: None,
1795            max_bound: None,
1796            autonomy_tier: AutonomyTier::Auto,
1797            modified_by: "init".into(),
1798        };
1799        store.insert(&param).unwrap();
1800
1801        // No bounds → any value accepted
1802        store
1803            .set("unbounded.param", 999.0, "test", AutonomyTier::Auto)
1804            .unwrap();
1805        assert_eq!(store.get("unbounded.param").unwrap().value_f64, Some(999.0));
1806
1807        store
1808            .set("unbounded.param", -999.0, "test", AutonomyTier::Auto)
1809            .unwrap();
1810        assert_eq!(
1811            store.get("unbounded.param").unwrap().value_f64,
1812            Some(-999.0)
1813        );
1814    }
1815
1816    // ── Signal weight tests ──────────────────────────────────────────
1817
1818    #[test]
1819    fn test_insert_signal_weight() {
1820        let mut store = CognitiveParameterStore::new();
1821        store
1822            .insert_signal_weight("fractal_confidence", "FAST", 0.9)
1823            .unwrap();
1824
1825        let val = store.get_signal_weight("fractal_confidence", "FAST");
1826        assert_eq!(val, Some(0.9));
1827
1828        // Verify underlying param_id format
1829        let param = store.get("sw.fractal_confidence.FAST").unwrap();
1830        assert_eq!(param.category, category::SIGNAL_WEIGHT);
1831        assert_eq!(param.min_bound, Some(-1.5));
1832        assert_eq!(param.max_bound, Some(1.5));
1833        assert_eq!(param.autonomy_tier, AutonomyTier::Auto);
1834    }
1835
1836    #[test]
1837    fn test_signal_weights_list() {
1838        let mut store = CognitiveParameterStore::new();
1839        store
1840            .insert_signal_weight("fractal_confidence", "FAST", 0.9)
1841            .unwrap();
1842        store
1843            .insert_signal_weight("fractal_confidence", "DEEP", -0.5)
1844            .unwrap();
1845        store
1846            .insert_signal_weight("novelty_surprise", "STANDARD", 0.1)
1847            .unwrap();
1848
1849        let weights = store.signal_weights();
1850        assert_eq!(weights.len(), 3);
1851        assert_eq!(store.signal_weight_count(), 3);
1852
1853        // Verify content
1854        let fractal_fast = weights
1855            .iter()
1856            .find(|(d, p, _)| d == "fractal_confidence" && p == "FAST");
1857        assert!(fractal_fast.is_some());
1858        assert!((fractal_fast.unwrap().2 - 0.9).abs() < 1e-10);
1859    }
1860
1861    #[test]
1862    fn test_default_signal_weights() {
1863        let store = default_signal_weights();
1864        let count = store.signal_weight_count();
1865        assert!(
1866            count >= 140,
1867            "should have >= 140 signal weight entries, got {}",
1868            count
1869        );
1870
1871        // Spot-check known values
1872        assert_eq!(
1873            store.get_signal_weight("fractal_confidence", "FAST"),
1874            Some(0.9)
1875        );
1876        assert_eq!(
1877            store.get_signal_weight("fractal_confidence", "DEEP"),
1878            Some(-0.5)
1879        );
1880        assert_eq!(
1881            store.get_signal_weight("tool_needed", "TOOL_USE"),
1882            Some(0.9)
1883        );
1884        assert_eq!(store.get_signal_weight("recency_weight", "FAST"), Some(0.3));
1885    }
1886
1887    #[test]
1888    fn test_default_params_includes_signal_weights() {
1889        let store = default_cognitive_params();
1890
1891        // Should contain both learning config AND signal weights
1892        let sw_count = store.signal_weight_count();
1893        assert!(
1894            sw_count >= 140,
1895            "default_cognitive_params should include signal weights, got {}",
1896            sw_count
1897        );
1898
1899        // Learning config should still be there
1900        let lr = store.get("learning.initial_learning_rate");
1901        assert!(lr.is_some());
1902
1903        // Signal weight should also be there
1904        let sw = store.get_signal_weight("fractal_confidence", "FAST");
1905        assert_eq!(sw, Some(0.9));
1906    }
1907
1908    #[test]
1909    fn test_signal_weight_bounds_enforced() {
1910        let mut store = CognitiveParameterStore::new();
1911
1912        // Insert a weight — value should be clipped to [-1.5, 1.5]
1913        store.insert_signal_weight("test_dim", "FAST", 2.0).unwrap();
1914        assert_eq!(
1915            store.get_signal_weight("test_dim", "FAST"),
1916            Some(1.5),
1917            "value above 1.5 should be clipped"
1918        );
1919
1920        store
1921            .insert_signal_weight("test_dim2", "FAST", -3.0)
1922            .unwrap();
1923        assert_eq!(
1924            store.get_signal_weight("test_dim2", "FAST"),
1925            Some(-1.5),
1926            "value below -1.5 should be clipped"
1927        );
1928
1929        // Setting via `set()` should also enforce bounds
1930        let err = store
1931            .set("sw.test_dim.FAST", 2.0, "test", AutonomyTier::Auto)
1932            .unwrap_err();
1933        assert!(matches!(err, ParamStoreError::OutOfBounds { .. }));
1934    }
1935
1936    #[test]
1937    fn test_signal_weight_parquet_roundtrip() {
1938        let store = default_signal_weights();
1939        let original_count = store.signal_weight_count();
1940        assert!(original_count > 0);
1941
1942        let dir = tempfile::tempdir().unwrap();
1943        let path = dir.path().join("signal_weights.parquet");
1944
1945        // Save
1946        save_params_to_parquet(&store, &path).unwrap();
1947        assert!(path.exists());
1948
1949        // Load
1950        let loaded = load_params_from_parquet(&path).unwrap().unwrap();
1951        assert_eq!(loaded.signal_weight_count(), original_count);
1952
1953        // Spot-check a value survived
1954        assert_eq!(
1955            loaded.get_signal_weight("fractal_confidence", "FAST"),
1956            Some(0.9)
1957        );
1958        assert_eq!(
1959            loaded.get_signal_weight("tool_needed", "TOOL_USE"),
1960            Some(0.9)
1961        );
1962    }
1963
1964    // ── Loss coefficient tests ───────────────────────────────────────
1965
1966    #[test]
1967    fn test_insert_loss_coefficient() {
1968        let mut store = CognitiveParameterStore::new();
1969        store.insert_loss_coefficient("alpha_lm", 1.0).unwrap();
1970
1971        assert_eq!(store.get_loss_coefficient("alpha_lm"), Some(1.0));
1972    }
1973
1974    #[test]
1975    fn test_loss_coefficients_list() {
1976        let mut store = CognitiveParameterStore::new();
1977        store.insert_loss_coefficient("alpha_lm", 1.0).unwrap();
1978        store.insert_loss_coefficient("alpha_rel", 0.1).unwrap();
1979        store.insert_loss_coefficient("alpha_causal", 0.05).unwrap();
1980
1981        let coeffs = store.loss_coefficients();
1982        assert_eq!(coeffs.len(), 3);
1983    }
1984
1985    #[test]
1986    fn test_default_loss_coefficients_present() {
1987        let store = default_cognitive_params();
1988        assert_eq!(store.get_loss_coefficient("alpha_lm"), Some(1.0));
1989        assert_eq!(store.get_loss_coefficient("alpha_rel"), Some(0.1));
1990        assert_eq!(store.get_loss_coefficient("alpha_causal"), Some(0.05));
1991        assert_eq!(store.get_loss_coefficient("rel_warmup_fraction"), Some(0.1));
1992        assert_eq!(
1993            store.get_loss_coefficient("causal_warmup_fraction"),
1994            Some(0.2)
1995        );
1996    }
1997
1998    #[test]
1999    fn test_loss_coefficients_are_tier_auto() {
2000        let store = default_cognitive_params();
2001        let param = store.get("loss.alpha_lm").unwrap();
2002        assert_eq!(param.autonomy_tier, AutonomyTier::Auto);
2003    }
2004
2005    // ── Consolidation trigger tests ──────────────────────────────────
2006
2007    #[test]
2008    fn test_insert_consolidation_trigger() {
2009        let mut store = CognitiveParameterStore::new();
2010        store
2011            .insert_consolidation_trigger("dedup_threshold", 0.95)
2012            .unwrap();
2013
2014        assert_eq!(
2015            store.get_consolidation_trigger("dedup_threshold"),
2016            Some(0.95)
2017        );
2018    }
2019
2020    #[test]
2021    fn test_consolidation_triggers_list() {
2022        let mut store = CognitiveParameterStore::new();
2023        store
2024            .insert_consolidation_trigger("dedup_threshold", 0.95)
2025            .unwrap();
2026        store
2027            .insert_consolidation_trigger("max_triples_per_cycle", 500.0)
2028            .unwrap();
2029
2030        let triggers = store.consolidation_triggers();
2031        assert_eq!(triggers.len(), 2);
2032    }
2033
2034    #[test]
2035    fn test_default_consolidation_triggers_present() {
2036        let store = default_cognitive_params();
2037        assert_eq!(
2038            store.get_consolidation_trigger("dedup_threshold"),
2039            Some(0.95)
2040        );
2041        assert_eq!(
2042            store.get_consolidation_trigger("max_triples_per_cycle"),
2043            Some(500.0)
2044        );
2045        assert_eq!(
2046            store.get_consolidation_trigger("min_promoted_triples"),
2047            Some(50.0)
2048        );
2049        assert_eq!(
2050            store.get_consolidation_trigger("training_threshold"),
2051            Some(200.0)
2052        );
2053    }
2054
2055    #[test]
2056    fn test_consolidation_triggers_are_tier_being_approved() {
2057        let store = default_cognitive_params();
2058        let param = store.get("consol.dedup_threshold").unwrap();
2059        assert_eq!(param.autonomy_tier, AutonomyTier::BeingApproved);
2060    }
2061
2062    #[test]
2063    fn test_loss_and_consolidation_parquet_roundtrip() {
2064        let store = default_cognitive_params();
2065        let original_loss_count = store.loss_coefficients().len();
2066        let original_trigger_count = store.consolidation_triggers().len();
2067
2068        let dir = tempfile::tempdir().unwrap();
2069        let path = dir.path().join("params.parquet");
2070
2071        save_params_to_parquet(&store, &path).unwrap();
2072        let loaded = load_params_from_parquet(&path).unwrap().unwrap();
2073
2074        assert_eq!(loaded.loss_coefficients().len(), original_loss_count);
2075        assert_eq!(
2076            loaded.consolidation_triggers().len(),
2077            original_trigger_count
2078        );
2079        assert_eq!(loaded.get_loss_coefficient("alpha_lm"), Some(1.0));
2080        assert_eq!(
2081            loaded.get_consolidation_trigger("dedup_threshold"),
2082            Some(0.95)
2083        );
2084    }
2085}