1use std::collections::HashMap;
4
5use rand::Rng;
6use serde::{Deserialize, Serialize};
7
8use crate::brain_signal::BrainSignal;
9use crate::posterior::BetaPosterior;
10use crate::section_type::SectionType;
11use crate::signal::FeedbackSignal;
12
13pub const DEFAULT_ESS_CAP: f64 = 100.0;
14pub const DEFAULT_EXPLORATION_EPOCH: u64 = 50;
15pub const DEFAULT_TAU_0: f64 = 1.0;
16pub const DEFAULT_TAU_EXPLOIT: f64 = 0.1;
17pub const DEFAULT_SECTION_WEIGHT_FLOOR: f64 = 0.05;
18
19pub struct SectionPosteriorState {
21 pub posteriors: HashMap<SectionType, BetaPosterior>,
22 pub priors: HashMap<SectionType, BetaPosterior>,
23 pub total_events: u64,
24 pub exploration_epoch: u64,
25}
26
27impl SectionPosteriorState {
28 pub fn new() -> Self {
29 let priors = Self::default_priors();
30 let posteriors = priors.clone();
31 Self {
32 posteriors,
33 priors,
34 total_events: 0,
35 exploration_epoch: DEFAULT_EXPLORATION_EPOCH,
36 }
37 }
38
39 pub fn from_priors(mut priors: HashMap<SectionType, BetaPosterior>) -> Self {
40 let neutral = BetaPosterior::new(2.0, 2.0);
41 for &st in SectionType::all() {
42 priors.entry(st).or_insert_with(|| neutral.clone());
43 }
44 let posteriors = priors.clone();
45 Self {
46 posteriors,
47 priors,
48 total_events: 0,
49 exploration_epoch: DEFAULT_EXPLORATION_EPOCH,
50 }
51 }
52
53 pub fn default_priors() -> HashMap<SectionType, BetaPosterior> {
54 let mut m = HashMap::new();
55 m.insert(SectionType::Overview, BetaPosterior::new(2.0, 2.0));
56 m.insert(SectionType::CoreModel, BetaPosterior::new(4.0, 2.0));
57 m.insert(
58 SectionType::BoundaryConditions,
59 BetaPosterior::new(2.0, 3.0),
60 );
61 m.insert(SectionType::Formalism, BetaPosterior::new(1.5, 4.0));
62 m.insert(
63 SectionType::OperationalGuidance,
64 BetaPosterior::new(6.0, 1.5),
65 );
66 m.insert(SectionType::Examples, BetaPosterior::new(5.0, 2.0));
67 m.insert(SectionType::FailureModes, BetaPosterior::new(3.0, 2.0));
68 m.insert(SectionType::ExpertLens, BetaPosterior::new(3.0, 2.0));
69 m.insert(SectionType::References, BetaPosterior::new(2.0, 2.0));
70 m.insert(SectionType::Other, BetaPosterior::new(2.0, 2.0));
71 m
72 }
73
74 pub fn to_snapshot(&self) -> SectionPosteriorSnapshot {
75 SectionPosteriorSnapshot {
76 posteriors: self.posteriors.clone(),
77 priors: self.priors.clone(),
78 total_events: self.total_events,
79 exploration_epoch: self.exploration_epoch,
80 }
81 }
82
83 pub fn from_snapshot(snapshot: SectionPosteriorSnapshot) -> Self {
84 Self {
85 posteriors: snapshot.posteriors,
86 priors: snapshot.priors,
87 total_events: snapshot.total_events,
88 exploration_epoch: snapshot.exploration_epoch,
89 }
90 }
91
92 pub fn weights(&self, rng: &mut impl Rng) -> HashMap<SectionType, f64> {
93 if self.exploration_epoch > 0 {
94 self.sample_weights(rng)
95 } else {
96 self.deterministic_weights()
97 }
98 }
99
100 pub fn sample_weights(&self, rng: &mut impl Rng) -> HashMap<SectionType, f64> {
101 let tau =
102 DEFAULT_TAU_0 * (self.exploration_epoch as f64 / DEFAULT_EXPLORATION_EPOCH as f64);
103 let tau = tau.max(1e-6);
104
105 let mut samples: HashMap<SectionType, f64> = HashMap::new();
106 for (&st, posterior) in &self.posteriors {
107 let theta =
108 sample_beta_gamma(posterior.alpha().max(1e-6), posterior.beta().max(1e-6), rng);
109 samples.insert(st, theta / tau);
110 }
111
112 let max_val = samples.values().cloned().fold(f64::NEG_INFINITY, f64::max);
113 let mut raw: HashMap<SectionType, f64> = HashMap::new();
114 for (&st, &logit) in &samples {
115 raw.insert(st, (logit - max_val).exp());
116 }
117
118 apply_floor_and_renorm(&mut raw, DEFAULT_SECTION_WEIGHT_FLOOR);
119 raw
120 }
121
122 pub fn deterministic_weights(&self) -> HashMap<SectionType, f64> {
123 let tau = DEFAULT_TAU_EXPLOIT;
124
125 let logits: HashMap<SectionType, f64> = self
126 .posteriors
127 .iter()
128 .map(|(&st, p)| (st, p.mean() / tau))
129 .collect();
130 let max_val = logits.values().cloned().fold(f64::NEG_INFINITY, f64::max);
131 let mut raw: HashMap<SectionType, f64> = HashMap::new();
132 for (&st, &logit) in &logits {
133 raw.insert(st, (logit - max_val).exp());
134 }
135
136 apply_floor_and_renorm(&mut raw, DEFAULT_SECTION_WEIGHT_FLOOR);
137 raw
138 }
139
140 pub fn reset_posteriors(&mut self) {
141 self.posteriors = self.priors.clone();
142 }
143
144 pub fn apply_signal(&mut self, signal: &BrainSignal) {
147 if let BrainSignal::Feedback {
148 section_signals: Some(ref signals),
149 ..
150 } = signal
151 {
152 self.total_events += 1;
153
154 for (section_type, feedback_signal) in signals {
155 if let Some(posterior) = self.posteriors.get_mut(section_type) {
156 match feedback_signal {
157 FeedbackSignal::Useful => posterior.update_success(),
158 FeedbackSignal::NotUseful => posterior.update_failure(),
159 FeedbackSignal::Wrong => posterior.update_failure_weighted(2.0),
160 }
161 if let Some(prior) = self.priors.get(section_type) {
162 if let Err(e) = posterior.apply_ess_cap(&prior.clone(), DEFAULT_ESS_CAP) {
163 eprintln!(
164 "[brain-core] apply_ess_cap failed for section {:?}: {e}",
165 section_type
166 );
167 }
168 }
169 }
170 }
171
172 if self.exploration_epoch > 0 {
173 self.exploration_epoch -= 1;
174 }
175 }
176 }
177}
178
179impl Default for SectionPosteriorState {
180 fn default() -> Self {
181 Self::new()
182 }
183}
184
185pub fn derive_weights(
187 state: &SectionPosteriorState,
188 rng: &mut impl Rng,
189) -> HashMap<SectionType, f64> {
190 state.weights(rng)
191}
192
193pub fn derive_deterministic_weights(state: &SectionPosteriorState) -> HashMap<SectionType, f64> {
195 state.deterministic_weights()
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct SectionPosteriorSnapshot {
202 pub posteriors: HashMap<SectionType, BetaPosterior>,
203 pub priors: HashMap<SectionType, BetaPosterior>,
204 pub total_events: u64,
205 pub exploration_epoch: u64,
206}
207
208fn apply_floor_and_renorm(weights: &mut HashMap<SectionType, f64>, floor: f64) {
211 let sum: f64 = weights.values().sum();
212 if sum > 0.0 {
213 for v in weights.values_mut() {
214 *v /= sum;
215 }
216 }
217 for _ in 0..20 {
218 let (pinned_sum, n_free) = weights.values().fold((0.0f64, 0usize), |(ps, nf), &w| {
219 if w <= floor {
220 (ps + floor, nf)
221 } else {
222 (ps, nf + 1)
223 }
224 });
225 if n_free == 0 {
226 let total: f64 = weights.values().sum();
227 if total > 0.0 {
228 for v in weights.values_mut() {
229 *v /= total;
230 }
231 }
232 break;
233 }
234 let free_mass = (1.0 - pinned_sum).max(0.0);
235 let free_sum: f64 = weights.values().filter(|&&w| w > floor).sum();
236 for v in weights.values_mut() {
237 if *v <= floor {
238 *v = floor;
239 } else if free_sum > 0.0 {
240 *v = (*v / free_sum) * free_mass;
241 }
242 }
243 if weights.values().all(|&w| w >= floor - 1e-12) {
244 break;
245 }
246 }
247}
248
249fn sample_beta_gamma(alpha: f64, beta: f64, rng: &mut impl Rng) -> f64 {
250 let x = sample_gamma_mt(alpha, rng);
251 let y = sample_gamma_mt(beta, rng);
252 let s = x + y;
253 if s <= 0.0 {
254 0.5
255 } else {
256 x / s
257 }
258}
259
260fn sample_gamma_mt(shape: f64, rng: &mut impl Rng) -> f64 {
261 if shape < 1.0 {
262 let g = sample_gamma_mt(shape + 1.0, rng);
263 let u: f64 = rng.gen();
264 return g * u.powf(1.0 / shape);
265 }
266 let d = shape - 1.0 / 3.0;
267 let c = 1.0 / (9.0 * d).sqrt();
268 loop {
269 let x: f64 = sample_standard_normal_bm(rng);
270 let v_raw = 1.0 + c * x;
271 if v_raw <= 0.0 {
272 continue;
273 }
274 let v = v_raw * v_raw * v_raw;
275 let u: f64 = rng.gen();
276 if u < 1.0 - 0.0331 * (x * x) * (x * x) {
277 return d * v;
278 }
279 if u.ln() < 0.5 * x * x + d * (1.0 - v + v.ln()) {
280 return d * v;
281 }
282 }
283}
284
285fn sample_standard_normal_bm(rng: &mut impl Rng) -> f64 {
286 let u1: f64 = rng.gen::<f64>().max(f64::EPSILON);
287 let u2: f64 = rng.gen();
288 (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294
295 #[test]
296 fn derive_weights_sums_to_one() {
297 let state = SectionPosteriorState::new();
298 let mut rng = rand::thread_rng();
299 for _ in 0..20 {
300 let weights = derive_weights(&state, &mut rng);
301 assert_eq!(weights.len(), SectionType::all().len());
302 let sum: f64 = weights.values().sum();
303 assert!(
304 (sum - 1.0).abs() < 1e-9,
305 "weights must sum to 1.0; got {sum}"
306 );
307 }
308 }
309
310 #[test]
311 fn deterministic_weights_sum_to_one() {
312 let state = SectionPosteriorState::new();
313 let weights = derive_deterministic_weights(&state);
314 assert_eq!(weights.len(), 10);
315 let sum: f64 = weights.values().sum();
316 assert!(
317 (sum - 1.0).abs() < 1e-9,
318 "deterministic weights must sum to 1.0; got {sum}"
319 );
320 }
321
322 #[test]
323 fn epoch_zero_uses_deterministic() {
324 let mut state = SectionPosteriorState::new();
325 state.exploration_epoch = 0;
326 let mut rng = rand::thread_rng();
327 let w1 = derive_weights(&state, &mut rng);
328 let w2 = derive_weights(&state, &mut rng);
329 for st in SectionType::all() {
330 assert!(
331 (w1[st] - w2[st]).abs() < 1e-12,
332 "epoch=0 should give deterministic weights for {:?}",
333 st
334 );
335 }
336 }
337
338 #[test]
339 fn higher_alpha_gets_higher_weight() {
340 let mut priors = SectionPosteriorState::default_priors();
341 priors.insert(
342 SectionType::OperationalGuidance,
343 BetaPosterior::new(20.0, 2.0),
344 );
345 let mut state = SectionPosteriorState::from_priors(priors);
346 state.exploration_epoch = 0;
347 let weights = derive_deterministic_weights(&state);
348 let og = weights[&SectionType::OperationalGuidance];
349 let form = weights[&SectionType::Formalism];
350 assert!(og > form, "og={og:.4} form={form:.4}");
351 }
352}