Skip to main content

khive_brain_core/
brain_state.rs

1//! BrainState — profile registry, resolution, and snapshot.
2
3use std::collections::HashMap;
4
5use serde::{Deserialize, Serialize};
6
7use crate::profile::{
8    BalancedRecallSnapshot, BalancedRecallState, ProfileBinding, ProfileLifecycle, ProfileRecord,
9};
10use crate::section_state::{SectionPosteriorSnapshot, SectionPosteriorState};
11
12/// Sort a slice of profile candidates in ascending `(created_at, id)` order.
13///
14/// This is the canonical fallback selection key: earliest creation time wins,
15/// with alphabetical `id` as a tiebreaker. See
16/// crates/khive-brain-core/docs/design-rationale.md#sort_fallback_candidates--why-its-a-standalone-helper
17/// for why this is a standalone helper rather than inlined.
18pub fn sort_fallback_candidates(candidates: &mut [&ProfileRecord]) {
19    candidates.sort_by(|a, b| a.created_at.cmp(&b.created_at).then(a.id.cmp(&b.id)));
20}
21
22/// Runtime brain state — profile registry + active state per profile.
23pub struct BrainState {
24    pub profiles: HashMap<String, ProfileRecord>,
25    pub balanced_recall: BalancedRecallState,
26    pub profile_states: HashMap<String, BalancedRecallState>,
27    pub bindings: Vec<ProfileBinding>,
28    pub section_states: HashMap<String, SectionPosteriorState>,
29    pub router_state: HashMap<String, RouterStateBlob>,
30    pub adapter_set: HashMap<String, Vec<AdapterRecord>>,
31}
32
33impl BrainState {
34    /// Create a fresh `BrainState` with a single default `balanced-recall-v1` profile.
35    pub fn new(entity_capacity: usize) -> Self {
36        let mut profiles = HashMap::new();
37        let record = ProfileRecord::new_balanced_recall(entity_capacity);
38        let profile_id = record.id.clone();
39        profiles.insert(profile_id, record);
40
41        Self {
42            profiles,
43            balanced_recall: BalancedRecallState::new(entity_capacity),
44            profile_states: HashMap::new(),
45            bindings: Vec::new(),
46            section_states: HashMap::new(),
47            router_state: HashMap::new(),
48            adapter_set: HashMap::new(),
49        }
50    }
51
52    /// Serialize the current state to a `BrainStateSnapshot` for persistence.
53    pub fn to_snapshot(&self) -> BrainStateSnapshot {
54        let extra: HashMap<String, BalancedRecallSnapshot> = self
55            .profile_states
56            .iter()
57            .map(|(id, s)| (id.clone(), s.to_snapshot()))
58            .collect();
59        let section_states: HashMap<String, SectionPosteriorSnapshot> = self
60            .section_states
61            .iter()
62            .map(|(id, s)| (id.clone(), s.to_snapshot()))
63            .collect();
64        BrainStateSnapshot {
65            profiles: self.profiles.clone(),
66            balanced_recall: self.balanced_recall.to_snapshot(),
67            profile_states: extra,
68            bindings: self.bindings.clone(),
69            section_states,
70            router_state: self.router_state.clone(),
71            adapter_set: self.adapter_set.clone(),
72        }
73    }
74
75    /// Rebuild live state from a persisted snapshot.
76    pub fn from_snapshot(snapshot: BrainStateSnapshot, entity_capacity: usize) -> Self {
77        let extra: HashMap<String, BalancedRecallState> = snapshot
78            .profile_states
79            .into_iter()
80            .map(|(id, s)| (id, BalancedRecallState::from_snapshot(s, entity_capacity)))
81            .collect();
82        let section_states: HashMap<String, SectionPosteriorState> = snapshot
83            .section_states
84            .into_iter()
85            .map(|(id, s)| (id, SectionPosteriorState::from_snapshot(s)))
86            .collect();
87        Self {
88            profiles: snapshot.profiles,
89            balanced_recall: BalancedRecallState::from_snapshot(
90                snapshot.balanced_recall,
91                entity_capacity,
92            ),
93            profile_states: extra,
94            bindings: snapshot.bindings,
95            section_states,
96            router_state: snapshot.router_state,
97            adapter_set: snapshot.adapter_set,
98        }
99    }
100
101    /// Reset all posteriors to their prior values and bump the exploration epoch.
102    pub fn reset_posteriors(&mut self) {
103        self.balanced_recall.reset_posteriors();
104        if let Some(record) = self.profiles.get_mut("balanced-recall-v1") {
105            record.exploration_epoch = self.balanced_recall.exploration_epoch;
106            record.state_snapshot = serde_json::to_value(self.balanced_recall.to_snapshot()).ok();
107        }
108        if let Some(ss) = self.section_states.get_mut("balanced-recall-v1") {
109            ss.reset_posteriors();
110        }
111    }
112
113    /// Reset posteriors for a single named profile, leaving other profiles unchanged.
114    pub fn reset_profile_posteriors(&mut self, profile_id: &str) {
115        if let Some(ps) = self.profile_states.get_mut(profile_id) {
116            ps.reset_posteriors();
117            let snap = serde_json::to_value(ps.to_snapshot()).ok();
118            let epoch = ps.exploration_epoch;
119            if let Some(record) = self.profiles.get_mut(profile_id) {
120                record.exploration_epoch = epoch;
121                record.state_snapshot = snap;
122            }
123        }
124        if let Some(ss) = self.section_states.get_mut(profile_id) {
125            ss.reset_posteriors();
126        }
127    }
128
129    /// Resolve which `ProfileRecord` serves a given (actor, namespace, consumer_kind) triple.
130    pub fn resolve(
131        &self,
132        actor: Option<&str>,
133        namespace: Option<&str>,
134        consumer_kind: &str,
135    ) -> Option<&ProfileRecord> {
136        self.resolve_with_match(actor, namespace, consumer_kind)
137            .map(|(record, _, _)| record)
138    }
139
140    /// Resolve a profile for the given context, returning the matched record,
141    /// the matched consumer_kind, and whether the result came from an explicit
142    /// binding (`matched_binding = true`) vs. a system-default fallback
143    /// (`matched_binding = false`).
144    ///
145    /// Callers that implement the ADR-035 tier-2 / tier-3 split MUST check
146    /// `matched_binding`: only a `true` result constitutes a real tier-2 hit.
147    /// When `false`, the caller should fall through to tier-3 (pack-local prior).
148    pub fn resolve_with_match(
149        &self,
150        actor: Option<&str>,
151        namespace: Option<&str>,
152        consumer_kind: &str,
153    ) -> Option<(&ProfileRecord, String, bool)> {
154        let actor_val = actor.unwrap_or("*");
155        let namespace_val = namespace.unwrap_or("*");
156
157        let best = self
158            .bindings
159            .iter()
160            .filter(|b| {
161                (b.actor == "*" || b.actor == actor_val)
162                    && (b.namespace == "*" || b.namespace == namespace_val)
163                    && (b.consumer_kind == "*" || b.consumer_kind == consumer_kind)
164                    && self
165                        .profiles
166                        .get(&b.profile_id)
167                        .is_some_and(|p| p.lifecycle != ProfileLifecycle::Archived)
168            })
169            .max_by_key(|b| {
170                let actor_score = if b.actor != "*" { 4 } else { 0 };
171                let ns_score = if b.namespace != "*" { 2 } else { 0 };
172                let kind_score = if b.consumer_kind != "*" { 1 } else { 0 };
173                (
174                    actor_score + ns_score + kind_score,
175                    b.priority,
176                    -(b.created_at.timestamp()),
177                )
178            });
179
180        if let Some(binding) = best {
181            if let Some(record) = self.profiles.get(&binding.profile_id) {
182                // matched_binding = true: came from an explicit binding row.
183                return Some((record, binding.consumer_kind.clone(), true));
184            }
185        }
186
187        if let Some(default) = self.profiles.get("balanced-recall-v1") {
188            if default.lifecycle == ProfileLifecycle::Active
189                && (default.consumer_kind == consumer_kind
190                    || consumer_kind == "*"
191                    || default.consumer_kind == "*")
192            {
193                // matched_binding = false: system-default fallback, not a binding match.
194                return Some((default, default.consumer_kind.clone(), false));
195            }
196        }
197
198        // Sort by (created_at, id) before selecting so the fallback profile is
199        // deterministic across processes regardless of HashMap's randomised seed.
200        let mut candidates: Vec<&ProfileRecord> = self
201            .profiles
202            .values()
203            .filter(|p| p.consumer_kind == consumer_kind && p.lifecycle == ProfileLifecycle::Active)
204            .collect();
205        sort_fallback_candidates(&mut candidates);
206        candidates
207            .into_iter()
208            .next()
209            // matched_binding = false: active-profile scan fallback, not a binding match.
210            .map(|p| (p, p.consumer_kind.clone(), false))
211    }
212}
213
214// ── Snapshot ────────────────────────────────────────────────────────────────
215
216/// Opaque envelope for gate-engine router weights. Brain stores and
217/// round-trips the bytes without parsing them; the gate engine owns
218/// the internal layout.
219#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
220pub struct RouterStateBlob {
221    pub schema_version: u32,
222    /// Raw serialized gate weights. Treated as opaque bytes by brain.
223    pub gate_bytes: Vec<u8>,
224}
225
226/// Brain-native integrity record for a single LoRA adapter slot.
227#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
228pub struct AdapterRecord {
229    pub adapter_id: String,
230    pub slot: u32,
231    /// Content hash of the adapter checkpoint, for integrity verification.
232    pub content_hash: String,
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct BrainStateSnapshot {
237    pub profiles: HashMap<String, ProfileRecord>,
238    pub balanced_recall: BalancedRecallSnapshot,
239    #[serde(default)]
240    pub profile_states: HashMap<String, BalancedRecallSnapshot>,
241    pub bindings: Vec<ProfileBinding>,
242    #[serde(default)]
243    pub section_states: HashMap<String, SectionPosteriorSnapshot>,
244    #[serde(default)]
245    pub router_state: HashMap<String, RouterStateBlob>,
246    #[serde(default)]
247    pub adapter_set: HashMap<String, Vec<AdapterRecord>>,
248}
249
250/// Validate all BetaPosterior values in a snapshot.
251pub fn validate_brain_state_snapshot(snapshot: &BrainStateSnapshot) -> Result<(), String> {
252    let br = &snapshot.balanced_recall;
253    br.relevance
254        .validate()
255        .map_err(|e| format!("balanced_recall.relevance: {e}"))?;
256    br.salience
257        .validate()
258        .map_err(|e| format!("balanced_recall.salience: {e}"))?;
259    br.temporal
260        .validate()
261        .map_err(|e| format!("balanced_recall.temporal: {e}"))?;
262    for (id, p) in &br.entity_posteriors {
263        p.validate()
264            .map_err(|e| format!("balanced_recall.entity_posteriors[{id}]: {e}"))?;
265    }
266
267    for (pid, ps) in &snapshot.profile_states {
268        ps.relevance
269            .validate()
270            .map_err(|e| format!("profile_states[{pid}].relevance: {e}"))?;
271        ps.salience
272            .validate()
273            .map_err(|e| format!("profile_states[{pid}].salience: {e}"))?;
274        ps.temporal
275            .validate()
276            .map_err(|e| format!("profile_states[{pid}].temporal: {e}"))?;
277        for (id, p) in &ps.entity_posteriors {
278            p.validate()
279                .map_err(|e| format!("profile_states[{pid}].entity_posteriors[{id}]: {e}"))?;
280        }
281    }
282
283    for (pid, ss) in &snapshot.section_states {
284        for (st, p) in &ss.posteriors {
285            p.validate()
286                .map_err(|e| format!("section_states[{pid}].posteriors[{st:?}]: {e}"))?;
287        }
288        for (st, p) in &ss.priors {
289            p.validate()
290                .map_err(|e| format!("section_states[{pid}].priors[{st:?}]: {e}"))?;
291        }
292    }
293
294    Ok(())
295}
296
297/// Validate posterior numerics (via [`validate_brain_state_snapshot`]) plus
298/// entity-posterior cache-capacity bounds for a persisted snapshot.
299///
300/// Rejects a snapshot whose `balanced_recall` or any `profile_states` entry
301/// holds more entity posteriors than `entity_capacity`, and rejects
302/// `entity_posterior_order` values that contain duplicate ids or reference
303/// ids absent from `entity_posteriors`.
304///
305/// `entity_posteriors_version` gates how strictly `entity_posterior_order` is
306/// checked:
307/// - version `0` (legacy, pre-ordering snapshots) — order MUST be empty;
308///   restore falls back to the deterministic ascending-`Uuid` compatibility
309///   path in [`crate::posterior::EntityPosteriors::from_snapshot`].
310/// - version `1` (current) — order MUST cover every `entity_posteriors` key
311///   exactly (same length, no duplicates, no unknown ids).
312/// - any other version — rejected outright as unknown.
313///
314/// See crates/khive-brain-core/docs/api/snapshot-validation.md#validate_brain_state_snapshot_with_capacity--entity_posterior_order-version-gating-rationale
315/// for why each of these is rejected outright rather than silently repaired.
316pub fn validate_brain_state_snapshot_with_capacity(
317    snapshot: &BrainStateSnapshot,
318    entity_capacity: usize,
319) -> Result<(), String> {
320    validate_brain_state_snapshot(snapshot)?;
321
322    fn check_recall(
323        label: &str,
324        br: &BalancedRecallSnapshot,
325        entity_capacity: usize,
326    ) -> Result<(), String> {
327        if br.entity_posteriors.len() > entity_capacity {
328            return Err(format!(
329                "{label}.entity_posteriors: {} entries exceeds capacity {entity_capacity}",
330                br.entity_posteriors.len()
331            ));
332        }
333
334        if br.entity_posteriors_version > 1 {
335            return Err(format!(
336                "{label}.entity_posteriors_version: unknown version {}",
337                br.entity_posteriors_version
338            ));
339        }
340
341        if !br.entity_posterior_order.is_empty() {
342            let mut seen =
343                std::collections::HashSet::with_capacity(br.entity_posterior_order.len());
344            for id in &br.entity_posterior_order {
345                if !seen.insert(*id) {
346                    return Err(format!("{label}.entity_posterior_order: duplicate id {id}"));
347                }
348                if !br.entity_posteriors.contains_key(id) {
349                    return Err(format!(
350                        "{label}.entity_posterior_order: id {id} not present in entity_posteriors"
351                    ));
352                }
353            }
354        }
355
356        // Version 0 requires an EMPTY order; a non-empty order here is
357        // corruption, not compatibility data — see design-notes.md.
358        if br.entity_posteriors_version == 0 && !br.entity_posterior_order.is_empty() {
359            return Err(format!(
360                "{label}.entity_posterior_order: non-empty order requires entity_posteriors_version >= 1 (got version 0, the legacy empty-order compatibility version)",
361            ));
362        }
363
364        // Version >= 1 must have full order coverage — see design-notes.md.
365        if br.entity_posteriors_version >= 1
366            && br.entity_posterior_order.len() != br.entity_posteriors.len()
367        {
368            return Err(format!(
369                "{label}.entity_posterior_order: length {} does not cover all {} entity_posteriors entries (version {} requires full order coverage)",
370                br.entity_posterior_order.len(),
371                br.entity_posteriors.len(),
372                br.entity_posteriors_version,
373            ));
374        }
375
376        Ok(())
377    }
378
379    check_recall(
380        "balanced_recall",
381        &snapshot.balanced_recall,
382        entity_capacity,
383    )?;
384    for (pid, ps) in &snapshot.profile_states {
385        check_recall(&format!("profile_states[{pid}]"), ps, entity_capacity)?;
386    }
387
388    Ok(())
389}
390
391#[cfg(test)]
392mod tests {
393    use chrono::{TimeZone, Utc};
394
395    use super::*;
396    use crate::profile::ProfileLifecycle;
397
398    fn make_profile(id: &str, consumer_kind: &str, ts_secs: i64) -> ProfileRecord {
399        ProfileRecord {
400            id: id.to_owned(),
401            description: String::new(),
402            consumer_kind: consumer_kind.to_owned(),
403            state_class: "Bayesian".into(),
404            lifecycle: ProfileLifecycle::Active,
405            created_at: Utc.timestamp_opt(ts_secs, 0).unwrap(),
406            state_snapshot: None,
407            total_events: 0,
408            exploration_epoch: 0,
409        }
410    }
411
412    /// `sort_fallback_candidates` produces `(created_at ASC, id ASC)` order.
413    /// See crates/khive-brain-core/docs/testing-strategy.md#brain_staters-sort_fallback_candidates_produces_created_at_then_id_order
414    #[test]
415    fn sort_fallback_candidates_produces_created_at_then_id_order() {
416        // Three profiles inserted in DESCENDING created_at order (worst case for
417        // any unsorted implementation).
418        let p1 = make_profile("aardvark", "recall", 1_000); // earliest, lowest id
419        let p2 = make_profile("mango", "recall", 2_000);
420        let p3 = make_profile("zebra", "recall", 3_000); // latest, highest id
421
422        // Intentionally unsorted: reverse order.
423        let mut candidates = vec![&p3, &p2, &p1];
424        sort_fallback_candidates(&mut candidates);
425
426        // After sort: p1 (earliest) must be first, p3 (latest) must be last.
427        assert_eq!(
428            candidates[0].id, "aardvark",
429            "earliest profile must come first"
430        );
431        assert_eq!(candidates[1].id, "mango");
432        assert_eq!(candidates[2].id, "zebra", "latest profile must come last");
433    }
434
435    /// `sort_fallback_candidates` must break ties by `id` (ascending) when
436    /// `created_at` is equal.
437    #[test]
438    fn sort_fallback_candidates_breaks_ties_by_id() {
439        let ts = 5_000i64;
440        let p_z = make_profile("z-profile", "recall", ts);
441        let p_a = make_profile("a-profile", "recall", ts);
442        let p_m = make_profile("m-profile", "recall", ts);
443
444        // Deliberately insert in reverse alphabetical order.
445        let mut candidates = vec![&p_z, &p_m, &p_a];
446        sort_fallback_candidates(&mut candidates);
447
448        assert_eq!(
449            candidates[0].id, "a-profile",
450            "lowest id must come first on equal timestamps"
451        );
452        assert_eq!(candidates[1].id, "m-profile");
453        assert_eq!(candidates[2].id, "z-profile");
454    }
455
456    /// `BrainState::resolve` selects the earliest-created profile regardless
457    /// of HashMap insertion order (integration guard on `sort_fallback_candidates`).
458    #[test]
459    fn resolve_fallback_is_deterministic() {
460        let p_early = make_profile("alpha", "recall", 1_000);
461        let p_later = make_profile("zeta", "recall", 2_000);
462
463        let mut state_a = BrainState {
464            profiles: HashMap::new(),
465            balanced_recall: BalancedRecallState::new(8),
466            profile_states: HashMap::new(),
467            bindings: Vec::new(),
468            section_states: HashMap::new(),
469            router_state: HashMap::new(),
470            adapter_set: HashMap::new(),
471        };
472        state_a.profiles.insert(p_early.id.clone(), p_early.clone());
473        state_a.profiles.insert(p_later.id.clone(), p_later.clone());
474
475        let mut state_b = BrainState {
476            profiles: HashMap::new(),
477            balanced_recall: BalancedRecallState::new(8),
478            profile_states: HashMap::new(),
479            bindings: Vec::new(),
480            section_states: HashMap::new(),
481            router_state: HashMap::new(),
482            adapter_set: HashMap::new(),
483        };
484        // Insert in the opposite order.
485        state_b.profiles.insert(p_later.id.clone(), p_later.clone());
486        state_b.profiles.insert(p_early.id.clone(), p_early.clone());
487
488        let result_a = state_a.resolve(None, None, "recall");
489        let result_b = state_b.resolve(None, None, "recall");
490
491        let id_a = result_a.map(|p| p.id.clone());
492        let id_b = result_b.map(|p| p.id.clone());
493        assert_eq!(id_a, Some("alpha".to_owned()));
494        assert_eq!(id_a, id_b, "fallback resolution must be deterministic");
495    }
496
497    /// `router_state` and `adapter_set` survive a snapshot round-trip byte-for-byte.
498    #[test]
499    fn router_state_and_adapter_set_round_trip() {
500        let mut state = BrainState::new(8);
501
502        let blob = RouterStateBlob {
503            schema_version: 1,
504            gate_bytes: vec![1, 2, 3],
505        };
506        state
507            .router_state
508            .insert("profile-x".to_owned(), blob.clone());
509
510        let record = AdapterRecord {
511            adapter_id: "lora-42".to_owned(),
512            slot: 0,
513            content_hash: "abc123".to_owned(),
514        };
515        state
516            .adapter_set
517            .insert("profile-x".to_owned(), vec![record.clone()]);
518
519        let snapshot = state.to_snapshot();
520        let restored = BrainState::from_snapshot(snapshot, 8);
521
522        assert_eq!(
523            restored.router_state.get("profile-x"),
524            Some(&blob),
525            "router_state must survive round-trip"
526        );
527        assert_eq!(
528            restored.adapter_set.get("profile-x"),
529            Some(&vec![record]),
530            "adapter_set must survive round-trip"
531        );
532    }
533
534    /// Omitting `router_state`/`adapter_set` from JSON must deserialize to
535    /// empty maps via `#[serde(default)]`, with no migration required.
536    #[test]
537    fn snapshot_missing_router_and_adapter_fields_defaults_to_empty() {
538        let state = BrainState::new(8);
539        let snapshot = state.to_snapshot();
540
541        let mut value: serde_json::Value =
542            serde_json::to_value(&snapshot).expect("serialize snapshot");
543
544        let obj = value.as_object_mut().expect("snapshot is a JSON object");
545        obj.remove("router_state");
546        obj.remove("adapter_set");
547
548        let restored: BrainStateSnapshot =
549            serde_json::from_value(value).expect("deserialize old-format snapshot");
550
551        assert!(
552            restored.router_state.is_empty(),
553            "router_state must default to empty map when absent from JSON"
554        );
555        assert!(
556            restored.adapter_set.is_empty(),
557            "adapter_set must default to empty map when absent from JSON"
558        );
559    }
560
561    /// BRAINCORE-AUD-001 regression: oversized-snapshot restore is rejected
562    /// (bounded/rejected) at the capacity-aware validation boundary.
563    #[test]
564    fn validate_brain_state_snapshot_with_capacity_rejects_entity_posteriors_over_capacity() {
565        use uuid::Uuid;
566
567        let capacity = 2;
568        let mut state = BrainState::new(capacity);
569        for _ in 0..(capacity + 1) {
570            state
571                .balanced_recall
572                .entity_posteriors
573                .get_or_insert(Uuid::new_v4(), crate::posterior::BetaPosterior::default);
574        }
575        // Force an over-capacity snapshot directly, since live inserts already
576        // enforce the bound — this simulates a crafted/legacy oversized snapshot.
577        let mut snapshot = state.to_snapshot();
578        snapshot
579            .balanced_recall
580            .entity_posteriors
581            .insert(Uuid::new_v4(), crate::posterior::BetaPosterior::default());
582
583        let result = validate_brain_state_snapshot_with_capacity(&snapshot, capacity);
584        assert!(
585            result.is_err(),
586            "snapshot with entity_posteriors.len() > capacity must be rejected"
587        );
588    }
589
590    /// BRAINCORE-AUD-001: capacity violation in a named `profile_states`
591    /// entry (not just the default profile) must also be rejected.
592    #[test]
593    fn validate_brain_state_snapshot_with_capacity_rejects_profile_state_over_capacity() {
594        use uuid::Uuid;
595
596        let capacity = 1;
597        let mut state = BrainState::new(capacity);
598        let mut extra = BalancedRecallState::new(capacity);
599        extra
600            .entity_posteriors
601            .get_or_insert(Uuid::new_v4(), crate::posterior::BetaPosterior::default);
602        state
603            .profile_states
604            .insert("extra-profile".to_owned(), extra);
605
606        let mut snapshot = state.to_snapshot();
607        snapshot
608            .profile_states
609            .get_mut("extra-profile")
610            .unwrap()
611            .entity_posteriors
612            .insert(Uuid::new_v4(), crate::posterior::BetaPosterior::default());
613
614        let result = validate_brain_state_snapshot_with_capacity(&snapshot, capacity);
615        assert!(
616            result.is_err(),
617            "profile_states entry with entity_posteriors.len() > capacity must be rejected"
618        );
619    }
620
621    // ── entity_posterior_order strict coverage regression guard (PR #535) ──────────────
622
623    /// Build a fresh version-1 `BalancedRecallSnapshot` with `n` entity
624    /// posteriors and a fully-covering order, for mutation in the tests below.
625    fn make_versioned_recall_snapshot(capacity: usize, n: usize) -> BalancedRecallSnapshot {
626        let mut state = BalancedRecallState::new(capacity);
627        for _ in 0..n {
628            state.entity_posteriors.get_or_insert(
629                uuid::Uuid::new_v4(),
630                crate::posterior::BetaPosterior::default,
631            );
632        }
633        state.to_snapshot()
634    }
635
636    #[test]
637    fn validate_brain_state_snapshot_with_capacity_rejects_missing_order_ids() {
638        let capacity = 4;
639        let mut snapshot = make_versioned_recall_snapshot(capacity, 2);
640        assert_eq!(snapshot.entity_posteriors_version, 1);
641        assert_eq!(snapshot.entity_posterior_order.len(), 2);
642
643        // Drop one id from the order — partial coverage on a current-format
644        // (version 1) snapshot must be rejected, not silently normalized.
645        snapshot.entity_posterior_order.truncate(1);
646
647        let mut full_snapshot = BrainState::new(capacity).to_snapshot();
648        full_snapshot.balanced_recall = snapshot;
649
650        let result = validate_brain_state_snapshot_with_capacity(&full_snapshot, capacity);
651        assert!(
652            result.is_err(),
653            "version-1 snapshot with order shorter than entity_posteriors must be rejected"
654        );
655        assert!(
656            result.unwrap_err().contains("does not cover all"),
657            "error must name the missing-coverage reason"
658        );
659    }
660
661    #[test]
662    fn validate_brain_state_snapshot_with_capacity_rejects_duplicate_order_ids() {
663        let capacity = 4;
664        let mut snapshot = make_versioned_recall_snapshot(capacity, 2);
665        let dup = snapshot.entity_posterior_order[0];
666        snapshot.entity_posterior_order[1] = dup;
667
668        let mut full_snapshot = BrainState::new(capacity).to_snapshot();
669        full_snapshot.balanced_recall = snapshot;
670
671        let result = validate_brain_state_snapshot_with_capacity(&full_snapshot, capacity);
672        assert!(
673            result.is_err(),
674            "duplicate entity_posterior_order ids must be rejected"
675        );
676        assert!(result.unwrap_err().contains("duplicate id"));
677    }
678
679    #[test]
680    fn validate_brain_state_snapshot_with_capacity_rejects_unknown_order_ids() {
681        let capacity = 4;
682        let mut snapshot = make_versioned_recall_snapshot(capacity, 2);
683        snapshot.entity_posterior_order.push(uuid::Uuid::new_v4());
684
685        let mut full_snapshot = BrainState::new(capacity).to_snapshot();
686        full_snapshot.balanced_recall = snapshot;
687
688        let result = validate_brain_state_snapshot_with_capacity(&full_snapshot, capacity);
689        assert!(
690            result.is_err(),
691            "entity_posterior_order id absent from entity_posteriors must be rejected"
692        );
693        assert!(result
694            .unwrap_err()
695            .contains("not present in entity_posteriors"));
696    }
697
698    #[test]
699    fn validate_brain_state_snapshot_with_capacity_rejects_unknown_version() {
700        let capacity = 4;
701        let mut snapshot = make_versioned_recall_snapshot(capacity, 1);
702        snapshot.entity_posteriors_version = 2;
703
704        let mut full_snapshot = BrainState::new(capacity).to_snapshot();
705        full_snapshot.balanced_recall = snapshot;
706
707        let result = validate_brain_state_snapshot_with_capacity(&full_snapshot, capacity);
708        assert!(
709            result.is_err(),
710            "unknown entity_posteriors_version must be rejected"
711        );
712        assert!(result.unwrap_err().contains("unknown version"));
713    }
714
715    #[test]
716    fn validate_brain_state_snapshot_with_capacity_accepts_legacy_empty_order() {
717        let capacity = 4;
718        let mut snapshot = make_versioned_recall_snapshot(capacity, 2);
719        // Explicitly-legacy version 0 with no order metadata must still pass —
720        // restore falls back to the deterministic ascending-Uuid path.
721        snapshot.entity_posteriors_version = 0;
722        snapshot.entity_posterior_order.clear();
723
724        let mut full_snapshot = BrainState::new(capacity).to_snapshot();
725        full_snapshot.balanced_recall = snapshot;
726
727        let result = validate_brain_state_snapshot_with_capacity(&full_snapshot, capacity);
728        assert!(
729            result.is_ok(),
730            "legacy version-0 snapshot with empty order must be accepted: {result:?}"
731        );
732    }
733
734    #[test]
735    fn validate_brain_state_snapshot_with_capacity_rejects_version_zero_with_partial_order() {
736        // PR #535: version-0 with a partial order is corruption, not legacy
737        // compatibility data — see design-notes.md.
738        let capacity = 4;
739        let mut snapshot = make_versioned_recall_snapshot(capacity, 2);
740        snapshot.entity_posteriors_version = 0;
741        snapshot.entity_posterior_order.truncate(1);
742
743        let mut full_snapshot = BrainState::new(capacity).to_snapshot();
744        full_snapshot.balanced_recall = snapshot;
745
746        let result = validate_brain_state_snapshot_with_capacity(&full_snapshot, capacity);
747        assert!(
748            result.is_err(),
749            "version-0 snapshot with non-empty partial order must be rejected"
750        );
751        assert!(
752            result
753                .unwrap_err()
754                .contains("requires entity_posteriors_version >= 1"),
755            "error must name the version-0-with-non-empty-order reason"
756        );
757    }
758
759    #[test]
760    fn validate_brain_state_snapshot_with_capacity_accepts_full_order_current_version() {
761        let capacity = 4;
762        let mut full_snapshot = BrainState::new(capacity).to_snapshot();
763        full_snapshot.balanced_recall = make_versioned_recall_snapshot(capacity, 3);
764
765        let result = validate_brain_state_snapshot_with_capacity(&full_snapshot, capacity);
766        assert!(
767            result.is_ok(),
768            "version-1 snapshot with a fully-covering order must be accepted: {result:?}"
769        );
770    }
771
772    /// A validated snapshot must restore→re-snapshot→restore to the same
773    /// state — the validation gate must not itself introduce drift.
774    #[test]
775    fn restore_to_snapshot_restore_idempotency() {
776        let capacity = 4;
777        let mut state = BrainState::new(capacity);
778        for _ in 0..3 {
779            state.balanced_recall.entity_posteriors.get_or_insert(
780                uuid::Uuid::new_v4(),
781                crate::posterior::BetaPosterior::default,
782            );
783        }
784
785        let snapshot_1 = state.to_snapshot();
786        validate_brain_state_snapshot_with_capacity(&snapshot_1, capacity)
787            .expect("first snapshot must validate");
788
789        let restored_1 = BrainState::from_snapshot(snapshot_1, capacity);
790        let snapshot_2 = restored_1.to_snapshot();
791        validate_brain_state_snapshot_with_capacity(&snapshot_2, capacity)
792            .expect("round-tripped snapshot must still validate");
793
794        let restored_2 = BrainState::from_snapshot(snapshot_2.clone(), capacity);
795        let snapshot_3 = restored_2.to_snapshot();
796
797        assert_eq!(
798            snapshot_2.balanced_recall.entity_posteriors,
799            snapshot_3.balanced_recall.entity_posteriors,
800            "entity_posteriors must be stable across a second restore/snapshot cycle"
801        );
802        assert_eq!(
803            snapshot_2.balanced_recall.entity_posterior_order,
804            snapshot_3.balanced_recall.entity_posterior_order,
805            "entity_posterior_order must be stable across a second restore/snapshot cycle"
806        );
807    }
808}