Skip to main content

khive_pack_brain/
state.rs

1use std::collections::{HashMap, VecDeque};
2use std::fmt;
3use std::str::FromStr;
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9// ── SectionType ───────────────────────────────────────────────────────────────
10
11/// Knowledge-section types that the brain tracks per-profile (ADR-048).
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum SectionType {
15    Overview,
16    CoreModel,
17    BoundaryConditions,
18    Formalism,
19    OperationalGuidance,
20    Examples,
21    FailureModes,
22    ExpertLens,
23    References,
24    Other,
25}
26
27impl SectionType {
28    /// Canonical string representation (matches serde snake_case).
29    pub fn as_str(self) -> &'static str {
30        match self {
31            SectionType::Overview => "overview",
32            SectionType::CoreModel => "core_model",
33            SectionType::BoundaryConditions => "boundary_conditions",
34            SectionType::Formalism => "formalism",
35            SectionType::OperationalGuidance => "operational_guidance",
36            SectionType::Examples => "examples",
37            SectionType::FailureModes => "failure_modes",
38            SectionType::ExpertLens => "expert_lens",
39            SectionType::References => "references",
40            SectionType::Other => "other",
41        }
42    }
43
44    /// All section types in a stable canonical order.
45    pub fn all() -> &'static [SectionType] {
46        &Self::ALL
47    }
48
49    /// All section types as a const array.
50    pub const ALL: [SectionType; 10] = [
51        SectionType::Overview,
52        SectionType::CoreModel,
53        SectionType::BoundaryConditions,
54        SectionType::Formalism,
55        SectionType::OperationalGuidance,
56        SectionType::Examples,
57        SectionType::FailureModes,
58        SectionType::ExpertLens,
59        SectionType::References,
60        SectionType::Other,
61    ];
62}
63
64impl fmt::Display for SectionType {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        f.write_str(self.as_str())
67    }
68}
69
70impl FromStr for SectionType {
71    type Err = String;
72
73    fn from_str(s: &str) -> Result<Self, Self::Err> {
74        match s {
75            "overview" => Ok(SectionType::Overview),
76            "core_model" => Ok(SectionType::CoreModel),
77            "boundary_conditions" => Ok(SectionType::BoundaryConditions),
78            "formalism" => Ok(SectionType::Formalism),
79            "operational_guidance" => Ok(SectionType::OperationalGuidance),
80            "examples" => Ok(SectionType::Examples),
81            "failure_modes" => Ok(SectionType::FailureModes),
82            "expert_lens" => Ok(SectionType::ExpertLens),
83            "references" => Ok(SectionType::References),
84            "other" => Ok(SectionType::Other),
85            _ => Err(format!("unknown SectionType: {s:?}")),
86        }
87    }
88}
89
90// ── BetaPosterior ─────────────────────────────────────────────────────────────
91
92/// Beta-Binomial posterior for a single parameter.
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
94pub struct BetaPosterior {
95    pub alpha: f64,
96    pub beta: f64,
97}
98
99impl BetaPosterior {
100    pub fn new(alpha: f64, beta: f64) -> Self {
101        Self { alpha, beta }
102    }
103
104    pub fn mean(&self) -> f64 {
105        self.alpha / (self.alpha + self.beta)
106    }
107
108    pub fn variance(&self) -> f64 {
109        let n = self.alpha + self.beta;
110        (self.alpha * self.beta) / (n * n * (n + 1.0))
111    }
112
113    pub fn effective_sample_size(&self) -> f64 {
114        self.alpha + self.beta
115    }
116
117    pub fn update_success(&mut self) {
118        self.alpha += 1.0;
119    }
120
121    pub fn update_failure(&mut self) {
122        self.beta += 1.0;
123    }
124
125    /// Weighted success update (issue #268).
126    ///
127    /// Adds `weight` to α instead of a fixed 1.0. Used by semantic feedback
128    /// event kinds that carry different evidence strength (explicit vs implicit).
129    pub fn update_success_weighted(&mut self, weight: f64) {
130        debug_assert!(
131            weight > 0.0,
132            "update_success_weighted: weight must be positive, got {weight}"
133        );
134        self.alpha += weight;
135    }
136
137    /// Weighted failure update (issue #268).
138    ///
139    /// Adds `weight` to β instead of a fixed 1.0. Used by semantic feedback
140    /// event kinds that carry different evidence strength (explicit vs implicit).
141    pub fn update_failure_weighted(&mut self, weight: f64) {
142        debug_assert!(
143            weight > 0.0,
144            "update_failure_weighted: weight must be positive, got {weight}"
145        );
146        self.beta += weight;
147    }
148
149    /// Combine evidence from two independent observers sharing the same prior.
150    ///
151    /// merged = Beta(a₁ + a₂ − a_prior, b₁ + b₂ − b_prior)
152    pub fn merge(&self, other: &BetaPosterior, prior: &BetaPosterior) -> BetaPosterior {
153        BetaPosterior {
154            alpha: self.alpha + other.alpha - prior.alpha,
155            beta: self.beta + other.beta - prior.beta,
156        }
157    }
158
159    /// Cap ESS at `cap` by scaling excess evidence back toward the prior.
160    ///
161    /// If current ESS exceeds cap, the excess evidence (above the prior) is
162    /// scaled so the resulting ESS equals cap exactly.
163    ///
164    /// Formula: scale = (cap - prior_ess) / (ess - prior_ess)
165    pub fn apply_ess_cap(&mut self, prior: &BetaPosterior, cap: f64) {
166        let ess = self.effective_sample_size();
167        if ess > cap {
168            let prior_ess = prior.effective_sample_size();
169            let scale = (cap - prior_ess) / (ess - prior_ess);
170            self.alpha = prior.alpha + (self.alpha - prior.alpha) * scale;
171            self.beta = prior.beta + (self.beta - prior.beta) * scale;
172        }
173    }
174
175    /// Posterior mean floored at `floor`.
176    pub fn floored_mean(&self, floor: f64) -> f64 {
177        self.mean().max(floor)
178    }
179}
180
181impl Default for BetaPosterior {
182    fn default() -> Self {
183        Self::new(1.0, 1.0)
184    }
185}
186
187// ── EntityPosteriors ──────────────────────────────────────────────────────────
188
189/// Bounded LRU map for per-entity posteriors.
190/// Uses a VecDeque to track insertion order; evicts oldest on insert when full.
191pub struct EntityPosteriors {
192    map: HashMap<Uuid, BetaPosterior>,
193    order: VecDeque<Uuid>,
194    capacity: usize,
195}
196
197impl EntityPosteriors {
198    pub fn new(capacity: usize) -> Self {
199        Self {
200            map: HashMap::with_capacity(capacity),
201            order: VecDeque::with_capacity(capacity),
202            capacity,
203        }
204    }
205
206    pub fn get_or_insert(
207        &mut self,
208        id: Uuid,
209        default: impl FnOnce() -> BetaPosterior,
210    ) -> &mut BetaPosterior {
211        if !self.map.contains_key(&id) {
212            if self.map.len() >= self.capacity {
213                if let Some(evicted) = self.order.pop_front() {
214                    self.map.remove(&evicted);
215                }
216            }
217            self.map.insert(id, default());
218            self.order.push_back(id);
219        }
220        self.map.get_mut(&id).unwrap()
221    }
222
223    pub fn get(&self, id: &Uuid) -> Option<&BetaPosterior> {
224        self.map.get(id)
225    }
226
227    pub fn len(&self) -> usize {
228        self.map.len()
229    }
230
231    pub fn is_empty(&self) -> bool {
232        self.map.is_empty()
233    }
234
235    pub fn clear(&mut self) {
236        self.map.clear();
237        self.order.clear();
238    }
239
240    pub fn to_snapshot(&self) -> HashMap<Uuid, BetaPosterior> {
241        self.map.clone()
242    }
243
244    pub fn from_snapshot(snapshot: HashMap<Uuid, BetaPosterior>, capacity: usize) -> Self {
245        let mut ep = Self::new(capacity);
246        for (id, posterior) in snapshot {
247            ep.map.insert(id, posterior);
248            ep.order.push_back(id);
249        }
250        ep
251    }
252}
253
254// ── BalancedRecallState ───────────────────────────────────────────────────────
255
256/// State for the `BalancedRecallProfile` — the v1 default profile.
257///
258/// Migrated from the predecessor scalar `BrainState` design (ADR-032 §5a).
259/// Three-parameter Beta posteriors with informative priors + per-entity LRU.
260pub struct BalancedRecallState {
261    /// relevance_weight — prior Beta(7,3): warm-starts expecting 70% success
262    pub relevance: BetaPosterior,
263    /// salience_weight — prior Beta(2,8)
264    pub salience: BetaPosterior,
265    /// temporal_weight — prior Beta(1,9)
266    pub temporal: BetaPosterior,
267    /// Per-entity posteriors, bounded LRU (10K default)
268    pub entity_posteriors: EntityPosteriors,
269    /// Total events processed by this profile
270    pub total_events: u64,
271    /// Incremented each time posteriors are reset to priors
272    pub exploration_epoch: u64,
273}
274
275impl BalancedRecallState {
276    pub fn new(entity_capacity: usize) -> Self {
277        Self {
278            relevance: BetaPosterior::new(7.0, 3.0),
279            salience: BetaPosterior::new(2.0, 8.0),
280            temporal: BetaPosterior::new(1.0, 9.0),
281            entity_posteriors: EntityPosteriors::new(entity_capacity),
282            total_events: 0,
283            exploration_epoch: 0,
284        }
285    }
286
287    pub fn reset_posteriors(&mut self) {
288        self.relevance = BetaPosterior::new(7.0, 3.0);
289        self.salience = BetaPosterior::new(2.0, 8.0);
290        self.temporal = BetaPosterior::new(1.0, 9.0);
291        self.entity_posteriors.clear();
292        self.exploration_epoch += 1;
293    }
294
295    pub fn to_snapshot(&self) -> BalancedRecallSnapshot {
296        BalancedRecallSnapshot {
297            relevance: self.relevance.clone(),
298            salience: self.salience.clone(),
299            temporal: self.temporal.clone(),
300            entity_posteriors: self.entity_posteriors.to_snapshot(),
301            total_events: self.total_events,
302            exploration_epoch: self.exploration_epoch,
303        }
304    }
305
306    pub fn from_snapshot(snapshot: BalancedRecallSnapshot, entity_capacity: usize) -> Self {
307        Self {
308            relevance: snapshot.relevance,
309            salience: snapshot.salience,
310            temporal: snapshot.temporal,
311            entity_posteriors: EntityPosteriors::from_snapshot(
312                snapshot.entity_posteriors,
313                entity_capacity,
314            ),
315            total_events: snapshot.total_events,
316            exploration_epoch: snapshot.exploration_epoch,
317        }
318    }
319}
320
321/// Serializable snapshot of `BalancedRecallState`.
322#[derive(Debug, Clone, Serialize, Deserialize)]
323pub struct BalancedRecallSnapshot {
324    pub relevance: BetaPosterior,
325    pub salience: BetaPosterior,
326    pub temporal: BetaPosterior,
327    pub entity_posteriors: HashMap<Uuid, BetaPosterior>,
328    pub total_events: u64,
329    pub exploration_epoch: u64,
330}
331
332// ── SectionPosteriorState ─────────────────────────────────────────────────────
333
334/// Default ESS cap for section posteriors (ADR-048 Correction 2: cap=100).
335pub const DEFAULT_ESS_CAP: f64 = 100.0;
336
337/// Default exploration epoch countdown (ADR-048 §489).
338pub const DEFAULT_EXPLORATION_EPOCH: u64 = 50;
339
340/// Initial temperature for Thompson sampling softmax (ADR-048 Correction 1).
341pub const DEFAULT_TAU_0: f64 = 1.0;
342
343/// Exploit-mode temperature when exploration_epoch == 0 (ADR-048 Correction 1).
344pub const DEFAULT_TAU_EXPLOIT: f64 = 0.1;
345
346/// Default weight floor for section weights (5%).
347pub const DEFAULT_SECTION_WEIGHT_FLOOR: f64 = 0.05;
348
349/// Per-profile section posterior state (ADR-048 Phase 1).
350pub struct SectionPosteriorState {
351    pub posteriors: HashMap<SectionType, BetaPosterior>,
352    pub priors: HashMap<SectionType, BetaPosterior>,
353    pub total_events: u64,
354    pub exploration_epoch: u64,
355}
356
357impl SectionPosteriorState {
358    /// Create with default informative priors for all 10 section types.
359    pub fn new() -> Self {
360        let priors = Self::default_priors();
361        let posteriors = priors.clone();
362        Self {
363            posteriors,
364            priors,
365            total_events: 0,
366            exploration_epoch: DEFAULT_EXPLORATION_EPOCH,
367        }
368    }
369
370    /// Create from explicit prior map. Missing sections get neutral Beta(2,2) fallback.
371    pub fn from_priors(mut priors: HashMap<SectionType, BetaPosterior>) -> Self {
372        let neutral = BetaPosterior::new(2.0, 2.0);
373        for &st in SectionType::all() {
374            priors.entry(st).or_insert_with(|| neutral.clone());
375        }
376        let posteriors = priors.clone();
377        Self {
378            posteriors,
379            priors,
380            total_events: 0,
381            exploration_epoch: DEFAULT_EXPLORATION_EPOCH,
382        }
383    }
384
385    /// Default informative priors from ADR-048.
386    pub fn default_priors() -> HashMap<SectionType, BetaPosterior> {
387        let mut m = HashMap::new();
388        m.insert(SectionType::Overview, BetaPosterior::new(2.0, 2.0));
389        m.insert(SectionType::CoreModel, BetaPosterior::new(4.0, 2.0));
390        m.insert(
391            SectionType::BoundaryConditions,
392            BetaPosterior::new(2.0, 3.0),
393        );
394        m.insert(SectionType::Formalism, BetaPosterior::new(1.5, 4.0));
395        m.insert(
396            SectionType::OperationalGuidance,
397            BetaPosterior::new(6.0, 1.5),
398        );
399        m.insert(SectionType::Examples, BetaPosterior::new(5.0, 2.0));
400        m.insert(SectionType::FailureModes, BetaPosterior::new(3.0, 2.0));
401        m.insert(SectionType::ExpertLens, BetaPosterior::new(3.0, 2.0));
402        m.insert(SectionType::References, BetaPosterior::new(2.0, 2.0));
403        m.insert(SectionType::Other, BetaPosterior::new(2.0, 2.0));
404        m
405    }
406
407    pub fn to_snapshot(&self) -> SectionPosteriorSnapshot {
408        SectionPosteriorSnapshot {
409            posteriors: self.posteriors.clone(),
410            priors: self.priors.clone(),
411            total_events: self.total_events,
412            exploration_epoch: self.exploration_epoch,
413        }
414    }
415
416    pub fn from_snapshot(snapshot: SectionPosteriorSnapshot) -> Self {
417        Self {
418            posteriors: snapshot.posteriors,
419            priors: snapshot.priors,
420            total_events: snapshot.total_events,
421            exploration_epoch: snapshot.exploration_epoch,
422        }
423    }
424
425    /// Thompson sampling weights (stochastic when exploring, deterministic otherwise).
426    pub fn weights(&self, rng: &mut impl rand::Rng) -> HashMap<SectionType, f64> {
427        if self.exploration_epoch > 0 {
428            self.sample_weights(rng)
429        } else {
430            self.deterministic_weights()
431        }
432    }
433
434    /// Stochastic weights via Thompson sampling + softmax (ADR-048 Correction 1).
435    ///
436    /// Explore mode (exploration_epoch > 0):
437    ///   tau = tau_0 * (exploration_epoch / DEFAULT_EXPLORATION_EPOCH)
438    ///   theta_i ~ Beta(alpha_i, beta_i) via Gamma-ratio method
439    ///   w_i = softmax(theta_i / tau), then floor at DEFAULT_SECTION_WEIGHT_FLOOR + renorm
440    ///
441    /// Exploit mode (exploration_epoch == 0): delegates to deterministic_weights() with
442    ///   tau_exploit = DEFAULT_TAU_EXPLOIT applied over posterior means.
443    pub fn sample_weights(&self, rng: &mut impl rand::Rng) -> HashMap<SectionType, f64> {
444        // Compute tau proportional to remaining exploration budget.
445        let tau =
446            DEFAULT_TAU_0 * (self.exploration_epoch as f64 / DEFAULT_EXPLORATION_EPOCH as f64);
447        let tau = tau.max(1e-6); // guard against divide-by-zero at epoch boundary
448
449        // Draw one Thompson sample per section.
450        let mut samples: HashMap<SectionType, f64> = HashMap::new();
451        for (&st, posterior) in &self.posteriors {
452            let theta = sample_beta_gamma(posterior.alpha.max(1e-6), posterior.beta.max(1e-6), rng);
453            samples.insert(st, theta / tau);
454        }
455
456        // Numerically stable softmax: subtract max before exp.
457        let max_val = samples.values().cloned().fold(f64::NEG_INFINITY, f64::max);
458        let mut raw: HashMap<SectionType, f64> = HashMap::new();
459        for (&st, &logit) in &samples {
460            raw.insert(st, (logit - max_val).exp());
461        }
462
463        apply_floor_and_renorm(&mut raw, DEFAULT_SECTION_WEIGHT_FLOOR);
464        raw
465    }
466
467    /// Deterministic weights from posterior means with exploit-mode softmax (ADR-048 Correction 1).
468    ///
469    /// Uses tau_exploit = DEFAULT_TAU_EXPLOIT (0.1) over posterior means, then applies
470    /// weight floor at DEFAULT_SECTION_WEIGHT_FLOOR and renormalizes.
471    pub fn deterministic_weights(&self) -> HashMap<SectionType, f64> {
472        let tau = DEFAULT_TAU_EXPLOIT;
473
474        // Numerically stable softmax over posterior means / tau.
475        let logits: HashMap<SectionType, f64> = self
476            .posteriors
477            .iter()
478            .map(|(&st, p)| (st, p.mean() / tau))
479            .collect();
480        let max_val = logits.values().cloned().fold(f64::NEG_INFINITY, f64::max);
481        let mut raw: HashMap<SectionType, f64> = HashMap::new();
482        for (&st, &logit) in &logits {
483            raw.insert(st, (logit - max_val).exp());
484        }
485
486        apply_floor_and_renorm(&mut raw, DEFAULT_SECTION_WEIGHT_FLOOR);
487        raw
488    }
489
490    /// Reset posteriors to their stored priors.
491    pub fn reset_posteriors(&mut self) {
492        self.posteriors = self.priors.clone();
493    }
494}
495
496impl Default for SectionPosteriorState {
497    fn default() -> Self {
498        Self::new()
499    }
500}
501
502// ── Weight floor helpers ──────────────────────────────────────────────────────
503
504/// Apply a weight floor and renormalize iteratively until all weights meet the floor.
505///
506/// A single floor+renorm pass can push other weights below the floor after renorm.
507/// Iterate until stable (at most N iterations; in practice 2-3 suffice).
508fn apply_floor_and_renorm(weights: &mut HashMap<SectionType, f64>, floor: f64) {
509    // Normalize first so we work with probabilities.
510    let sum: f64 = weights.values().sum();
511    if sum > 0.0 {
512        for v in weights.values_mut() {
513            *v /= sum;
514        }
515    }
516    // Iterative floor: push up pinned values, renormalize the free mass.
517    for _ in 0..20 {
518        let (pinned_sum, n_free) = weights.values().fold((0.0f64, 0usize), |(ps, nf), &w| {
519            if w <= floor {
520                (ps + floor, nf)
521            } else {
522                (ps, nf + 1)
523            }
524        });
525        if n_free == 0 {
526            // All sections are at or above floor; uniform renorm suffices.
527            let total: f64 = weights.values().sum();
528            if total > 0.0 {
529                for v in weights.values_mut() {
530                    *v /= total;
531                }
532            }
533            break;
534        }
535        let free_mass = (1.0 - pinned_sum).max(0.0);
536        let free_sum: f64 = weights.values().filter(|&&w| w > floor).sum();
537        // Rescale free entries so they fill free_mass proportionally.
538        for v in weights.values_mut() {
539            if *v <= floor {
540                *v = floor;
541            } else if free_sum > 0.0 {
542                *v = (*v / free_sum) * free_mass;
543            }
544        }
545        // Check convergence: all free entries above floor?
546        if weights.values().all(|&w| w >= floor - 1e-12) {
547            break;
548        }
549    }
550}
551
552// ── Beta/Gamma sampling helpers ───────────────────────────────────────────────
553
554/// Sample from Beta(alpha, beta) using the Gamma-ratio method.
555///
556/// X ~ Gamma(alpha, 1), Y ~ Gamma(beta, 1) → Beta = X / (X + Y).
557fn sample_beta_gamma(alpha: f64, beta: f64, rng: &mut impl rand::Rng) -> f64 {
558    let x = sample_gamma_mt(alpha, rng);
559    let y = sample_gamma_mt(beta, rng);
560    let s = x + y;
561    if s <= 0.0 {
562        0.5
563    } else {
564        x / s
565    }
566}
567
568/// Sample from Gamma(shape, 1) using Marsaglia-Tsang's method (shape >= 1),
569/// or the transformation Gamma(shape) = Gamma(shape+1) * U^(1/shape) for shape < 1.
570fn sample_gamma_mt(shape: f64, rng: &mut impl rand::Rng) -> f64 {
571    if shape < 1.0 {
572        let g = sample_gamma_mt(shape + 1.0, rng);
573        let u: f64 = rng.gen();
574        return g * u.powf(1.0 / shape);
575    }
576    let d = shape - 1.0 / 3.0;
577    let c = 1.0 / (9.0 * d).sqrt();
578    loop {
579        let x: f64 = sample_standard_normal_bm(rng);
580        let v_raw = 1.0 + c * x;
581        if v_raw <= 0.0 {
582            continue;
583        }
584        let v = v_raw * v_raw * v_raw;
585        let u: f64 = rng.gen();
586        if u < 1.0 - 0.0331 * (x * x) * (x * x) {
587            return d * v;
588        }
589        if u.ln() < 0.5 * x * x + d * (1.0 - v + v.ln()) {
590            return d * v;
591        }
592    }
593}
594
595/// Sample from N(0,1) using the Box-Muller transform.
596fn sample_standard_normal_bm(rng: &mut impl rand::Rng) -> f64 {
597    let u1: f64 = rng.gen::<f64>().max(f64::EPSILON);
598    let u2: f64 = rng.gen();
599    (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
600}
601
602/// Serializable snapshot of SectionPosteriorState.
603#[derive(Debug, Clone, Serialize, Deserialize)]
604pub struct SectionPosteriorSnapshot {
605    pub posteriors: HashMap<SectionType, BetaPosterior>,
606    pub priors: HashMap<SectionType, BetaPosterior>,
607    pub total_events: u64,
608    pub exploration_epoch: u64,
609}
610
611// ── ProfileLifecycle ──────────────────────────────────────────────────────────
612
613/// Lifecycle states for a registered profile (ADR-032 §10).
614#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
615#[serde(rename_all = "snake_case")]
616pub enum ProfileLifecycle {
617    /// Profile code and metadata exist; not yet registered with brain.
618    Defined,
619    /// Brain knows about it; backtest-eligible. Not yet in live update loop.
620    Registered,
621    /// Live update loop running; snapshots persist.
622    Active,
623    /// Registered but no live updates. State retained; read-only.
624    Inactive,
625    /// Live updates stopped; snapshots and event log retained for audit.
626    Archived,
627}
628
629// ── ProfileRecord ─────────────────────────────────────────────────────────────
630
631/// Profile metadata stored in the registry (ADR-032 §2).
632#[derive(Debug, Clone, Serialize, Deserialize)]
633pub struct ProfileRecord {
634    pub id: String,
635    pub description: String,
636    pub consumer_kind: String,
637    pub state_class: String,
638    pub lifecycle: ProfileLifecycle,
639    pub created_at: DateTime<Utc>,
640    /// Serialized state snapshot (opaque bytes to brain core)
641    pub state_snapshot: Option<serde_json::Value>,
642    pub total_events: u64,
643    pub exploration_epoch: u64,
644}
645
646impl ProfileRecord {
647    pub fn new_balanced_recall(entity_capacity: usize) -> Self {
648        let state = BalancedRecallState::new(entity_capacity);
649        let snapshot = state.to_snapshot();
650        Self {
651            id: "balanced-recall-v1".into(),
652            description: "Default recall profile: three-scalar Beta posteriors (ADR-032 §5a)"
653                .into(),
654            consumer_kind: "recall".into(),
655            state_class: "Bayesian".into(),
656            lifecycle: ProfileLifecycle::Active,
657            created_at: Utc::now(),
658            state_snapshot: serde_json::to_value(snapshot).ok(),
659            total_events: 0,
660            exploration_epoch: 0,
661        }
662    }
663}
664
665// ── ProfileBinding ────────────────────────────────────────────────────────────
666
667/// One row in the profile binding table (ADR-032 §10).
668///
669/// Resolution uses longest-match wins; `*` is the wildcard sentinel.
670#[derive(Debug, Clone, Serialize, Deserialize)]
671pub struct ProfileBinding {
672    pub actor: String,
673    pub namespace: String,
674    pub consumer_kind: String,
675    pub profile_id: String,
676    pub priority: i32,
677    pub created_at: DateTime<Utc>,
678}
679
680// ── BrainState (profile registry) ────────────────────────────────────────────
681
682/// Runtime brain state — profile registry + active state per profile.
683///
684/// ADR-032 §1: BrainState holds profile registry and lifecycle metadata.
685/// Posteriors live inside each profile's own state, opaque to brain.
686///
687/// Per-profile state: `balanced_recall` holds the live state for the built-in
688/// `balanced-recall-v1` profile. `profile_states` holds live `BalancedRecallState`
689/// for every user-created Bayesian profile. Both maps are initialised at profile
690/// creation and cleared on hard-delete; they are never absent for a living profile
691/// whose `state_class == "Bayesian"`.
692///
693/// `section_states`: per-profile section-level Beta posteriors (ADR-048 §Phase1).
694/// Keys are profile_id; values are `SectionPosteriorState`.
695pub struct BrainState {
696    /// Registered profiles indexed by profile_id.
697    pub profiles: HashMap<String, ProfileRecord>,
698    /// In-memory BalancedRecallState for the built-in `balanced-recall-v1` profile.
699    pub balanced_recall: BalancedRecallState,
700    /// Per-profile live state for user-created Bayesian profiles.
701    pub profile_states: HashMap<String, BalancedRecallState>,
702    /// Profile binding table — maps (actor, namespace, consumer_kind) → profile_id.
703    pub bindings: Vec<ProfileBinding>,
704    /// Per-profile section posteriors (ADR-048).
705    pub section_states: HashMap<String, SectionPosteriorState>,
706}
707
708impl BrainState {
709    pub fn new(entity_capacity: usize) -> Self {
710        let mut profiles = HashMap::new();
711        let record = ProfileRecord::new_balanced_recall(entity_capacity);
712        let profile_id = record.id.clone();
713        profiles.insert(profile_id.clone(), record);
714
715        Self {
716            profiles,
717            balanced_recall: BalancedRecallState::new(entity_capacity),
718            profile_states: HashMap::new(),
719            bindings: Vec::new(),
720            section_states: HashMap::new(),
721        }
722    }
723
724    pub fn to_snapshot(&self) -> BrainStateSnapshot {
725        let extra: HashMap<String, BalancedRecallSnapshot> = self
726            .profile_states
727            .iter()
728            .map(|(id, s)| (id.clone(), s.to_snapshot()))
729            .collect();
730        let section_states: HashMap<String, SectionPosteriorSnapshot> = self
731            .section_states
732            .iter()
733            .map(|(id, s)| (id.clone(), s.to_snapshot()))
734            .collect();
735        BrainStateSnapshot {
736            profiles: self.profiles.clone(),
737            balanced_recall: self.balanced_recall.to_snapshot(),
738            profile_states: extra,
739            bindings: self.bindings.clone(),
740            section_states,
741        }
742    }
743
744    pub fn from_snapshot(snapshot: BrainStateSnapshot, entity_capacity: usize) -> Self {
745        let extra: HashMap<String, BalancedRecallState> = snapshot
746            .profile_states
747            .into_iter()
748            .map(|(id, s)| (id, BalancedRecallState::from_snapshot(s, entity_capacity)))
749            .collect();
750        let section_states: HashMap<String, SectionPosteriorState> = snapshot
751            .section_states
752            .into_iter()
753            .map(|(id, s)| (id, SectionPosteriorState::from_snapshot(s)))
754            .collect();
755        Self {
756            profiles: snapshot.profiles,
757            balanced_recall: BalancedRecallState::from_snapshot(
758                snapshot.balanced_recall,
759                entity_capacity,
760            ),
761            profile_states: extra,
762            bindings: snapshot.bindings,
763            section_states,
764        }
765    }
766
767    /// Reset the balanced-recall profile posteriors to priors.
768    pub fn reset_posteriors(&mut self) {
769        self.balanced_recall.reset_posteriors();
770        if let Some(record) = self.profiles.get_mut("balanced-recall-v1") {
771            record.exploration_epoch = self.balanced_recall.exploration_epoch;
772            record.state_snapshot = serde_json::to_value(self.balanced_recall.to_snapshot()).ok();
773        }
774        if let Some(ss) = self.section_states.get_mut("balanced-recall-v1") {
775            ss.reset_posteriors();
776        }
777    }
778
779    /// Reset posteriors for a user-created Bayesian profile.
780    pub fn reset_profile_posteriors(&mut self, profile_id: &str) {
781        if let Some(ps) = self.profile_states.get_mut(profile_id) {
782            ps.reset_posteriors();
783            let snap = serde_json::to_value(ps.to_snapshot()).ok();
784            let epoch = ps.exploration_epoch;
785            if let Some(record) = self.profiles.get_mut(profile_id) {
786                record.exploration_epoch = epoch;
787                record.state_snapshot = snap;
788            }
789        }
790        if let Some(ss) = self.section_states.get_mut(profile_id) {
791            ss.reset_posteriors();
792        }
793    }
794
795    /// Resolve a profile_id for the given caller context (ADR-032 §10).
796    ///
797    /// Longest-match wins: actor + namespace + consumer_kind beats actor + consumer_kind
798    /// beats namespace + consumer_kind beats consumer_kind alone. Returns the
799    /// `balanced-recall-v1` default when no explicit binding matches.
800    ///
801    /// Archived profiles are never returned, whether reached via binding or fallback.
802    pub fn resolve(
803        &self,
804        actor: Option<&str>,
805        namespace: Option<&str>,
806        consumer_kind: &str,
807    ) -> Option<&ProfileRecord> {
808        self.resolve_with_match(actor, namespace, consumer_kind)
809            .map(|(record, _)| record)
810    }
811
812    /// Like `resolve`, but also returns the `consumer_kind` field from the matched
813    /// binding row (H3: lets the caller distinguish a wildcard match from an exact match).
814    ///
815    /// Returns `(profile_record, matched_binding_consumer_kind)`.
816    /// For the implicit default fallback the matched kind equals the profile's own
817    /// `consumer_kind`.
818    pub fn resolve_with_match(
819        &self,
820        actor: Option<&str>,
821        namespace: Option<&str>,
822        consumer_kind: &str,
823    ) -> Option<(&ProfileRecord, String)> {
824        let actor_val = actor.unwrap_or("*");
825        let namespace_val = namespace.unwrap_or("*");
826
827        // Pre-filter: exclude bindings whose target profile is archived or missing.
828        // This ensures archived profiles are excluded from candidate selection entirely,
829        // so a lower-priority live binding can win over a higher-priority archived one.
830        let best = self
831            .bindings
832            .iter()
833            .filter(|b| {
834                (b.actor == "*" || b.actor == actor_val)
835                    && (b.namespace == "*" || b.namespace == namespace_val)
836                    && (b.consumer_kind == "*" || b.consumer_kind == consumer_kind)
837                    && self
838                        .profiles
839                        .get(&b.profile_id)
840                        .is_some_and(|p| p.lifecycle != ProfileLifecycle::Archived)
841            })
842            .max_by_key(|b| {
843                let actor_score = if b.actor != "*" { 4 } else { 0 };
844                let ns_score = if b.namespace != "*" { 2 } else { 0 };
845                let kind_score = if b.consumer_kind != "*" { 1 } else { 0 };
846                (
847                    actor_score + ns_score + kind_score,
848                    b.priority,
849                    -(b.created_at.timestamp()),
850                )
851            });
852
853        if let Some(binding) = best {
854            if let Some(record) = self.profiles.get(&binding.profile_id) {
855                return Some((record, binding.consumer_kind.clone()));
856            }
857            // Profile disappeared between filter and get (very unlikely) — fall through.
858        }
859
860        // No explicit binding (or all matched bindings point at archived profiles) —
861        // return the named default profile if it exists and is usable.
862        // ADR-032 §10: "balanced-recall-v1" is the v1 system-default for recall.
863        if let Some(default) = self.profiles.get("balanced-recall-v1") {
864            if default.lifecycle == ProfileLifecycle::Active
865                && (default.consumer_kind == consumer_kind
866                    || consumer_kind == "*"
867                    || default.consumer_kind == "*")
868            {
869                return Some((default, default.consumer_kind.clone()));
870            }
871        }
872
873        // Generic fallback: first active profile matching consumer_kind.
874        self.profiles.values().find_map(|p| {
875            if p.consumer_kind == consumer_kind && p.lifecycle == ProfileLifecycle::Active {
876                Some((p, p.consumer_kind.clone()))
877            } else {
878                None
879            }
880        })
881    }
882}
883
884/// Serializable snapshot of the full brain state.
885#[derive(Debug, Clone, Serialize, Deserialize)]
886pub struct BrainStateSnapshot {
887    pub profiles: HashMap<String, ProfileRecord>,
888    pub balanced_recall: BalancedRecallSnapshot,
889    /// Snapshots for user-created Bayesian profiles.
890    #[serde(default)]
891    pub profile_states: HashMap<String, BalancedRecallSnapshot>,
892    pub bindings: Vec<ProfileBinding>,
893    /// Per-profile section posteriors (ADR-048).
894    #[serde(default)]
895    pub section_states: HashMap<String, SectionPosteriorSnapshot>,
896}
897
898#[cfg(test)]
899mod tests {
900    use super::*;
901
902    #[test]
903    fn beta_posterior_mean() {
904        let p = BetaPosterior::new(7.0, 3.0);
905        assert!((p.mean() - 0.7).abs() < 1e-12);
906    }
907
908    #[test]
909    fn beta_posterior_variance() {
910        let p = BetaPosterior::new(7.0, 3.0);
911        let expected = 21.0 / 1100.0;
912        assert!((p.variance() - expected).abs() < 1e-12);
913    }
914
915    #[test]
916    fn beta_posterior_ess() {
917        let p = BetaPosterior::new(7.0, 3.0);
918        assert!((p.effective_sample_size() - 10.0).abs() < 1e-12);
919    }
920
921    #[test]
922    fn beta_posterior_update() {
923        let mut p = BetaPosterior::new(1.0, 1.0);
924        p.update_success();
925        p.update_success();
926        p.update_failure();
927        assert!((p.alpha - 3.0).abs() < 1e-12);
928        assert!((p.beta - 2.0).abs() < 1e-12);
929        assert!((p.mean() - 0.6).abs() < 1e-12);
930    }
931
932    #[test]
933    fn beta_posterior_merge() {
934        let prior = BetaPosterior::new(2.0, 8.0);
935        let a = BetaPosterior::new(5.0, 9.0); // prior + 3 success, 1 failure
936        let b = BetaPosterior::new(4.0, 10.0); // prior + 2 success, 2 failure
937        let merged = a.merge(&b, &prior);
938        // merged = (5+4-2, 9+10-8) = (7, 11)
939        assert!((merged.alpha - 7.0).abs() < 1e-12);
940        assert!((merged.beta - 11.0).abs() < 1e-12);
941    }
942
943    #[test]
944    fn beta_posterior_apply_ess_cap_noop() {
945        // ESS = 10 ≤ cap = 50 → no change
946        let prior = BetaPosterior::new(2.0, 2.0);
947        let mut p = BetaPosterior::new(7.0, 3.0);
948        p.apply_ess_cap(&prior, 50.0);
949        assert!((p.alpha - 7.0).abs() < 1e-12);
950        assert!((p.beta - 3.0).abs() < 1e-12);
951    }
952
953    #[test]
954    fn beta_posterior_apply_ess_cap_rescale() {
955        // alpha=30, beta=30 → ESS=60 > cap=50, prior_ess=4
956        // scale = (cap - prior_ess) / (ess - prior_ess) = (50-4)/(60-4) = 46/56
957        // new_alpha = 2 + 28*(46/56), new_beta = 2 + 28*(46/56)
958        // new_ess = 4 + 56*(46/56) = 50 exactly
959        let prior = BetaPosterior::new(2.0, 2.0);
960        let mut p = BetaPosterior::new(30.0, 30.0);
961        p.apply_ess_cap(&prior, 50.0);
962        let scale = (50.0 - 4.0) / (60.0 - 4.0); // (cap - prior_ess) / (ess - prior_ess)
963        let expected_excess = 28.0 * scale;
964        assert!((p.alpha - (2.0 + expected_excess)).abs() < 1e-10);
965        assert!((p.beta - (2.0 + expected_excess)).abs() < 1e-10);
966        assert!((p.effective_sample_size() - 50.0).abs() < 1e-10);
967    }
968
969    #[test]
970    fn beta_posterior_floored_mean() {
971        let p = BetaPosterior::new(1.0, 99.0); // mean = 0.01
972        assert!((p.floored_mean(0.05) - 0.05).abs() < 1e-12);
973
974        let p2 = BetaPosterior::new(7.0, 3.0); // mean = 0.7
975        assert!((p2.floored_mean(0.05) - 0.7).abs() < 1e-12);
976    }
977
978    #[test]
979    fn entity_posteriors_eviction() {
980        let mut ep = EntityPosteriors::new(3);
981        let ids: Vec<Uuid> = (0..5).map(|_| Uuid::new_v4()).collect();
982        for id in &ids {
983            ep.get_or_insert(*id, BetaPosterior::default);
984        }
985        assert_eq!(ep.len(), 3);
986        assert!(ep.get(&ids[0]).is_none());
987        assert!(ep.get(&ids[1]).is_none());
988        assert!(ep.get(&ids[2]).is_some());
989        assert!(ep.get(&ids[3]).is_some());
990        assert!(ep.get(&ids[4]).is_some());
991    }
992
993    #[test]
994    fn entity_posteriors_get_or_insert_existing() {
995        let mut ep = EntityPosteriors::new(10);
996        let id = Uuid::new_v4();
997        ep.get_or_insert(id, BetaPosterior::default)
998            .update_success();
999        let p = ep.get_or_insert(id, BetaPosterior::default);
1000        assert!((p.alpha - 2.0).abs() < 1e-12);
1001    }
1002
1003    #[test]
1004    fn balanced_recall_state_snapshot_roundtrip() {
1005        let mut state = BalancedRecallState::new(100);
1006        state.relevance.update_success();
1007        state.total_events = 42;
1008        let id = Uuid::new_v4();
1009        state
1010            .entity_posteriors
1011            .get_or_insert(id, BetaPosterior::default)
1012            .update_success();
1013
1014        let snapshot = state.to_snapshot();
1015        let json = serde_json::to_string(&snapshot).unwrap();
1016        let back: BalancedRecallSnapshot = serde_json::from_str(&json).unwrap();
1017        assert_eq!(back.total_events, 42);
1018        assert!((back.relevance.alpha - 8.0).abs() < 1e-12);
1019        assert!(back.entity_posteriors.contains_key(&id));
1020    }
1021
1022    #[test]
1023    fn balanced_recall_state_reset_preserves_epoch_increment() {
1024        let mut state = BalancedRecallState::new(10);
1025        state.total_events = 100;
1026        state.reset_posteriors();
1027        assert_eq!(state.total_events, 100);
1028        assert_eq!(state.exploration_epoch, 1);
1029        assert!((state.relevance.alpha - 7.0).abs() < 1e-12);
1030        assert!((state.relevance.beta - 3.0).abs() < 1e-12);
1031    }
1032
1033    #[test]
1034    fn brain_state_has_balanced_recall_profile_by_default() {
1035        let state = BrainState::new(100);
1036        assert!(state.profiles.contains_key("balanced-recall-v1"));
1037        let record = &state.profiles["balanced-recall-v1"];
1038        assert_eq!(record.lifecycle, ProfileLifecycle::Active);
1039        assert_eq!(record.consumer_kind, "recall");
1040        assert_eq!(record.state_class, "Bayesian");
1041    }
1042
1043    #[test]
1044    fn brain_state_reset_posteriors_updates_record() {
1045        let mut state = BrainState::new(10);
1046        state.balanced_recall.relevance.update_success();
1047        state.balanced_recall.total_events = 50;
1048        state.reset_posteriors();
1049        assert_eq!(state.balanced_recall.exploration_epoch, 1);
1050        let record = &state.profiles["balanced-recall-v1"];
1051        assert_eq!(record.exploration_epoch, 1);
1052    }
1053
1054    #[test]
1055    fn brain_state_resolve_falls_back_to_default() {
1056        let state = BrainState::new(100);
1057        let resolved = state.resolve(None, None, "recall");
1058        assert!(resolved.is_some());
1059        assert_eq!(resolved.unwrap().id, "balanced-recall-v1");
1060    }
1061
1062    #[test]
1063    fn brain_state_resolve_uses_explicit_binding() {
1064        let mut state = BrainState::new(100);
1065        // Add a second profile
1066        let mut alt = ProfileRecord::new_balanced_recall(100);
1067        alt.id = "alt-profile".into();
1068        state.profiles.insert("alt-profile".into(), alt);
1069
1070        // Bind alt-profile for actor "agent-1"
1071        state.bindings.push(ProfileBinding {
1072            actor: "agent-1".into(),
1073            namespace: "*".into(),
1074            consumer_kind: "recall".into(),
1075            profile_id: "alt-profile".into(),
1076            priority: 0,
1077            created_at: Utc::now(),
1078        });
1079
1080        let resolved = state.resolve(Some("agent-1"), None, "recall");
1081        assert!(resolved.is_some());
1082        assert_eq!(resolved.unwrap().id, "alt-profile");
1083
1084        // Different actor falls back to default
1085        let resolved_other = state.resolve(Some("agent-2"), None, "recall");
1086        assert_eq!(resolved_other.unwrap().id, "balanced-recall-v1");
1087    }
1088
1089    // Regression test for MAJ-005: an archived default profile must NOT be returned
1090    // by resolve (ADR-032 §10: "Archived … NOT resolvable for live recall").
1091    #[test]
1092    fn brain_state_resolve_skips_archived_default() {
1093        let mut state = BrainState::new(100);
1094
1095        // Archive the built-in default
1096        state
1097            .profiles
1098            .get_mut("balanced-recall-v1")
1099            .expect("default profile always exists")
1100            .lifecycle = ProfileLifecycle::Archived;
1101
1102        // No explicit binding → must not return the archived default
1103        let resolved = state.resolve(None, None, "recall");
1104        assert!(
1105            resolved.is_none(),
1106            "archived default profile must not be returned by resolve"
1107        );
1108    }
1109
1110    #[test]
1111    fn entity_posteriors_from_snapshot_rebuilds_map() {
1112        let id1 = Uuid::new_v4();
1113        let id2 = Uuid::new_v4();
1114        let mut snapshot = HashMap::new();
1115        snapshot.insert(id1, BetaPosterior::new(3.0, 2.0));
1116        snapshot.insert(id2, BetaPosterior::new(5.0, 1.0));
1117
1118        let ep = EntityPosteriors::from_snapshot(snapshot, 100);
1119        assert_eq!(ep.len(), 2);
1120        let p1 = ep.get(&id1).unwrap();
1121        assert!((p1.alpha - 3.0).abs() < 1e-12);
1122        let p2 = ep.get(&id2).unwrap();
1123        assert!((p2.alpha - 5.0).abs() < 1e-12);
1124    }
1125
1126    #[test]
1127    fn brain_state_snapshot_roundtrip() {
1128        let mut state = BrainState::new(100);
1129        state.balanced_recall.relevance.update_success();
1130        state.balanced_recall.total_events = 55;
1131        state.balanced_recall.exploration_epoch = 2;
1132        let id = Uuid::new_v4();
1133        state
1134            .balanced_recall
1135            .entity_posteriors
1136            .get_or_insert(id, || BetaPosterior::new(4.0, 6.0))
1137            .update_success();
1138
1139        let snap1 = state.to_snapshot();
1140        let restored = BrainState::from_snapshot(snap1, 100);
1141        let snap2 = restored.to_snapshot();
1142
1143        assert_eq!(snap2.balanced_recall.total_events, 55);
1144        assert_eq!(snap2.balanced_recall.exploration_epoch, 2);
1145        assert!((snap2.balanced_recall.relevance.alpha - 8.0).abs() < 1e-12);
1146        let ep = snap2.balanced_recall.entity_posteriors.get(&id).unwrap();
1147        assert!((ep.alpha - 5.0).abs() < 1e-12);
1148        assert!((ep.beta - 6.0).abs() < 1e-12);
1149    }
1150
1151    #[test]
1152    fn profile_lifecycle_serde_roundtrip() {
1153        let lc = ProfileLifecycle::Active;
1154        let json = serde_json::to_string(&lc).unwrap();
1155        let back: ProfileLifecycle = serde_json::from_str(&json).unwrap();
1156        assert_eq!(back, ProfileLifecycle::Active);
1157    }
1158
1159    #[test]
1160    fn beta_posterior_default_has_uniform_prior() {
1161        let p = BetaPosterior::default();
1162        assert!((p.alpha - 1.0).abs() < 1e-12);
1163        assert!((p.beta - 1.0).abs() < 1e-12);
1164        assert!((p.mean() - 0.5).abs() < 1e-12);
1165    }
1166
1167    #[test]
1168    fn section_type_serde_roundtrip() {
1169        for &st in SectionType::all() {
1170            let json = serde_json::to_string(&st).unwrap();
1171            let back: SectionType = serde_json::from_str(&json).unwrap();
1172            assert_eq!(back, st, "roundtrip failed for {st}");
1173        }
1174    }
1175
1176    #[test]
1177    fn section_type_display_and_from_str() {
1178        for &st in SectionType::all() {
1179            let s = st.to_string();
1180            let parsed: SectionType = s.parse().expect("parse should succeed");
1181            assert_eq!(parsed, st);
1182        }
1183    }
1184
1185    #[test]
1186    fn section_type_from_str_unknown_rejected() {
1187        let result = "unknown_section".parse::<SectionType>();
1188        assert!(result.is_err());
1189    }
1190
1191    #[test]
1192    fn section_posterior_state_new_has_all_sections() {
1193        let state = SectionPosteriorState::new();
1194        assert_eq!(state.posteriors.len(), 10);
1195        assert_eq!(state.priors.len(), 10);
1196        for &st in SectionType::all() {
1197            assert!(
1198                state.posteriors.contains_key(&st),
1199                "missing section {st} in posteriors"
1200            );
1201            assert!(
1202                state.priors.contains_key(&st),
1203                "missing section {st} in priors"
1204            );
1205        }
1206    }
1207
1208    #[test]
1209    fn section_posterior_state_snapshot_roundtrip() {
1210        let state = SectionPosteriorState::new();
1211        let snap = state.to_snapshot();
1212        let json = serde_json::to_string(&snap).unwrap();
1213        let back: SectionPosteriorSnapshot = serde_json::from_str(&json).unwrap();
1214        assert_eq!(back.posteriors.len(), 10);
1215        assert_eq!(back.total_events, 0);
1216        assert_eq!(back.exploration_epoch, DEFAULT_EXPLORATION_EPOCH);
1217
1218        // Reconstruct from snapshot and verify posteriors match
1219        let restored = SectionPosteriorState::from_snapshot(back);
1220        assert_eq!(restored.posteriors.len(), 10);
1221        let op = &state.posteriors[&SectionType::OperationalGuidance];
1222        let rp = &restored.posteriors[&SectionType::OperationalGuidance];
1223        assert!((op.alpha - rp.alpha).abs() < 1e-12);
1224        assert!((op.beta - rp.beta).abs() < 1e-12);
1225    }
1226
1227    #[test]
1228    fn section_posterior_state_deterministic_weights_normalized() {
1229        let state = SectionPosteriorState::new();
1230        let weights = state.deterministic_weights();
1231        assert_eq!(weights.len(), 10);
1232        let sum: f64 = weights.values().sum();
1233        assert!((sum - 1.0).abs() < 1e-10, "weights sum = {sum}");
1234        for (&st, &w) in &weights {
1235            assert!(
1236                w >= DEFAULT_SECTION_WEIGHT_FLOOR - 1e-12,
1237                "weight for {st} below floor: {w}"
1238            );
1239        }
1240    }
1241
1242    #[test]
1243    fn section_posterior_state_from_priors_fills_missing() {
1244        // Provide only 3 sections; the other 7 should be filled with neutral Beta(2,2)
1245        let mut partial: HashMap<SectionType, BetaPosterior> = HashMap::new();
1246        partial.insert(SectionType::Overview, BetaPosterior::new(5.0, 1.0));
1247        partial.insert(SectionType::CoreModel, BetaPosterior::new(4.0, 2.0));
1248        partial.insert(SectionType::Formalism, BetaPosterior::new(3.0, 3.0));
1249
1250        let state = SectionPosteriorState::from_priors(partial);
1251        assert_eq!(state.priors.len(), 10);
1252        assert_eq!(state.posteriors.len(), 10);
1253
1254        // Explicitly provided priors are preserved
1255        assert!((state.priors[&SectionType::Overview].alpha - 5.0).abs() < 1e-12);
1256
1257        // Missing sections get neutral Beta(2,2)
1258        let neutral = &state.priors[&SectionType::Examples];
1259        assert!((neutral.alpha - 2.0).abs() < 1e-12);
1260        assert!((neutral.beta - 2.0).abs() < 1e-12);
1261    }
1262
1263    #[test]
1264    fn beta_posterior_weighted_success_adds_weight_to_alpha() {
1265        let mut p = BetaPosterior::new(2.0, 8.0);
1266        p.update_success_weighted(1.5);
1267        assert!((p.alpha - 3.5).abs() < 1e-12);
1268        assert!((p.beta - 8.0).abs() < 1e-12);
1269    }
1270
1271    #[test]
1272    fn beta_posterior_weighted_failure_adds_weight_to_beta() {
1273        let mut p = BetaPosterior::new(2.0, 8.0);
1274        p.update_failure_weighted(2.0);
1275        assert!((p.alpha - 2.0).abs() < 1e-12);
1276        assert!((p.beta - 10.0).abs() < 1e-12);
1277    }
1278
1279    #[test]
1280    fn beta_posterior_weighted_fractional_update() {
1281        let mut p = BetaPosterior::new(1.0, 1.0);
1282        p.update_success_weighted(0.5);
1283        assert!((p.alpha - 1.5).abs() < 1e-12);
1284        assert!((p.beta - 1.0).abs() < 1e-12);
1285    }
1286
1287    #[cfg(debug_assertions)]
1288    #[test]
1289    #[should_panic(expected = "update_success_weighted: weight must be positive")]
1290    fn beta_posterior_weighted_success_rejects_zero_weight() {
1291        let mut p = BetaPosterior::new(1.0, 1.0);
1292        p.update_success_weighted(0.0);
1293    }
1294
1295    #[cfg(debug_assertions)]
1296    #[test]
1297    #[should_panic(expected = "update_failure_weighted: weight must be positive")]
1298    fn beta_posterior_weighted_failure_rejects_negative_weight() {
1299        let mut p = BetaPosterior::new(1.0, 1.0);
1300        p.update_failure_weighted(-1.0);
1301    }
1302}