#![cfg_attr(not(feature = "std"), no_std)]
#![forbid(unsafe_code)]
#![warn(missing_docs)]
extern crate alloc;
use alloc::vec::Vec;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Score(u32);
impl Score {
pub const SCALE: u32 = 10_000;
pub const ZERO: Score = Score(0);
pub const MAX: Score = Score(Self::SCALE);
pub const fn from_raw(raw: u32) -> Score {
Score(if raw > Self::SCALE { Self::SCALE } else { raw })
}
pub const fn raw(self) -> u32 {
self.0
}
pub const fn from_ratio(num: u32, den: u32) -> Score {
if den == 0 {
Score::ZERO
} else {
let v = (num as u64 * Self::SCALE as u64) / den as u64;
Score::from_raw(if v > Self::SCALE as u64 {
Self::SCALE
} else {
v as u32
})
}
}
pub const fn mul(self, other: Score) -> Score {
Score(((self.0 as u64 * other.0 as u64) / Self::SCALE as u64) as u32)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Curve {
Constant(Score),
Linear,
Inverse,
Quadratic,
Threshold {
at: Score,
below: Score,
above: Score,
},
}
impl Curve {
pub const fn eval(self, input: Score) -> Score {
match self {
Curve::Constant(s) => s,
Curve::Linear => input,
Curve::Inverse => Score(Score::SCALE.saturating_sub(input.0)),
Curve::Quadratic => input.mul(input),
Curve::Threshold { at, below, above } => {
if input.0 >= at.0 {
above
} else {
below
}
}
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Consideration {
pub label: &'static str,
pub curve: Curve,
pub input: Score,
}
impl Consideration {
pub const fn new(curve: Curve, input: Score) -> Consideration {
Consideration {
label: "",
curve,
input,
}
}
pub const fn labeled(label: &'static str, curve: Curve, input: Score) -> Consideration {
Consideration {
label,
curve,
input,
}
}
pub const fn score(self) -> Score {
self.curve.eval(self.input)
}
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct Action<A> {
pub id: A,
pub base: Score,
pub allowed: bool,
pub considerations: Vec<Consideration>,
}
impl<A> Action<A> {
pub fn new(id: A) -> Action<A> {
Action {
id,
base: Score::MAX,
allowed: true,
considerations: Vec::new(),
}
}
pub fn gate(mut self, allowed: bool) -> Action<A> {
self.allowed = self.allowed && allowed;
self
}
pub fn with_base(mut self, base: Score) -> Action<A> {
self.base = base;
self
}
pub fn consider(mut self, curve: Curve, input: Score) -> Action<A> {
self.considerations.push(Consideration::new(curve, input));
self
}
pub fn consider_labeled(
mut self,
label: &'static str,
curve: Curve,
input: Score,
) -> Action<A> {
self.considerations
.push(Consideration::labeled(label, curve, input));
self
}
pub fn utility(&self) -> Score {
if !self.allowed {
return Score::ZERO;
}
let mut u = self.base;
for c in &self.considerations {
u = u.mul(c.score());
}
u
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Decision<A> {
pub id: A,
pub utility: Score,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Contribution {
pub label: &'static str,
pub input: Score,
pub output: Score,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Explanation<A> {
pub id: A,
pub allowed: bool,
pub utility: Score,
pub contributions: Vec<Contribution>,
}
#[derive(Debug, Clone)]
pub struct Reasoner<A> {
actions: Vec<Action<A>>,
}
impl<A> Default for Reasoner<A> {
fn default() -> Self {
Reasoner {
actions: Vec::new(),
}
}
}
impl<A> Reasoner<A> {
pub fn new() -> Reasoner<A> {
Reasoner::default()
}
pub fn add(&mut self, action: Action<A>) -> &mut Self {
self.actions.push(action);
self
}
pub fn len(&self) -> usize {
self.actions.len()
}
pub fn is_empty(&self) -> bool {
self.actions.is_empty()
}
fn best_index(&self) -> Option<usize> {
let mut best: Option<usize> = None;
let mut best_u = Score::ZERO;
for (i, a) in self.actions.iter().enumerate() {
let u = a.utility();
if best.is_none() || u > best_u {
best = Some(i);
best_u = u;
}
}
best
}
}
impl<A: Clone> Reasoner<A> {
pub fn decide(&self) -> Option<Decision<A>> {
self.best_index().map(|i| Decision {
id: self.actions[i].id.clone(),
utility: self.actions[i].utility(),
})
}
pub fn decide_above(&self, threshold: Score) -> Option<Decision<A>> {
self.best_index().and_then(|i| {
let utility = self.actions[i].utility();
if utility > threshold {
Some(Decision {
id: self.actions[i].id.clone(),
utility,
})
} else {
None
}
})
}
pub fn decide_weighted(&self, rand: u32) -> Option<Decision<A>> {
if self.actions.is_empty() {
return None;
}
let total: u64 = self.actions.iter().map(|a| a.utility().raw() as u64).sum();
if total == 0 {
let a = &self.actions[0];
return Some(Decision {
id: a.id.clone(),
utility: a.utility(),
});
}
let target = ((rand as u128 * total as u128) >> 32) as u64;
let mut cumulative: u64 = 0;
for a in &self.actions {
cumulative += a.utility().raw() as u64;
if target < cumulative {
return Some(Decision {
id: a.id.clone(),
utility: a.utility(),
});
}
}
let a = &self.actions[self.actions.len() - 1];
Some(Decision {
id: a.id.clone(),
utility: a.utility(),
})
}
pub fn explain(&self) -> Option<Explanation<A>> {
self.best_index().map(|i| {
let a = &self.actions[i];
let contributions = a
.considerations
.iter()
.map(|c| Contribution {
label: c.label,
input: c.input,
output: c.score(),
})
.collect();
Explanation {
id: a.id.clone(),
allowed: a.allowed,
utility: a.utility(),
contributions,
}
})
}
pub fn rank(&self) -> Vec<Decision<A>> {
let mut out: Vec<Decision<A>> = self
.actions
.iter()
.map(|a| Decision {
id: a.id.clone(),
utility: a.utility(),
})
.collect();
out.sort_by_key(|d| core::cmp::Reverse(d.utility));
out
}
}
#[derive(Debug, Clone)]
pub struct Policy<K> {
entries: Vec<(K, Score)>,
rate: Score,
default: Score,
}
impl<K> Policy<K> {
pub fn new(rate: Score, default: Score) -> Policy<K> {
Policy {
entries: Vec::new(),
rate,
default,
}
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn entries(&self) -> &[(K, Score)] {
&self.entries
}
fn step(rate: Score, current: Score, outcome: Score) -> Score {
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;
let clamped = moved.clamp(0, Score::SCALE as i64);
Score::from_raw(clamped as u32)
}
}
impl<K: PartialEq> Policy<K> {
pub fn weight(&self, key: &K) -> Score {
self.entries
.iter()
.find(|(k, _)| k == key)
.map(|(_, w)| *w)
.unwrap_or(self.default)
}
pub fn reward(&mut self, key: K, outcome: Score) {
if let Some(entry) = self.entries.iter_mut().find(|(k, _)| *k == key) {
entry.1 = Self::step(self.rate, entry.1, outcome);
} else {
let moved = Self::step(self.rate, self.default, outcome);
self.entries.push((key, moved));
}
}
pub fn set(&mut self, key: K, weight: Score) {
if let Some(entry) = self.entries.iter_mut().find(|(k, _)| *k == key) {
entry.1 = weight;
} else {
self.entries.push((key, weight));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn score_ratio_clamp_and_mul() {
assert_eq!(Score::from_ratio(1, 2).raw(), 5_000);
assert_eq!(Score::from_ratio(3, 0), Score::ZERO);
assert_eq!(Score::from_raw(99_999), Score::MAX); assert_eq!(Score::MAX.mul(Score::from_raw(5_000)).raw(), 5_000); assert_eq!(
Score::from_raw(5_000).mul(Score::from_raw(5_000)).raw(),
2_500
); }
#[test]
fn curves_eval_exactly() {
let x = Score::from_raw(3_000);
assert_eq!(Curve::Linear.eval(x), x);
assert_eq!(Curve::Inverse.eval(x).raw(), 7_000);
assert_eq!(Curve::Quadratic.eval(Score::from_raw(5_000)).raw(), 2_500);
assert_eq!(Curve::Constant(Score::MAX).eval(Score::ZERO), Score::MAX);
let step = Curve::Threshold {
at: Score::from_raw(5_000),
below: Score::ZERO,
above: Score::MAX,
};
assert_eq!(step.eval(Score::from_raw(4_999)), Score::ZERO);
assert_eq!(step.eval(Score::from_raw(5_000)), Score::MAX);
}
#[test]
fn utility_is_product_veto() {
let vetoed = Action::new(())
.consider(Curve::Linear, Score::MAX)
.consider(Curve::Linear, Score::ZERO);
assert_eq!(vetoed.utility(), Score::ZERO);
let a = Action::new(())
.consider(Curve::Linear, Score::from_raw(8_000))
.consider(Curve::Linear, Score::from_raw(5_000));
assert_eq!(a.utility().raw(), 4_000);
}
#[test]
fn decide_picks_highest_and_breaks_ties_by_order() {
let mut r = Reasoner::new();
r.add(Action::new("a").consider(Curve::Linear, Score::from_raw(3_000)));
r.add(Action::new("b").consider(Curve::Linear, Score::from_raw(9_000)));
assert_eq!(r.decide().unwrap().id, "b");
let mut t = Reasoner::new();
t.add(Action::new("first").consider(Curve::Linear, Score::from_raw(5_000)));
t.add(Action::new("second").consider(Curve::Linear, Score::from_raw(5_000)));
assert_eq!(t.decide().unwrap().id, "first");
}
#[test]
fn decide_on_empty_is_none() {
let r: Reasoner<&str> = Reasoner::new();
assert!(r.decide().is_none());
assert!(r.is_empty());
}
#[test]
fn rank_orders_descending_stably() {
let mut r = Reasoner::new();
r.add(Action::new("low").consider(Curve::Linear, Score::from_raw(2_000)));
r.add(Action::new("high").consider(Curve::Linear, Score::from_raw(8_000)));
r.add(Action::new("mid").consider(Curve::Linear, Score::from_raw(5_000)));
let ranked = r.rank();
let ids: Vec<&str> = ranked.iter().map(|d| d.id).collect();
assert_eq!(ids, ["high", "mid", "low"]);
}
#[test]
fn with_base_scales_and_vetoes() {
let scaled = Action::new(())
.with_base(Score::from_raw(5_000))
.consider(Curve::Linear, Score::from_raw(8_000));
assert_eq!(scaled.utility().raw(), 4_000);
let vetoed = Action::new(())
.with_base(Score::ZERO)
.consider(Curve::Linear, Score::MAX);
assert_eq!(vetoed.utility(), Score::ZERO);
}
#[test]
fn explain_breaks_down_the_winner() {
let health = Score::from_ratio(20, 100);
let mut r = Reasoner::new();
r.add(Action::new("flee").consider_labeled("low_health", Curve::Inverse, health));
r.add(Action::new("fight").consider_labeled("high_health", Curve::Linear, health));
let ex = r.explain().unwrap();
assert_eq!(ex.id, "flee");
assert_eq!(ex.utility.raw(), 8_000); assert_eq!(ex.contributions.len(), 1);
assert_eq!(ex.contributions[0].label, "low_health");
assert_eq!(ex.contributions[0].input, health);
assert_eq!(ex.contributions[0].output.raw(), 8_000);
}
#[test]
fn explain_on_empty_is_none() {
let r: Reasoner<&str> = Reasoner::new();
assert!(r.explain().is_none());
}
#[test]
fn explain_lists_all_considerations_in_order() {
let mut r = Reasoner::new();
r.add(
Action::new("act")
.consider_labeled("a", Curve::Linear, Score::from_raw(8_000))
.consider_labeled("b", Curve::Linear, Score::from_raw(5_000)),
);
let ex = r.explain().unwrap();
assert_eq!(ex.utility.raw(), 4_000); let labels: Vec<&str> = ex.contributions.iter().map(|c| c.label).collect();
assert_eq!(labels, ["a", "b"]); assert_eq!(ex.contributions[0].output.raw(), 8_000);
assert_eq!(ex.contributions[1].output.raw(), 5_000);
}
#[test]
fn rank_keeps_declaration_order_on_ties() {
let mut r = Reasoner::new();
r.add(Action::new("first").consider(Curve::Linear, Score::from_raw(5_000)));
r.add(Action::new("second").consider(Curve::Linear, Score::from_raw(5_000)));
let ids: Vec<&str> = r.rank().iter().map(|d| d.id).collect();
assert_eq!(ids, ["first", "second"]); }
#[test]
fn from_ratio_above_one_clamps() {
assert_eq!(Score::from_ratio(3, 2), Score::MAX); assert_eq!(Score::from_ratio(10, 10), Score::MAX); }
#[test]
fn quadratic_extremes() {
assert_eq!(Curve::Quadratic.eval(Score::MAX), Score::MAX); assert_eq!(Curve::Quadratic.eval(Score::ZERO), Score::ZERO); }
#[test]
fn decide_weighted_is_proportional_and_deterministic() {
let mut r = Reasoner::new();
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");
assert_eq!(r.decide_weighted(u32::MAX).unwrap().id, "b");
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!(
r.decide_weighted(1_234_567).unwrap().id,
r.decide_weighted(1_234_567).unwrap().id
);
}
#[test]
fn decide_weighted_zero_total_returns_first() {
let mut r = Reasoner::new();
r.add(Action::new("x").with_base(Score::ZERO));
r.add(Action::new("y").with_base(Score::ZERO));
assert_eq!(r.decide_weighted(999).unwrap().id, "x");
}
#[test]
fn decide_weighted_empty_is_none() {
let r: Reasoner<&str> = Reasoner::new();
assert!(r.decide_weighted(0).is_none());
}
#[test]
fn policy_unseen_key_returns_default() {
let p: Policy<&str> = Policy::new(Score::from_ratio(1, 2), Score::from_raw(3_000));
assert_eq!(p.weight(&"x").raw(), 3_000);
assert!(p.is_empty());
}
#[test]
fn policy_reward_moves_toward_outcome_and_converges() {
let mut p = Policy::new(Score::from_ratio(1, 2), Score::from_ratio(1, 2));
p.reward("a", Score::MAX); assert_eq!(p.weight(&"a").raw(), 7_500);
p.reward("a", Score::MAX); assert_eq!(p.weight(&"a").raw(), 8_750);
for _ in 0..50 {
p.reward("a", Score::MAX);
}
assert!(p.weight(&"a").raw() > 9_900);
assert!(p.weight(&"a").raw() <= Score::SCALE);
}
#[test]
fn policy_rate_extremes() {
let mut still = Policy::new(Score::ZERO, Score::from_ratio(1, 2));
still.reward("a", Score::MAX);
assert_eq!(still.weight(&"a").raw(), 5_000);
let mut fast = Policy::new(Score::MAX, Score::from_ratio(1, 2));
fast.reward("a", Score::from_raw(2_000));
assert_eq!(fast.weight(&"a").raw(), 2_000);
}
#[test]
fn policy_reward_toward_zero_clamps_at_zero() {
let mut p = Policy::new(Score::MAX, Score::from_ratio(1, 2)); p.reward("a", Score::ZERO);
assert_eq!(p.weight(&"a"), Score::ZERO);
}
#[test]
fn policy_reward_same_key_updates_in_place() {
let mut p = Policy::new(Score::from_ratio(1, 2), Score::from_ratio(1, 2));
p.reward("a", Score::MAX);
p.reward("a", Score::MAX); assert_eq!(p.len(), 1); p.reward("b", Score::MAX); assert_eq!(p.len(), 2);
}
#[test]
fn policy_set_replaces_existing() {
let mut p = Policy::new(Score::MAX, Score::ZERO);
p.set("a", Score::from_raw(1_000));
p.set("a", Score::from_raw(9_000)); assert_eq!(p.len(), 1);
assert_eq!(p.weight(&"a").raw(), 9_000);
}
#[test]
fn policy_entries_snapshot_round_trips_via_set() {
let mut p = Policy::new(Score::from_ratio(1, 2), Score::ZERO);
p.reward("a", Score::MAX);
p.set("b", Score::from_raw(3_000));
let saved: Vec<(&str, Score)> = p.entries().to_vec();
let mut restored = Policy::new(Score::from_ratio(1, 2), Score::ZERO);
for (k, w) in saved {
restored.set(k, w);
}
assert_eq!(restored.weight(&"a"), p.weight(&"a"));
assert_eq!(restored.weight(&"b").raw(), 3_000);
assert_eq!(restored.len(), p.len());
}
#[test]
fn gate_vetoes_and_combines() {
let ok = Action::new("x")
.gate(true)
.consider(Curve::Linear, Score::from_raw(8_000));
assert_eq!(ok.utility().raw(), 8_000);
let blocked = Action::new("x")
.gate(false)
.consider(Curve::Linear, Score::MAX);
assert_eq!(blocked.utility(), Score::ZERO);
assert!(Action::new("x").gate(true).gate(true).allowed);
assert!(!Action::new("x").gate(true).gate(false).allowed);
}
#[test]
fn gated_action_loses_to_ungated_fallback() {
let mut r = Reasoner::new();
r.add(
Action::new("call_llm")
.gate(false) .consider(Curve::Linear, Score::MAX),
);
r.add(Action::new("defer").consider(Curve::Linear, Score::from_raw(1_000)));
assert_eq!(r.decide().unwrap().id, "defer");
}
#[test]
fn explain_surfaces_gated_winner() {
let mut r = Reasoner::new();
r.add(
Action::new("only")
.gate(false)
.consider(Curve::Linear, Score::MAX),
);
let ex = r.explain().unwrap();
assert_eq!(ex.id, "only");
assert!(!ex.allowed); assert_eq!(ex.utility, Score::ZERO);
}
#[test]
fn gated_action_excluded_from_weighted() {
let mut r = Reasoner::new();
r.add(
Action::new("blocked")
.gate(false)
.consider(Curve::Linear, Score::MAX),
);
r.add(Action::new("open").consider(Curve::Linear, Score::from_raw(5_000)));
assert_eq!(r.decide_weighted(0).unwrap().id, "open");
}
#[test]
fn decide_above_abstains_below_threshold() {
let mut r = Reasoner::new();
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()); }
#[test]
fn decide_above_abstains_when_all_gated() {
let mut r = Reasoner::new();
r.add(
Action::new("a")
.gate(false)
.consider(Curve::Linear, Score::MAX),
);
r.add(
Action::new("b")
.gate(false)
.consider(Curve::Linear, Score::MAX),
);
assert!(r.decide_above(Score::ZERO).is_none()); assert_eq!(r.decide().unwrap().id, "a"); }
#[test]
fn decide_above_empty_is_none() {
let r: Reasoner<&str> = Reasoner::new();
assert!(r.decide_above(Score::ZERO).is_none());
}
#[test]
fn personas_emerge_from_per_agent_policy_keys() {
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")));
let mason = Action::new("trade").with_base(p.weight(&("mason", "trade")));
assert_eq!(vale.utility(), Score::MAX); assert_eq!(mason.utility(), Score::ZERO);
}
}