Skip to main content

encounter_argumentation/
state_scorer.rs

1//! `StateActionScorer`: encounter's `ActionScorer` impl backed by an
2//! [`EncounterArgumentationState`].
3//!
4//! The scorer composes:
5//! 1. An inner `ActionScorer<P>` that produces base scores.
6//! 2. A reference to a live `EncounterArgumentationState`.
7//!
8//! After the inner scorer runs, each scored affordance is amplified
9//! by `boost * state.is_credulously_accepted(arg_id)` where the
10//! argument id is looked up from the affordance's forward-index key.
11//! Affordances with no seeded argument receive no boost (inner score
12//! unchanged).
13//!
14//! This gives the *proposer* bias: actions whose own argument is
15//! credulously acceptable at the scene's current β get boosted,
16//! making the scene self-consistent with the argumentation state.
17//! The per-responder gate is orthogonal and lives in
18//! [`crate::state_acceptance::StateAcceptanceEval`].
19
20use crate::affordance_key::AffordanceKey;
21use crate::state::EncounterArgumentationState;
22use encounter::affordance::CatalogEntry;
23use encounter::scoring::{ActionScorer, ScoredAffordance};
24
25/// An [`ActionScorer<P>`] composing an inner scorer with a shared-ref
26/// view of an [`EncounterArgumentationState`].
27pub struct StateActionScorer<'a, S> {
28    state: &'a EncounterArgumentationState,
29    inner: S,
30    boost: f64,
31}
32
33impl<'a, S> StateActionScorer<'a, S> {
34    /// Construct a state-aware scorer wrapping `inner`. `boost` is
35    /// the additive score added to any affordance whose argument is
36    /// credulously accepted at the current β. Typical values: 0.3–1.0.
37    #[must_use]
38    pub fn new(state: &'a EncounterArgumentationState, inner: S, boost: f64) -> Self {
39        Self { state, inner, boost }
40    }
41}
42
43impl<S, P> ActionScorer<P> for StateActionScorer<'_, S>
44where
45    S: ActionScorer<P>,
46    P: Clone,
47{
48    fn score_actions(
49        &self,
50        actor: &str,
51        available: &[CatalogEntry<P>],
52        participants: &[String],
53    ) -> Vec<ScoredAffordance<P>> {
54        let mut scored = self.inner.score_actions(actor, available, participants);
55        for sa in &mut scored {
56            let key = AffordanceKey::new(actor, &sa.entry.spec.name, &sa.bindings);
57            let Some(id) = self.state.argument_id_for(&key) else {
58                continue;
59            };
60            match self.state.is_credulously_accepted(&id) {
61                Ok(true) => sa.score += self.boost,
62                Ok(false) => {}
63                Err(e) => {
64                    self.state.record_error(e);
65                }
66            }
67        }
68        scored
69    }
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75    use argumentation_schemes::catalog::default_catalog;
76    use encounter::affordance::AffordanceSpec;
77    use std::collections::HashMap;
78
79    struct FlatInner;
80    impl<P: Clone> ActionScorer<P> for FlatInner {
81        fn score_actions(
82            &self,
83            _actor: &str,
84            available: &[CatalogEntry<P>],
85            _participants: &[String],
86        ) -> Vec<ScoredAffordance<P>> {
87            available
88                .iter()
89                .map(|e| {
90                    let mut bindings = HashMap::new();
91                    bindings.insert("self".to_string(), "alice".to_string());
92                    bindings.insert("expert".to_string(), "alice".to_string());
93                    bindings.insert("domain".to_string(), "military".to_string());
94                    bindings.insert("claim".to_string(), "fortify_east".to_string());
95                    ScoredAffordance {
96                        entry: e.clone(),
97                        score: 1.0,
98                        bindings,
99                    }
100                })
101                .collect()
102        }
103    }
104
105    fn catalog() -> Vec<CatalogEntry<String>> {
106        let spec = AffordanceSpec {
107            name: "argue_fortify_east".to_string(),
108            domain: "persuasion".to_string(),
109            bindings: vec![
110                "self".to_string(),
111                "expert".to_string(),
112                "domain".to_string(),
113                "claim".to_string(),
114            ],
115            considerations: Vec::new(),
116            effects_on_accept: Vec::new(),
117            effects_on_reject: Vec::new(),
118            drive_alignment: Vec::new(),
119        };
120        vec![CatalogEntry { spec, precondition: String::new() }]
121    }
122
123    #[test]
124    fn unboosted_when_no_argument_is_seeded() {
125        let state = EncounterArgumentationState::new(default_catalog());
126        let scorer = StateActionScorer::new(&state, FlatInner, 0.5);
127        let catalog_vec = catalog();
128        let scored = scorer.score_actions("alice", &catalog_vec, &["alice".to_string()]);
129        assert_eq!(scored.len(), 1);
130        assert!((scored[0].score - 1.0).abs() < 1e-9);
131    }
132
133    #[test]
134    fn boosted_when_argument_is_credulously_accepted() {
135        let registry = default_catalog();
136        let scheme = registry.by_key("argument_from_expert_opinion").unwrap();
137        let mut b = HashMap::new();
138        b.insert("expert".to_string(), "alice".to_string());
139        b.insert("domain".to_string(), "military".to_string());
140        b.insert("claim".to_string(), "fortify_east".to_string());
141        let instance = scheme.instantiate(&b).unwrap();
142        let mut state = EncounterArgumentationState::new(registry);
143        let mut affordance_b = b.clone();
144        affordance_b.insert("self".to_string(), "alice".to_string());
145        state.add_scheme_instance_for_affordance(
146            "alice",
147            "argue_fortify_east",
148            &affordance_b,
149            instance,
150        );
151
152        let scorer = StateActionScorer::new(&state, FlatInner, 0.5);
153        let catalog_vec = catalog();
154        let scored = scorer.score_actions("alice", &catalog_vec, &["alice".to_string()]);
155        assert_eq!(scored.len(), 1);
156        // inner gave 1.0; argument is unattacked → credulously accepted → +0.5.
157        assert!(
158            (scored[0].score - 1.5).abs() < 1e-9,
159            "expected 1.5, got {}",
160            scored[0].score
161        );
162    }
163
164    #[test]
165    fn not_boosted_when_argument_is_attacked_above_budget() {
166        // Seed an argument, attach an attacker heavier than the current
167        // budget, and verify: (a) the argument is NOT credulously
168        // accepted, so (b) the scorer does not apply the boost.
169        use crate::arg_id::ArgumentId;
170
171        let registry = default_catalog();
172        let scheme = registry.by_key("argument_from_expert_opinion").unwrap();
173        let mut b = HashMap::new();
174        b.insert("expert".to_string(), "alice".to_string());
175        b.insert("domain".to_string(), "military".to_string());
176        b.insert("claim".to_string(), "fortify_east".to_string());
177        let instance = scheme.instantiate(&b).unwrap();
178
179        let mut state = EncounterArgumentationState::new(registry);
180        let mut affordance_b = b.clone();
181        affordance_b.insert("self".to_string(), "alice".to_string());
182        let alice_id = state.add_scheme_instance_for_affordance(
183            "alice",
184            "argue_fortify_east",
185            &affordance_b,
186            instance,
187        );
188        // Attacker weight 0.8 > default β=0 → attack binds, argument not credulous.
189        state
190            .add_weighted_attack(&ArgumentId::new("attacker"), &alice_id, 0.8)
191            .unwrap();
192
193        let scorer = StateActionScorer::new(&state, FlatInner, 0.5);
194        let catalog_vec = catalog();
195        let scored = scorer.score_actions("alice", &catalog_vec, &["alice".to_string()]);
196        assert_eq!(scored.len(), 1);
197        // Attack binds at β=0 → argument NOT credulously accepted → score stays at 1.0.
198        assert!(
199            (scored[0].score - 1.0).abs() < 1e-9,
200            "expected 1.0 (no boost), got {}",
201            scored[0].score
202        );
203    }
204}