1#![cfg_attr(not(feature = "std"), no_std)]
48#![forbid(unsafe_code)]
49#![warn(missing_docs)]
50
51extern crate alloc;
52
53use alloc::vec::Vec;
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
61pub struct Score(u32);
62
63impl Score {
64 pub const SCALE: u32 = 10_000;
66 pub const ZERO: Score = Score(0);
68 pub const MAX: Score = Score(Self::SCALE);
70
71 pub const fn from_raw(raw: u32) -> Score {
73 Score(if raw > Self::SCALE { Self::SCALE } else { raw })
74 }
75
76 pub const fn raw(self) -> u32 {
78 self.0
79 }
80
81 pub const fn from_ratio(num: u32, den: u32) -> Score {
85 if den == 0 {
86 Score::ZERO
87 } else {
88 let v = (num as u64 * Self::SCALE as u64) / den as u64;
89 Score::from_raw(if v > Self::SCALE as u64 {
90 Self::SCALE
91 } else {
92 v as u32
93 })
94 }
95 }
96
97 pub const fn mul(self, other: Score) -> Score {
100 Score(((self.0 as u64 * other.0 as u64) / Self::SCALE as u64) as u32)
101 }
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109#[non_exhaustive]
110pub enum Curve {
111 Constant(Score),
113 Linear,
115 Inverse,
117 Quadratic,
119 Threshold {
121 at: Score,
123 below: Score,
125 above: Score,
127 },
128}
129
130impl Curve {
131 pub const fn eval(self, input: Score) -> Score {
133 match self {
134 Curve::Constant(s) => s,
135 Curve::Linear => input,
136 Curve::Inverse => Score(Score::SCALE.saturating_sub(input.0)),
137 Curve::Quadratic => input.mul(input),
138 Curve::Threshold { at, below, above } => {
139 if input.0 >= at.0 {
140 above
141 } else {
142 below
143 }
144 }
145 }
146 }
147}
148
149#[non_exhaustive]
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub struct Consideration {
153 pub label: &'static str,
156 pub curve: Curve,
158 pub input: Score,
160}
161
162impl Consideration {
163 pub const fn new(curve: Curve, input: Score) -> Consideration {
165 Consideration {
166 label: "",
167 curve,
168 input,
169 }
170 }
171
172 pub const fn labeled(label: &'static str, curve: Curve, input: Score) -> Consideration {
174 Consideration {
175 label,
176 curve,
177 input,
178 }
179 }
180
181 pub const fn score(self) -> Score {
183 self.curve.eval(self.input)
184 }
185}
186
187#[non_exhaustive]
194#[derive(Debug, Clone)]
195pub struct Action<A> {
196 pub id: A,
198 pub base: Score,
200 pub allowed: bool,
204 pub considerations: Vec<Consideration>,
206}
207
208impl<A> Action<A> {
209 pub fn new(id: A) -> Action<A> {
211 Action {
212 id,
213 base: Score::MAX,
214 allowed: true,
215 considerations: Vec::new(),
216 }
217 }
218
219 pub fn gate(mut self, allowed: bool) -> Action<A> {
228 self.allowed = self.allowed && allowed;
229 self
230 }
231
232 pub fn with_base(mut self, base: Score) -> Action<A> {
234 self.base = base;
235 self
236 }
237
238 pub fn consider(mut self, curve: Curve, input: Score) -> Action<A> {
240 self.considerations.push(Consideration::new(curve, input));
241 self
242 }
243
244 pub fn consider_labeled(
247 mut self,
248 label: &'static str,
249 curve: Curve,
250 input: Score,
251 ) -> Action<A> {
252 self.considerations
253 .push(Consideration::labeled(label, curve, input));
254 self
255 }
256
257 pub fn utility(&self) -> Score {
261 if !self.allowed {
262 return Score::ZERO;
263 }
264 let mut u = self.base;
265 for c in &self.considerations {
266 u = u.mul(c.score());
267 }
268 u
269 }
270}
271
272#[non_exhaustive]
274#[derive(Debug, Clone, PartialEq, Eq)]
275pub struct Decision<A> {
276 pub id: A,
278 pub utility: Score,
280}
281
282#[non_exhaustive]
285#[derive(Debug, Clone, Copy, PartialEq, Eq)]
286pub struct Contribution {
287 pub label: &'static str,
289 pub input: Score,
291 pub output: Score,
293}
294
295#[non_exhaustive]
298#[derive(Debug, Clone, PartialEq, Eq)]
299pub struct Explanation<A> {
300 pub id: A,
302 pub allowed: bool,
305 pub utility: Score,
307 pub contributions: Vec<Contribution>,
309}
310
311#[derive(Debug, Clone)]
313pub struct Reasoner<A> {
314 actions: Vec<Action<A>>,
315}
316
317impl<A> Default for Reasoner<A> {
318 fn default() -> Self {
319 Reasoner {
320 actions: Vec::new(),
321 }
322 }
323}
324
325impl<A> Reasoner<A> {
326 pub fn new() -> Reasoner<A> {
328 Reasoner::default()
329 }
330
331 pub fn add(&mut self, action: Action<A>) -> &mut Self {
333 self.actions.push(action);
334 self
335 }
336
337 pub fn len(&self) -> usize {
339 self.actions.len()
340 }
341
342 pub fn is_empty(&self) -> bool {
344 self.actions.is_empty()
345 }
346
347 fn best_index(&self) -> Option<usize> {
350 let mut best: Option<usize> = None;
351 let mut best_u = Score::ZERO;
352 for (i, a) in self.actions.iter().enumerate() {
353 let u = a.utility();
354 if best.is_none() || u > best_u {
355 best = Some(i);
356 best_u = u;
357 }
358 }
359 best
360 }
361}
362
363impl<A: Clone> Reasoner<A> {
364 pub fn decide(&self) -> Option<Decision<A>> {
369 self.best_index().map(|i| Decision {
370 id: self.actions[i].id.clone(),
371 utility: self.actions[i].utility(),
372 })
373 }
374
375 pub fn decide_above(&self, threshold: Score) -> Option<Decision<A>> {
384 self.best_index().and_then(|i| {
385 let utility = self.actions[i].utility();
386 if utility > threshold {
387 Some(Decision {
388 id: self.actions[i].id.clone(),
389 utility,
390 })
391 } else {
392 None
393 }
394 })
395 }
396
397 pub fn decide_weighted(&self, rand: u32) -> Option<Decision<A>> {
409 if self.actions.is_empty() {
410 return None;
411 }
412 let total: u64 = self.actions.iter().map(|a| a.utility().raw() as u64).sum();
413 if total == 0 {
414 let a = &self.actions[0];
415 return Some(Decision {
416 id: a.id.clone(),
417 utility: a.utility(),
418 });
419 }
420 let target = ((rand as u128 * total as u128) >> 32) as u64;
423 let mut cumulative: u64 = 0;
424 for a in &self.actions {
425 cumulative += a.utility().raw() as u64;
426 if target < cumulative {
427 return Some(Decision {
428 id: a.id.clone(),
429 utility: a.utility(),
430 });
431 }
432 }
433 let a = &self.actions[self.actions.len() - 1];
436 Some(Decision {
437 id: a.id.clone(),
438 utility: a.utility(),
439 })
440 }
441
442 pub fn explain(&self) -> Option<Explanation<A>> {
446 self.best_index().map(|i| {
447 let a = &self.actions[i];
448 let contributions = a
449 .considerations
450 .iter()
451 .map(|c| Contribution {
452 label: c.label,
453 input: c.input,
454 output: c.score(),
455 })
456 .collect();
457 Explanation {
458 id: a.id.clone(),
459 allowed: a.allowed,
460 utility: a.utility(),
461 contributions,
462 }
463 })
464 }
465
466 pub fn rank(&self) -> Vec<Decision<A>> {
471 let mut out: Vec<Decision<A>> = self
472 .actions
473 .iter()
474 .map(|a| Decision {
475 id: a.id.clone(),
476 utility: a.utility(),
477 })
478 .collect();
479 out.sort_by_key(|d| core::cmp::Reverse(d.utility));
480 out
481 }
482}
483
484#[derive(Debug, Clone)]
509pub struct Policy<K> {
510 entries: Vec<(K, Score)>,
511 rate: Score,
512 default: Score,
513}
514
515impl<K> Policy<K> {
516 pub fn new(rate: Score, default: Score) -> Policy<K> {
519 Policy {
520 entries: Vec::new(),
521 rate,
522 default,
523 }
524 }
525
526 pub fn len(&self) -> usize {
528 self.entries.len()
529 }
530
531 pub fn is_empty(&self) -> bool {
533 self.entries.is_empty()
534 }
535
536 pub fn entries(&self) -> &[(K, Score)] {
540 &self.entries
541 }
542
543 fn step(rate: Score, current: Score, outcome: Score) -> Score {
545 let delta = outcome.raw() as i64 - current.raw() as i64; let moved = current.raw() as i64 + (rate.raw() as i64 * delta) / Score::SCALE as i64;
547 let clamped = moved.clamp(0, Score::SCALE as i64);
548 Score::from_raw(clamped as u32)
549 }
550}
551
552impl<K: PartialEq> Policy<K> {
553 pub fn weight(&self, key: &K) -> Score {
555 self.entries
556 .iter()
557 .find(|(k, _)| k == key)
558 .map(|(_, w)| *w)
559 .unwrap_or(self.default)
560 }
561
562 pub fn reward(&mut self, key: K, outcome: Score) {
565 if let Some(entry) = self.entries.iter_mut().find(|(k, _)| *k == key) {
566 entry.1 = Self::step(self.rate, entry.1, outcome);
567 } else {
568 let moved = Self::step(self.rate, self.default, outcome);
569 self.entries.push((key, moved));
570 }
571 }
572
573 pub fn set(&mut self, key: K, weight: Score) {
577 if let Some(entry) = self.entries.iter_mut().find(|(k, _)| *k == key) {
578 entry.1 = weight;
579 } else {
580 self.entries.push((key, weight));
581 }
582 }
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588
589 #[test]
590 fn score_ratio_clamp_and_mul() {
591 assert_eq!(Score::from_ratio(1, 2).raw(), 5_000);
592 assert_eq!(Score::from_ratio(3, 0), Score::ZERO);
593 assert_eq!(Score::from_raw(99_999), Score::MAX); assert_eq!(Score::MAX.mul(Score::from_raw(5_000)).raw(), 5_000); assert_eq!(
596 Score::from_raw(5_000).mul(Score::from_raw(5_000)).raw(),
597 2_500
598 ); }
600
601 #[test]
602 fn curves_eval_exactly() {
603 let x = Score::from_raw(3_000);
604 assert_eq!(Curve::Linear.eval(x), x);
605 assert_eq!(Curve::Inverse.eval(x).raw(), 7_000);
606 assert_eq!(Curve::Quadratic.eval(Score::from_raw(5_000)).raw(), 2_500);
607 assert_eq!(Curve::Constant(Score::MAX).eval(Score::ZERO), Score::MAX);
608 let step = Curve::Threshold {
609 at: Score::from_raw(5_000),
610 below: Score::ZERO,
611 above: Score::MAX,
612 };
613 assert_eq!(step.eval(Score::from_raw(4_999)), Score::ZERO);
614 assert_eq!(step.eval(Score::from_raw(5_000)), Score::MAX);
615 }
616
617 #[test]
618 fn utility_is_product_veto() {
619 let vetoed = Action::new(())
621 .consider(Curve::Linear, Score::MAX)
622 .consider(Curve::Linear, Score::ZERO);
623 assert_eq!(vetoed.utility(), Score::ZERO);
624
625 let a = Action::new(())
627 .consider(Curve::Linear, Score::from_raw(8_000))
628 .consider(Curve::Linear, Score::from_raw(5_000));
629 assert_eq!(a.utility().raw(), 4_000);
630 }
631
632 #[test]
633 fn decide_picks_highest_and_breaks_ties_by_order() {
634 let mut r = Reasoner::new();
635 r.add(Action::new("a").consider(Curve::Linear, Score::from_raw(3_000)));
636 r.add(Action::new("b").consider(Curve::Linear, Score::from_raw(9_000)));
637 assert_eq!(r.decide().unwrap().id, "b");
638
639 let mut t = Reasoner::new();
641 t.add(Action::new("first").consider(Curve::Linear, Score::from_raw(5_000)));
642 t.add(Action::new("second").consider(Curve::Linear, Score::from_raw(5_000)));
643 assert_eq!(t.decide().unwrap().id, "first");
644 }
645
646 #[test]
647 fn decide_on_empty_is_none() {
648 let r: Reasoner<&str> = Reasoner::new();
649 assert!(r.decide().is_none());
650 assert!(r.is_empty());
651 }
652
653 #[test]
654 fn rank_orders_descending_stably() {
655 let mut r = Reasoner::new();
656 r.add(Action::new("low").consider(Curve::Linear, Score::from_raw(2_000)));
657 r.add(Action::new("high").consider(Curve::Linear, Score::from_raw(8_000)));
658 r.add(Action::new("mid").consider(Curve::Linear, Score::from_raw(5_000)));
659 let ranked = r.rank();
660 let ids: Vec<&str> = ranked.iter().map(|d| d.id).collect();
661 assert_eq!(ids, ["high", "mid", "low"]);
662 }
663
664 #[test]
665 fn with_base_scales_and_vetoes() {
666 let scaled = Action::new(())
668 .with_base(Score::from_raw(5_000))
669 .consider(Curve::Linear, Score::from_raw(8_000));
670 assert_eq!(scaled.utility().raw(), 4_000);
671
672 let vetoed = Action::new(())
674 .with_base(Score::ZERO)
675 .consider(Curve::Linear, Score::MAX);
676 assert_eq!(vetoed.utility(), Score::ZERO);
677 }
678
679 #[test]
680 fn explain_breaks_down_the_winner() {
681 let health = Score::from_ratio(20, 100);
682 let mut r = Reasoner::new();
683 r.add(Action::new("flee").consider_labeled("low_health", Curve::Inverse, health));
684 r.add(Action::new("fight").consider_labeled("high_health", Curve::Linear, health));
685
686 let ex = r.explain().unwrap();
687 assert_eq!(ex.id, "flee");
688 assert_eq!(ex.utility.raw(), 8_000); assert_eq!(ex.contributions.len(), 1);
690 assert_eq!(ex.contributions[0].label, "low_health");
691 assert_eq!(ex.contributions[0].input, health);
692 assert_eq!(ex.contributions[0].output.raw(), 8_000);
693 }
694
695 #[test]
696 fn explain_on_empty_is_none() {
697 let r: Reasoner<&str> = Reasoner::new();
698 assert!(r.explain().is_none());
699 }
700
701 #[test]
702 fn explain_lists_all_considerations_in_order() {
703 let mut r = Reasoner::new();
704 r.add(
705 Action::new("act")
706 .consider_labeled("a", Curve::Linear, Score::from_raw(8_000))
707 .consider_labeled("b", Curve::Linear, Score::from_raw(5_000)),
708 );
709 let ex = r.explain().unwrap();
710 assert_eq!(ex.utility.raw(), 4_000); let labels: Vec<&str> = ex.contributions.iter().map(|c| c.label).collect();
712 assert_eq!(labels, ["a", "b"]); assert_eq!(ex.contributions[0].output.raw(), 8_000);
714 assert_eq!(ex.contributions[1].output.raw(), 5_000);
715 }
716
717 #[test]
718 fn rank_keeps_declaration_order_on_ties() {
719 let mut r = Reasoner::new();
720 r.add(Action::new("first").consider(Curve::Linear, Score::from_raw(5_000)));
721 r.add(Action::new("second").consider(Curve::Linear, Score::from_raw(5_000)));
722 let ids: Vec<&str> = r.rank().iter().map(|d| d.id).collect();
723 assert_eq!(ids, ["first", "second"]); }
725
726 #[test]
727 fn from_ratio_above_one_clamps() {
728 assert_eq!(Score::from_ratio(3, 2), Score::MAX); assert_eq!(Score::from_ratio(10, 10), Score::MAX); }
731
732 #[test]
733 fn quadratic_extremes() {
734 assert_eq!(Curve::Quadratic.eval(Score::MAX), Score::MAX); assert_eq!(Curve::Quadratic.eval(Score::ZERO), Score::ZERO); }
737
738 #[test]
739 fn decide_weighted_is_proportional_and_deterministic() {
740 let mut r = Reasoner::new();
741 r.add(Action::new("a").consider(Curve::Linear, Score::from_raw(2_500))); r.add(Action::new("b").consider(Curve::Linear, Score::from_raw(7_500))); assert_eq!(r.decide_weighted(0).unwrap().id, "a");
746 assert_eq!(r.decide_weighted(u32::MAX).unwrap().id, "b");
747 assert_eq!(r.decide_weighted(1_073_741_823).unwrap().id, "a"); assert_eq!(r.decide_weighted(1_073_741_824).unwrap().id, "b"); assert_eq!(
753 r.decide_weighted(1_234_567).unwrap().id,
754 r.decide_weighted(1_234_567).unwrap().id
755 );
756 }
757
758 #[test]
759 fn decide_weighted_zero_total_returns_first() {
760 let mut r = Reasoner::new();
761 r.add(Action::new("x").with_base(Score::ZERO));
762 r.add(Action::new("y").with_base(Score::ZERO));
763 assert_eq!(r.decide_weighted(999).unwrap().id, "x");
764 }
765
766 #[test]
767 fn decide_weighted_empty_is_none() {
768 let r: Reasoner<&str> = Reasoner::new();
769 assert!(r.decide_weighted(0).is_none());
770 }
771
772 #[test]
773 fn policy_unseen_key_returns_default() {
774 let p: Policy<&str> = Policy::new(Score::from_ratio(1, 2), Score::from_raw(3_000));
775 assert_eq!(p.weight(&"x").raw(), 3_000);
776 assert!(p.is_empty());
777 }
778
779 #[test]
780 fn policy_reward_moves_toward_outcome_and_converges() {
781 let mut p = Policy::new(Score::from_ratio(1, 2), Score::from_ratio(1, 2));
783 p.reward("a", Score::MAX); assert_eq!(p.weight(&"a").raw(), 7_500);
785 p.reward("a", Score::MAX); assert_eq!(p.weight(&"a").raw(), 8_750);
787 for _ in 0..50 {
788 p.reward("a", Score::MAX);
789 }
790 assert!(p.weight(&"a").raw() > 9_900);
792 assert!(p.weight(&"a").raw() <= Score::SCALE);
793 }
794
795 #[test]
796 fn policy_rate_extremes() {
797 let mut still = Policy::new(Score::ZERO, Score::from_ratio(1, 2));
799 still.reward("a", Score::MAX);
800 assert_eq!(still.weight(&"a").raw(), 5_000);
801 let mut fast = Policy::new(Score::MAX, Score::from_ratio(1, 2));
803 fast.reward("a", Score::from_raw(2_000));
804 assert_eq!(fast.weight(&"a").raw(), 2_000);
805 }
806
807 #[test]
808 fn policy_reward_toward_zero_clamps_at_zero() {
809 let mut p = Policy::new(Score::MAX, Score::from_ratio(1, 2)); p.reward("a", Score::ZERO);
811 assert_eq!(p.weight(&"a"), Score::ZERO);
812 }
813
814 #[test]
815 fn policy_reward_same_key_updates_in_place() {
816 let mut p = Policy::new(Score::from_ratio(1, 2), Score::from_ratio(1, 2));
817 p.reward("a", Score::MAX);
818 p.reward("a", Score::MAX); assert_eq!(p.len(), 1); p.reward("b", Score::MAX); assert_eq!(p.len(), 2);
822 }
823
824 #[test]
825 fn policy_set_replaces_existing() {
826 let mut p = Policy::new(Score::MAX, Score::ZERO);
827 p.set("a", Score::from_raw(1_000));
828 p.set("a", Score::from_raw(9_000)); assert_eq!(p.len(), 1);
830 assert_eq!(p.weight(&"a").raw(), 9_000);
831 }
832
833 #[test]
834 fn policy_entries_snapshot_round_trips_via_set() {
835 let mut p = Policy::new(Score::from_ratio(1, 2), Score::ZERO);
836 p.reward("a", Score::MAX);
837 p.set("b", Score::from_raw(3_000));
838
839 let saved: Vec<(&str, Score)> = p.entries().to_vec();
841 let mut restored = Policy::new(Score::from_ratio(1, 2), Score::ZERO);
842 for (k, w) in saved {
843 restored.set(k, w);
844 }
845 assert_eq!(restored.weight(&"a"), p.weight(&"a"));
846 assert_eq!(restored.weight(&"b").raw(), 3_000);
847 assert_eq!(restored.len(), p.len());
848 }
849
850 #[test]
851 fn gate_vetoes_and_combines() {
852 let ok = Action::new("x")
854 .gate(true)
855 .consider(Curve::Linear, Score::from_raw(8_000));
856 assert_eq!(ok.utility().raw(), 8_000);
857
858 let blocked = Action::new("x")
860 .gate(false)
861 .consider(Curve::Linear, Score::MAX);
862 assert_eq!(blocked.utility(), Score::ZERO);
863
864 assert!(Action::new("x").gate(true).gate(true).allowed);
866 assert!(!Action::new("x").gate(true).gate(false).allowed);
867 }
868
869 #[test]
870 fn gated_action_loses_to_ungated_fallback() {
871 let mut r = Reasoner::new();
872 r.add(
873 Action::new("call_llm")
874 .gate(false) .consider(Curve::Linear, Score::MAX),
876 );
877 r.add(Action::new("defer").consider(Curve::Linear, Score::from_raw(1_000)));
878 assert_eq!(r.decide().unwrap().id, "defer");
879 }
880
881 #[test]
882 fn explain_surfaces_gated_winner() {
883 let mut r = Reasoner::new();
884 r.add(
885 Action::new("only")
886 .gate(false)
887 .consider(Curve::Linear, Score::MAX),
888 );
889 let ex = r.explain().unwrap();
890 assert_eq!(ex.id, "only");
891 assert!(!ex.allowed); assert_eq!(ex.utility, Score::ZERO);
893 }
894
895 #[test]
896 fn gated_action_excluded_from_weighted() {
897 let mut r = Reasoner::new();
898 r.add(
899 Action::new("blocked")
900 .gate(false)
901 .consider(Curve::Linear, Score::MAX),
902 );
903 r.add(Action::new("open").consider(Curve::Linear, Score::from_raw(5_000)));
904 assert_eq!(r.decide_weighted(0).unwrap().id, "open");
906 }
907
908 #[test]
909 fn decide_above_abstains_below_threshold() {
910 let mut r = Reasoner::new();
911 r.add(Action::new("weak").consider(Curve::Linear, Score::from_raw(3_000))); assert_eq!(r.decide_above(Score::from_raw(2_000)).unwrap().id, "weak"); assert!(r.decide_above(Score::from_raw(3_000)).is_none()); assert!(r.decide_above(Score::from_raw(5_000)).is_none()); }
916
917 #[test]
918 fn decide_above_abstains_when_all_gated() {
919 let mut r = Reasoner::new();
920 r.add(
921 Action::new("a")
922 .gate(false)
923 .consider(Curve::Linear, Score::MAX),
924 );
925 r.add(
926 Action::new("b")
927 .gate(false)
928 .consider(Curve::Linear, Score::MAX),
929 );
930 assert!(r.decide_above(Score::ZERO).is_none()); assert_eq!(r.decide().unwrap().id, "a"); }
933
934 #[test]
935 fn decide_above_empty_is_none() {
936 let r: Reasoner<&str> = Reasoner::new();
937 assert!(r.decide_above(Score::ZERO).is_none());
938 }
939
940 #[test]
941 fn personas_emerge_from_per_agent_policy_keys() {
942 let mut p = Policy::new(Score::MAX, Score::from_ratio(1, 2)); p.reward(("vale", "trade"), Score::MAX); p.reward(("mason", "trade"), Score::ZERO); let vale = Action::new("trade").with_base(p.weight(&("vale", "trade")));
949 let mason = Action::new("trade").with_base(p.weight(&("mason", "trade")));
950 assert_eq!(vale.utility(), Score::MAX); assert_eq!(mason.utility(), Score::ZERO);
952 }
953}