Skip to main content

encounter_argumentation/
state_acceptance.rs

1//! `StateAcceptanceEval`: encounter's `AcceptanceEval` impl backed by
2//! an [`EncounterArgumentationState`]'s current β-intensity.
3//!
4//! The eval rejects a proposed action iff the responder asserts a
5//! credulously-accepted attacker of the proposed argument at the
6//! current scene intensity. Otherwise it accepts.
7//!
8//! Internal errors (e.g. weighted-bipolar edge limit exceeded) cause
9//! the eval to default to *accept* and append the error to the state's
10//! buffer; consumers call `state.drain_errors()` to drain.
11//!
12//! ## Proposer-slot convention
13//!
14//! `StateAcceptanceEval` looks up the proposer's argument using the
15//! binding slot `"self"`. If your catalog uses a different convention
16//! (e.g., `"speaker"` or `"initiator"`), the eval will silently default
17//! to *accept* for those affordances. This is intentional —
18//! affordances without a seeded argument have nothing to adjudicate.
19//! Consumers who use a different slot name should seed their
20//! affordances with `"self"` bindings pointing at the proposer, or
21//! wrap this eval with a custom version that remaps the slot.
22
23use crate::affordance_key::AffordanceKey;
24use crate::state::EncounterArgumentationState;
25use encounter::scoring::{AcceptanceEval, ScoredAffordance};
26
27/// An [`AcceptanceEval<P>`] backed by a shared reference to a live
28/// [`EncounterArgumentationState`].
29///
30/// The eval uses [`EncounterArgumentationState::has_accepted_counter_by`]
31/// to decide whether the responder has a credulously-accepted counter
32/// to the proposed action's argument at current β. If the action has
33/// no seeded argument in the state, the eval accepts (falls back to
34/// permissive — there's no argumentation claim against which to
35/// adjudicate).
36pub struct StateAcceptanceEval<'a> {
37    state: &'a EncounterArgumentationState,
38}
39
40impl<'a> StateAcceptanceEval<'a> {
41    /// Construct an acceptance eval borrowing the given state.
42    #[must_use]
43    pub fn new(state: &'a EncounterArgumentationState) -> Self {
44        Self { state }
45    }
46
47    /// Build the key to look up the PROPOSER's argument from a scored
48    /// action. By convention the proposer binding slot is `"self"`.
49    fn proposer_key<P>(&self, action: &ScoredAffordance<P>) -> Option<AffordanceKey> {
50        let proposer = action.bindings.get("self")?;
51        Some(AffordanceKey::new(
52            proposer,
53            &action.entry.spec.name,
54            &action.bindings,
55        ))
56    }
57}
58
59impl<P> AcceptanceEval<P> for StateAcceptanceEval<'_> {
60    fn evaluate(&self, responder: &str, action: &ScoredAffordance<P>) -> bool {
61        let Some(key) = self.proposer_key(action) else {
62            self.state.record_error(crate::error::Error::MissingProposerBinding {
63                affordance_name: action.entry.spec.name.clone(),
64            });
65            return true;
66        };
67        let Some(target) = self.state.argument_id_for(&key) else {
68            return true;
69        };
70        match self.state.has_accepted_counter_by(responder, &target) {
71            Ok(true) => false,
72            Ok(false) => true,
73            Err(e) => {
74                self.state.record_error(e);
75                true
76            }
77        }
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use argumentation_schemes::catalog::default_catalog;
85    use encounter::affordance::{AffordanceSpec, CatalogEntry};
86    use encounter::scoring::ScoredAffordance;
87    use std::collections::HashMap;
88
89    fn make_affordance(
90        name: &str,
91        self_actor: &str,
92        expert: &str,
93        domain: &str,
94        claim: &str,
95    ) -> ScoredAffordance<String> {
96        let spec = AffordanceSpec {
97            name: name.to_string(),
98            domain: "persuasion".to_string(),
99            bindings: vec![
100                "self".to_string(),
101                "expert".to_string(),
102                "domain".to_string(),
103                "claim".to_string(),
104            ],
105            considerations: Vec::new(),
106            effects_on_accept: Vec::new(),
107            effects_on_reject: Vec::new(),
108            drive_alignment: Vec::new(),
109        };
110        let entry = CatalogEntry { spec, precondition: String::new() };
111        let mut bindings = HashMap::new();
112        bindings.insert("self".to_string(), self_actor.to_string());
113        bindings.insert("expert".to_string(), expert.to_string());
114        bindings.insert("domain".to_string(), domain.to_string());
115        bindings.insert("claim".to_string(), claim.to_string());
116        ScoredAffordance { entry, score: 1.0, bindings }
117    }
118
119    #[test]
120    fn accepts_when_no_argument_is_seeded_for_the_affordance() {
121        let state = EncounterArgumentationState::new(default_catalog());
122        let eval = StateAcceptanceEval::new(&state);
123        let action = make_affordance("unseeded_action", "alice", "alice", "military", "x");
124        assert!(eval.evaluate("bob", &action));
125    }
126
127    #[test]
128    fn accepts_when_responder_has_no_counter() {
129        let registry = default_catalog();
130        let scheme = registry.by_key("argument_from_expert_opinion").unwrap();
131        let mut proposer_bindings = HashMap::new();
132        proposer_bindings.insert("expert".to_string(), "alice".to_string());
133        proposer_bindings.insert("domain".to_string(), "military".to_string());
134        proposer_bindings.insert("claim".to_string(), "fortify_east".to_string());
135        let instance = scheme.instantiate(&proposer_bindings).unwrap();
136
137        let mut state = EncounterArgumentationState::new(registry);
138        let mut affordance_bindings = proposer_bindings.clone();
139        affordance_bindings.insert("self".to_string(), "alice".to_string());
140        state.add_scheme_instance_for_affordance(
141            "alice",
142            "argue_fortify_east",
143            &affordance_bindings,
144            instance,
145        );
146        let eval = StateAcceptanceEval::new(&state);
147        let action = make_affordance(
148            "argue_fortify_east",
149            "alice",
150            "alice",
151            "military",
152            "fortify_east",
153        );
154        assert!(eval.evaluate("bob", &action));
155    }
156
157    #[test]
158    fn rejects_when_responder_asserts_an_accepted_counter() {
159        let registry = default_catalog();
160        let scheme = registry.by_key("argument_from_expert_opinion").unwrap();
161
162        let mut alice_bindings = HashMap::new();
163        alice_bindings.insert("expert".to_string(), "alice".to_string());
164        alice_bindings.insert("domain".to_string(), "military".to_string());
165        alice_bindings.insert("claim".to_string(), "fortify_east".to_string());
166        let alice_instance = scheme.instantiate(&alice_bindings).unwrap();
167
168        let mut bob_bindings = HashMap::new();
169        bob_bindings.insert("expert".to_string(), "bob".to_string());
170        bob_bindings.insert("domain".to_string(), "logistics".to_string());
171        bob_bindings.insert("claim".to_string(), "abandon_east".to_string());
172        let bob_instance = scheme.instantiate(&bob_bindings).unwrap();
173
174        let mut state = EncounterArgumentationState::new(registry);
175        let mut alice_af_bindings = alice_bindings.clone();
176        alice_af_bindings.insert("self".to_string(), "alice".to_string());
177        let alice_id = state.add_scheme_instance_for_affordance(
178            "alice",
179            "argue_fortify_east",
180            &alice_af_bindings,
181            alice_instance,
182        );
183        let bob_id = state.add_scheme_instance("bob", bob_instance);
184        state.add_weighted_attack(&bob_id, &alice_id, 0.5).unwrap();
185
186        let eval = StateAcceptanceEval::new(&state);
187        let action = make_affordance(
188            "argue_fortify_east",
189            "alice",
190            "alice",
191            "military",
192            "fortify_east",
193        );
194        assert!(!eval.evaluate("bob", &action), "bob should reject alice's claim");
195    }
196
197    #[test]
198    fn default_permissive_on_missing_self_binding() {
199        let state = EncounterArgumentationState::new(default_catalog());
200        let eval = StateAcceptanceEval::new(&state);
201        let spec = AffordanceSpec {
202            name: "anon".to_string(),
203            domain: "x".to_string(),
204            bindings: vec![],
205            considerations: Vec::new(),
206            effects_on_accept: Vec::new(),
207            effects_on_reject: Vec::new(),
208            drive_alignment: Vec::new(),
209        };
210        let entry = CatalogEntry { spec, precondition: String::new() };
211        let action = ScoredAffordance {
212            entry,
213            score: 1.0,
214            bindings: HashMap::new(),
215        };
216        assert!(eval.evaluate("anyone", &action));
217        let errors = state.drain_errors();
218        assert_eq!(errors.len(), 1);
219        assert!(matches!(
220            &errors[0],
221            crate::error::Error::MissingProposerBinding { affordance_name }
222                if affordance_name == "anon"
223        ));
224    }
225}