Skip to main content

encounter_argumentation/
value_scorer.rs

1//! Value-aware action scoring.
2//!
3//! [`ValueAwareScorer`] wraps an inner [`ActionScorer`] and adds a per-
4//! affordance boost proportional to how strongly the actor's audience
5//! prefers the values the affordance's backing scheme promotes.
6//!
7//! # Composition
8//!
9//! Designed to wrap [`crate::SchemeActionScorer`] (which itself wraps a
10//! baseline scorer):
11//!
12//! ```ignore
13//! let scorer = ValueAwareScorer::new(
14//!     SchemeActionScorer::new(knowledge, registry, baseline, 0.3),
15//!     state,
16//!     0.2,
17//! );
18//! ```
19//!
20//! The two boosts compose additively: scheme-strength boost first, then
21//! value-preference boost. Both are skipped silently when the actor has
22//! no configured audience (i.e., no VAF dimension on this character).
23
24use crate::state::EncounterArgumentationState;
25use encounter::affordance::CatalogEntry;
26use encounter::scoring::{ActionScorer, ScoredAffordance};
27
28/// An [`ActionScorer`] that boosts affordances by audience-conditioned
29/// value preference.
30pub struct ValueAwareScorer<'a, S> {
31    inner: S,
32    state: &'a EncounterArgumentationState,
33    max_boost: f64,
34}
35
36impl<'a, S> ValueAwareScorer<'a, S> {
37    /// Construct a new value-aware scorer.
38    ///
39    /// # Parameters
40    /// - `inner` — the scorer to wrap (typically `SchemeActionScorer`).
41    /// - `state` — the encounter state holding per-actor audiences.
42    /// - `max_boost` — additive boost when the actor's most-preferred
43    ///   value is promoted by the affordance's scheme. Scaled linearly
44    ///   downward by audience tier rank.
45    pub fn new(inner: S, state: &'a EncounterArgumentationState, max_boost: f64) -> Self {
46        Self {
47            inner,
48            state,
49            max_boost,
50        }
51    }
52}
53
54impl<'a, S, P> ActionScorer<P> for ValueAwareScorer<'a, S>
55where
56    S: ActionScorer<P>,
57    P: Clone,
58{
59    fn score_actions(
60        &self,
61        actor: &str,
62        available: &[CatalogEntry<P>],
63        participants: &[String],
64    ) -> Vec<ScoredAffordance<P>> {
65        let mut scored = self.inner.score_actions(actor, available, participants);
66
67        let Some(audience) = self.state.audience_for(actor) else {
68            // No audience for this actor → behave as the inner scorer.
69            return scored;
70        };
71
72        // Apply value boost per affordance.
73        for sa in &mut scored {
74            sa.score += value_boost_for_affordance(
75                &sa.bindings,
76                &audience,
77                self.max_boost,
78            );
79        }
80        scored.sort_by(|a, b| {
81            b.score
82                .partial_cmp(&a.score)
83                .unwrap_or(std::cmp::Ordering::Equal)
84        });
85        scored
86    }
87}
88
89/// If the affordance's bindings include a `value` slot (as
90/// `argument_from_values` schemes do), and that value is ranked in the
91/// audience, returns a positive boost scaled by tier rank. Returns 0.0
92/// otherwise.
93///
94/// Scaling: tier 0 (most preferred) → `max_boost`; deeper tiers scale
95/// linearly down toward `max_boost / tier_count` at the bottom tier.
96/// Unranked values get 0.
97fn value_boost_for_affordance(
98    bindings: &std::collections::HashMap<String, String>,
99    audience: &argumentation_values::Audience,
100    max_boost: f64,
101) -> f64 {
102    let Some(value_str) = bindings.get("value") else {
103        return 0.0;
104    };
105    let value = argumentation_values::Value::new(value_str.clone());
106    let tier_count = audience.tier_count();
107    if tier_count == 0 {
108        return 0.0;
109    }
110    let Some(rank) = audience.rank(&value) else {
111        return 0.0;
112    };
113    let normalised = (tier_count - rank) as f64 / tier_count as f64;
114    max_boost * normalised
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use argumentation_schemes::catalog::default_catalog;
121    use argumentation_values::{Audience, Value};
122    use encounter::affordance::AffordanceSpec;
123    use encounter::scoring::ScoredAffordance;
124    use std::collections::HashMap;
125
126    /// Test scorer that returns one fixed-score result for every affordance,
127    /// with a value binding set to `value_promoted`.
128    struct StubScorer {
129        value_promoted: String,
130    }
131
132    impl<P: Clone> ActionScorer<P> for StubScorer {
133        fn score_actions(
134            &self,
135            actor: &str,
136            available: &[CatalogEntry<P>],
137            _participants: &[String],
138        ) -> Vec<ScoredAffordance<P>> {
139            available
140                .iter()
141                .map(|entry| {
142                    let mut bindings = HashMap::new();
143                    bindings.insert("self".into(), actor.into());
144                    bindings.insert("value".into(), self.value_promoted.clone());
145                    ScoredAffordance {
146                        entry: entry.clone(),
147                        score: 1.0,
148                        bindings,
149                    }
150                })
151                .collect()
152        }
153    }
154
155    fn dummy_entry() -> CatalogEntry<()> {
156        CatalogEntry {
157            spec: AffordanceSpec {
158                name: "test_affordance".into(),
159                domain: "test".into(),
160                bindings: vec!["self".into(), "value".into()],
161                considerations: Vec::new(),
162                effects_on_accept: Vec::new(),
163                effects_on_reject: Vec::new(),
164                drive_alignment: Vec::new(),
165            },
166            precondition: (),
167        }
168    }
169
170    #[test]
171    fn no_audience_means_no_boost() {
172        let registry = default_catalog();
173        let state = EncounterArgumentationState::new(registry);
174        let inner = StubScorer { value_promoted: "honor".into() };
175        let scorer = ValueAwareScorer::new(inner, &state, 0.5);
176        let entries = vec![dummy_entry()];
177        let scored = scorer.score_actions("alice", &entries, &["alice".into()]);
178        assert_eq!(scored.len(), 1);
179        assert!((scored[0].score - 1.0).abs() < 1e-9);
180    }
181
182    #[test]
183    fn boost_proportional_to_tier_position() {
184        let registry = default_catalog();
185        let state = EncounterArgumentationState::new(registry);
186        state.set_audience(
187            "alice",
188            Audience::total([Value::new("honor"), Value::new("safety")]),
189        );
190        let inner = StubScorer { value_promoted: "honor".into() };
191        let scorer = ValueAwareScorer::new(inner, &state, 0.5);
192        let entries = vec![dummy_entry()];
193        let scored = scorer.score_actions("alice", &entries, &["alice".into()]);
194        // honor is at tier 0 of 2 tiers → normalised = (2-0)/2 = 1.0.
195        // boost = 0.5 * 1.0 = 0.5; total = 1.0 + 0.5 = 1.5.
196        assert!((scored[0].score - 1.5).abs() < 1e-9, "got {}", scored[0].score);
197    }
198
199    #[test]
200    fn unranked_value_gets_no_boost() {
201        let registry = default_catalog();
202        let state = EncounterArgumentationState::new(registry);
203        state.set_audience(
204            "alice",
205            Audience::total([Value::new("life")]),
206        );
207        let inner = StubScorer { value_promoted: "tradition".into() };
208        let scorer = ValueAwareScorer::new(inner, &state, 0.5);
209        let entries = vec![dummy_entry()];
210        let scored = scorer.score_actions("alice", &entries, &["alice".into()]);
211        assert!((scored[0].score - 1.0).abs() < 1e-9);
212    }
213
214    #[test]
215    fn lower_tier_gets_smaller_boost() {
216        let registry = default_catalog();
217        let state = EncounterArgumentationState::new(registry);
218        state.set_audience(
219            "alice",
220            Audience::total([
221                Value::new("honor"),
222                Value::new("safety"),
223                Value::new("comfort"),
224            ]),
225        );
226        let inner = StubScorer { value_promoted: "comfort".into() };
227        let scorer = ValueAwareScorer::new(inner, &state, 0.5);
228        let entries = vec![dummy_entry()];
229        let scored = scorer.score_actions("alice", &entries, &["alice".into()]);
230        // comfort at tier 2 of 3 tiers → normalised = (3-2)/3 ≈ 0.333.
231        // boost ≈ 0.5 * 0.333 ≈ 0.167; total ≈ 1.167.
232        assert!((scored[0].score - 1.1666666666).abs() < 1e-3, "got {}", scored[0].score);
233    }
234}