Skip to main content

encounter_argumentation/
state.rs

1//! `EncounterArgumentationState`: the encounter-level state object
2//! composing schemes + bipolar + weighted + weighted-bipolar.
3//!
4//! Consumers build a state via `new(registry)`, optionally configure
5//! a weight source and scene intensity via builders, add scheme
6//! instances and raw edges, then query acceptance and coalitions.
7
8use crate::affordance_key::AffordanceKey;
9use crate::arg_id::ArgumentId;
10use crate::error::Error;
11use argumentation_schemes::instance::SchemeInstance;
12use argumentation_schemes::registry::CatalogRegistry;
13use argumentation_values::Audience;
14use argumentation_weighted::types::Budget;
15use argumentation_weighted_bipolar::WeightedBipolarFramework;
16use std::collections::HashMap;
17use std::sync::Mutex;
18
19/// Encounter-level argumentation state composing schemes (premises +
20/// conclusion), bipolar graph structure (attacks + supports), weighted
21/// edge strengths, and a configurable scene-intensity budget.
22pub struct EncounterArgumentationState {
23    /// Scheme catalog used for instantiation + CQ lookup.
24    #[allow(dead_code)]
25    registry: CatalogRegistry,
26    /// The underlying weighted bipolar framework.
27    framework: WeightedBipolarFramework<ArgumentId>,
28    /// Which actor asserted each argument. Multiple actors may share
29    /// an `ArgumentId` (the same conclusion), so stored as a vec.
30    actors_by_argument: HashMap<ArgumentId, Vec<String>>,
31    /// Scheme instances backing each argument.
32    instances_by_argument: HashMap<ArgumentId, Vec<SchemeInstance>>,
33    /// Forward index: maps affordance-keyed scheme instances to their
34    /// argument id in the framework. Populated by
35    /// [`Self::add_scheme_instance_for_affordance`]. Enables bridge
36    /// consumers (`StateActionScorer`, `StateAcceptanceEval`) to look
37    /// up the right argument node at `evaluate`/`score_actions` time
38    /// given only an `(actor, affordance_name, bindings)` triple.
39    argument_id_by_affordance: HashMap<AffordanceKey, ArgumentId>,
40    /// Current scene intensity (β). Stored in `Mutex` so it can be
41    /// mutated through a shared reference (`&self`) — required for
42    /// bridge consumers that hold `&State` during encounter's
43    /// `resolve` loops — while still being `Sync`, which encounter's
44    /// `AcceptanceEval`/`ActionScorer` traits require.
45    intensity: Mutex<Budget>,
46    /// Per-character audiences (value preference orderings). Mutable via
47    /// `set_audience` through a shared reference, mirroring how `intensity`
48    /// is stored. Empty map means no audiences are configured; consumers
49    /// that opt into VAF-aware scoring should populate this before resolve.
50    audiences: Mutex<HashMap<String, Audience>>,
51    /// Error buffer: errors observed by bridge impls whose trait
52    /// signatures can't propagate `Result` (e.g.,
53    /// `AcceptanceEval::evaluate`). Bridge impls APPEND to this
54    /// buffer; consumers DRAIN via [`Self::drain_errors`] after
55    /// encounter's resolve returns.
56    errors: Mutex<Vec<Error>>,
57}
58
59impl EncounterArgumentationState {
60    /// Create a new state with the given scheme registry and zero
61    /// scene intensity. Consumers that want relationship-modulated
62    /// attack weights should construct a societas-aware `WeightSource`
63    /// (e.g. `societas_encounter::SocietasRelationshipSource` from the
64    /// `societas-encounter` crate with the `argumentation` feature
65    /// enabled), then pass its computed weights into
66    /// [`add_weighted_attack`](Self::add_weighted_attack); the state
67    /// does not auto-wire the source.
68    #[must_use]
69    pub fn new(registry: CatalogRegistry) -> Self {
70        Self {
71            registry,
72            framework: WeightedBipolarFramework::new(),
73            actors_by_argument: HashMap::new(),
74            instances_by_argument: HashMap::new(),
75            argument_id_by_affordance: HashMap::new(),
76            intensity: Mutex::new(Budget::zero()),
77            audiences: Mutex::new(HashMap::new()),
78            errors: Mutex::new(Vec::new()),
79        }
80    }
81
82    /// Read-only access to the current scene intensity.
83    #[must_use]
84    pub fn intensity(&self) -> Budget {
85        *self.intensity_guard()
86    }
87
88    // Lock intensity, recovering from poisoning — a prior panic
89    // elsewhere must not turn into a panic here, since the bridge's
90    // D5 contract requires permissive-on-failure behaviour.
91    //
92    // NON-REENTRANT: `std::sync::Mutex` does not support recursive
93    // locking on the same thread. Callers must NOT hold the returned
94    // guard across another call to `intensity()` or `set_intensity()`.
95    fn intensity_guard(&self) -> std::sync::MutexGuard<'_, Budget> {
96        self.intensity.lock().unwrap_or_else(|e| e.into_inner())
97    }
98
99    // Lock errors, recovering from poisoning — see `intensity_guard`
100    // for rationale.
101    //
102    // NON-REENTRANT: `std::sync::Mutex` does not support recursive
103    // locking on the same thread. Callers must NOT hold the returned
104    // guard across another call to `record_error` or `drain_errors`.
105    fn errors_guard(&self) -> std::sync::MutexGuard<'_, Vec<Error>> {
106        self.errors.lock().unwrap_or_else(|e| e.into_inner())
107    }
108
109    // Lock audiences, recovering from poisoning — see `intensity_guard`
110    // for rationale.
111    //
112    // NON-REENTRANT: `std::sync::Mutex` does not support recursive
113    // locking on the same thread. Callers must NOT hold the returned
114    // guard across another call to `set_audience`, `audience_for`, or
115    // `audiences`.
116    fn audiences_guard(&self) -> std::sync::MutexGuard<'_, HashMap<String, Audience>> {
117        self.audiences.lock().unwrap_or_else(|e| e.into_inner())
118    }
119
120    /// Number of argument nodes in the framework.
121    #[must_use]
122    pub fn argument_count(&self) -> usize {
123        self.framework.argument_count()
124    }
125
126    /// Number of edges (attacks + supports) in the framework.
127    #[must_use]
128    pub fn edge_count(&self) -> usize {
129        self.framework.edge_count()
130    }
131
132    /// Add a scheme instance asserted by `actor`. The instance's
133    /// conclusion literal becomes an argument node in the framework
134    /// (if not already present). The actor and instance are recorded
135    /// against that node for later lookup via `actors_for` /
136    /// `instances_for`. Returns the argument's identifier.
137    pub fn add_scheme_instance(
138        &mut self,
139        actor: &str,
140        instance: SchemeInstance,
141    ) -> ArgumentId {
142        let id: ArgumentId = (&instance.conclusion).into();
143        self.framework.add_argument(id.clone());
144        self.actors_by_argument
145            .entry(id.clone())
146            .or_default()
147            .push(actor.to_string());
148        self.instances_by_argument
149            .entry(id.clone())
150            .or_default()
151            .push(instance);
152        id
153    }
154
155    /// Add a scheme instance asserted by `actor` for the named
156    /// affordance with the given bindings. Functionally identical to
157    /// [`Self::add_scheme_instance`] plus an entry in the affordance
158    /// forward index so consumers can later look up this argument
159    /// from an `(actor, affordance_name, bindings)` triple.
160    ///
161    /// If two scheme instances produce the same conclusion literal
162    /// (same `ArgumentId`), both actors are recorded against that
163    /// single argument node — convergence behaviour is the same as
164    /// [`Self::add_scheme_instance`]. Both keys in the forward index
165    /// point at the shared id.
166    ///
167    /// Convergence has a downstream consequence for
168    /// [`Self::has_accepted_counter_by`]: see that method's docs.
169    ///
170    /// **Re-seeding the same key silently overwrites.** Calling this
171    /// method twice with identical `(actor, affordance_name, bindings)`
172    /// triples replaces the previous entry; the most recent
173    /// `ArgumentId` wins. Callers should seed each affordance at most
174    /// once per scene.
175    pub fn add_scheme_instance_for_affordance(
176        &mut self,
177        actor: &str,
178        affordance_name: &str,
179        bindings: &std::collections::HashMap<String, String>,
180        instance: SchemeInstance,
181    ) -> ArgumentId {
182        let id = self.add_scheme_instance(actor, instance);
183        let key = AffordanceKey::new(actor, affordance_name, bindings);
184        self.argument_id_by_affordance.insert(key, id.clone());
185        id
186    }
187
188    /// Look up the argument id associated with an affordance key, if
189    /// one was seeded via [`Self::add_scheme_instance_for_affordance`].
190    #[must_use]
191    pub fn argument_id_for(
192        &self,
193        key: &AffordanceKey,
194    ) -> Option<ArgumentId> {
195        self.argument_id_by_affordance.get(key).cloned()
196    }
197
198    /// Return the list of actors who have asserted the given argument.
199    /// Empty slice if the argument is not associated with any actor.
200    #[must_use]
201    pub fn actors_for(&self, id: &ArgumentId) -> &[String] {
202        self.actors_by_argument
203            .get(id)
204            .map(Vec::as_slice)
205            .unwrap_or(&[])
206    }
207
208    /// Read-only access to the actor-per-argument map. Used by
209    /// bridge weight sources (e.g.
210    /// `societas_encounter::SocietasRelationshipSource` from the
211    /// `societas-encounter` crate) to resolve an [`ArgumentId`] back
212    /// to the actors whose asserted schemes produce that conclusion.
213    #[must_use]
214    pub fn actors_by_argument(&self) -> &HashMap<ArgumentId, Vec<String>> {
215        &self.actors_by_argument
216    }
217
218    /// Return the list of scheme instances backing the given argument.
219    /// Empty slice if the argument is not scheme-backed.
220    #[must_use]
221    pub fn instances_for(&self, id: &ArgumentId) -> &[SchemeInstance] {
222        self.instances_by_argument
223            .get(id)
224            .map(Vec::as_slice)
225            .unwrap_or(&[])
226    }
227
228    /// Return the direct attackers of `target` in the current
229    /// framework. Ignores support edges and does NOT resolve the
230    /// β-inconsistent residual — this is a structural query.
231    ///
232    /// Consumers that want "is there a credulously accepted attacker
233    /// at current β?" should query each attacker via
234    /// [`Self::is_credulously_accepted`].
235    ///
236    /// If the underlying framework contains multiple attack edges for
237    /// the same `(attacker, target)` pair (the framework does not
238    /// deduplicate), the attacker id appears once per edge in the
239    /// returned `Vec`. Consumers that want a set projection should
240    /// `.dedup()` or collect through a `HashSet`.
241    #[must_use]
242    pub fn attackers_of(&self, target: &ArgumentId) -> Vec<ArgumentId> {
243        self.framework
244            .attacks()
245            .filter(|atk| &atk.target == target)
246            .map(|atk| atk.attacker.clone())
247            .collect()
248    }
249
250    /// Return `true` iff `responder` has put forward (via
251    /// [`Self::add_scheme_instance`] or
252    /// [`Self::add_scheme_instance_for_affordance`]) some argument that
253    /// (1) directly attacks `target`, AND
254    /// (2) is credulously accepted at the current scene intensity.
255    ///
256    /// This is the per-responder counter-argument query used by the
257    /// bridge's [`crate::state_acceptance::StateAcceptanceEval`] to
258    /// decide whether a responder rejects a proposed action. It differs
259    /// from [`Self::is_credulously_accepted`] (which is a global β
260    /// acceptance check regardless of who asserted the argument).
261    ///
262    /// Returns `Err` if the framework exceeds the weighted-bipolar
263    /// residual enumeration limit. The bridge wraps this error into
264    /// its error latch; consumers should rarely see `Err` surface
265    /// directly.
266    ///
267    /// **Shared-ArgumentId attribution.** If two actors both assert scheme
268    /// instances whose conclusions resolve to the same `ArgumentId` (i.e.,
269    /// they independently endorse the same argument), both appear in
270    /// [`Self::actors_for`] against that node. This method considers all
271    /// of them to "have put forward" the argument — so it returns `true`
272    /// for any responder in the shared set, not just the specific actor
273    /// who seeded the instance that carries the attack edge. Consumers
274    /// who need strict per-instance attribution should seed distinct
275    /// conclusions for each actor (e.g., `"alice_opposes"` vs
276    /// `"bob_opposes"` rather than a shared `"oppose"` literal).
277    pub fn has_accepted_counter_by(
278        &self,
279        responder: &str,
280        target: &ArgumentId,
281    ) -> Result<bool, Error> {
282        for attacker in self.attackers_of(target) {
283            let asserted_by_responder = self
284                .actors_for(&attacker)
285                .iter()
286                .any(|a| a == responder);
287            if !asserted_by_responder {
288                continue;
289            }
290            if self.is_credulously_accepted(&attacker)? {
291                return Ok(true);
292            }
293        }
294        Ok(false)
295    }
296
297    /// Add a weighted attack edge. Both endpoints are implicitly added
298    /// to the framework if not already present. Returns
299    /// `Error::WeightedBipolar` for invalid weights.
300    pub fn add_weighted_attack(
301        &mut self,
302        attacker: &ArgumentId,
303        target: &ArgumentId,
304        weight: f64,
305    ) -> Result<(), Error> {
306        self.framework
307            .add_weighted_attack(attacker.clone(), target.clone(), weight)?;
308        Ok(())
309    }
310
311    /// Add a weighted support edge. Both endpoints are implicitly
312    /// added. Returns `Error::WeightedBipolar` for invalid weights or
313    /// self-support.
314    pub fn add_weighted_support(
315        &mut self,
316        supporter: &ArgumentId,
317        supported: &ArgumentId,
318        weight: f64,
319    ) -> Result<(), Error> {
320        self.framework
321            .add_weighted_support(supporter.clone(), supported.clone(), weight)?;
322        Ok(())
323    }
324
325    /// Builder method setting the scene-intensity budget. Returns
326    /// `self` by value to allow chaining.
327    #[must_use]
328    pub fn at_intensity(self, intensity: Budget) -> Self {
329        *self.intensity_guard() = intensity;
330        self
331    }
332
333    /// Mutate the scene intensity (β) through a shared reference.
334    /// Used by consumers — notably the bridge's `StateAcceptanceEval`
335    /// and `StateActionScorer` — that hold `&self` during encounter's
336    /// `resolve` loops but still want to escalate β mid-scene.
337    ///
338    /// For new-state construction prefer the by-value builder
339    /// [`Self::at_intensity`].
340    pub fn set_intensity(&self, intensity: Budget) {
341        *self.intensity_guard() = intensity;
342    }
343
344    /// Set the audience (value preference ordering) for one actor.
345    /// Mirrors `set_intensity` — uses a shared reference plus interior mutability.
346    pub fn set_audience(&self, actor: &str, audience: Audience) {
347        let mut map = self.audiences_guard();
348        map.insert(actor.to_string(), audience);
349    }
350
351    /// Borrow the audience for one actor, if set. Returns a clone because
352    /// the underlying mutex guard cannot be held across the boundary.
353    #[must_use]
354    pub fn audience_for(&self, actor: &str) -> Option<Audience> {
355        let map = self.audiences_guard();
356        map.get(actor).cloned()
357    }
358
359    /// All (actor, audience) pairs as owned data. The lock is released
360    /// before returning, so callers can iterate without holding it.
361    #[must_use]
362    pub fn audiences(&self) -> Vec<(String, Audience)> {
363        let map = self.audiences_guard();
364        map.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
365    }
366
367    /// Whether the argument is credulously accepted under the current
368    /// scene intensity (at least one preferred extension of at least
369    /// one β-inconsistent residual contains it).
370    pub fn is_credulously_accepted(&self, arg: &ArgumentId) -> Result<bool, Error> {
371        Ok(argumentation_weighted_bipolar::is_credulously_accepted_at(
372            &self.framework,
373            arg,
374            self.intensity(),
375        )?)
376    }
377
378    /// Whether the argument is skeptically accepted under the current
379    /// scene intensity (every preferred extension of every
380    /// β-inconsistent residual contains it).
381    pub fn is_skeptically_accepted(&self, arg: &ArgumentId) -> Result<bool, Error> {
382        Ok(argumentation_weighted_bipolar::is_skeptically_accepted_at(
383            &self.framework,
384            arg,
385            self.intensity(),
386        )?)
387    }
388
389    /// Detect coalitions (strongly-connected components of the support
390    /// graph) at the current framework state. Independent of scene
391    /// intensity — coalitions are a structural property of supports,
392    /// not a semantic query.
393    ///
394    /// Returns `Err(Error::WeightedBipolar)` if the framework exceeds
395    /// the underlying edge-enumeration limit (currently 24 attacks +
396    /// supports combined).
397    pub fn coalitions(&self) -> Result<Vec<argumentation_bipolar::Coalition<ArgumentId>>, Error> {
398        // Materialise the full-edge bipolar residual (β=0 → one residual
399        // containing every edge) and run SCC on the support graph.
400        let residuals = argumentation_weighted_bipolar::wbipolar_residuals(
401            &self.framework,
402            Budget::zero(),
403        )?;
404        let bipolar = residuals
405            .into_iter()
406            .next()
407            .expect("zero-budget residual always includes the empty subset");
408        Ok(argumentation_bipolar::detect_coalitions(&bipolar))
409    }
410
411    /// Drain all errors observed by bridge impls whose trait
412    /// signature can't propagate `Result`. Clears the internal
413    /// buffer. Returns an empty `Vec` if no errors occurred since
414    /// the last drain.
415    ///
416    /// Errors are returned in the order they were recorded, so
417    /// the first entry is the first error observed in the current
418    /// drain window.
419    #[must_use]
420    pub fn drain_errors(&self) -> Vec<Error> {
421        std::mem::take(&mut *self.errors_guard())
422    }
423
424    /// Append an error to the internal buffer. Called by bridge impls
425    /// whose trait signature can't propagate `Result`. All recorded
426    /// errors survive until [`Self::drain_errors`] is called, so
427    /// errors from both `StateActionScorer::score_actions` and
428    /// `StateAcceptanceEval::evaluate` within the same beat are
429    /// preserved independently.
430    pub(crate) fn record_error(&self, err: Error) {
431        self.errors_guard().push(err);
432    }
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438    use argumentation_schemes::catalog::default_catalog;
439
440    #[test]
441    fn new_state_is_empty() {
442        let state = EncounterArgumentationState::new(default_catalog());
443        assert_eq!(state.argument_count(), 0);
444        assert_eq!(state.edge_count(), 0);
445    }
446
447    #[test]
448    fn new_state_has_zero_intensity() {
449        let state = EncounterArgumentationState::new(default_catalog());
450        assert_eq!(state.intensity().value(), 0.0);
451    }
452
453    #[test]
454    fn add_scheme_instance_creates_argument_node() {
455        let registry = default_catalog();
456        let scheme = registry.by_key("argument_from_expert_opinion").unwrap();
457        let instance = scheme
458            .instantiate(
459                &[
460                    ("expert".to_string(), "alice".to_string()),
461                    ("domain".to_string(), "military".to_string()),
462                    ("claim".to_string(), "fortify_east".to_string()),
463                ]
464                .into_iter()
465                .collect(),
466            )
467            .unwrap();
468
469        let mut state = EncounterArgumentationState::new(registry);
470        let id = state.add_scheme_instance("alice", instance);
471
472        assert_eq!(id.as_str(), "fortify_east");
473        assert_eq!(state.argument_count(), 1);
474    }
475
476    #[test]
477    fn add_scheme_instance_associates_actor_and_instance() {
478        let registry = default_catalog();
479        let scheme = registry.by_key("argument_from_expert_opinion").unwrap();
480        let instance = scheme
481            .instantiate(
482                &[
483                    ("expert".to_string(), "alice".to_string()),
484                    ("domain".to_string(), "military".to_string()),
485                    ("claim".to_string(), "fortify_east".to_string()),
486                ]
487                .into_iter()
488                .collect(),
489            )
490            .unwrap();
491        let mut state = EncounterArgumentationState::new(registry);
492        let id = state.add_scheme_instance("alice", instance);
493        assert_eq!(state.actors_for(&id), &["alice".to_string()]);
494        assert_eq!(state.instances_for(&id).len(), 1);
495    }
496
497    #[test]
498    fn add_two_instances_with_same_conclusion_share_node() {
499        let registry = default_catalog();
500        let scheme = registry.by_key("argument_from_expert_opinion").unwrap();
501
502        let inst1 = scheme
503            .instantiate(
504                &[
505                    ("expert".to_string(), "alice".to_string()),
506                    ("domain".to_string(), "military".to_string()),
507                    ("claim".to_string(), "fortify_east".to_string()),
508                ]
509                .into_iter()
510                .collect(),
511            )
512            .unwrap();
513        let inst2 = scheme
514            .instantiate(
515                &[
516                    ("expert".to_string(), "bob".to_string()),
517                    ("domain".to_string(), "logistics".to_string()),
518                    ("claim".to_string(), "fortify_east".to_string()),
519                ]
520                .into_iter()
521                .collect(),
522            )
523            .unwrap();
524
525        let mut state = EncounterArgumentationState::new(registry);
526        let id1 = state.add_scheme_instance("alice", inst1);
527        let id2 = state.add_scheme_instance("bob", inst2);
528        assert_eq!(id1, id2);
529        assert_eq!(state.argument_count(), 1);
530        assert_eq!(
531            state.actors_for(&id1),
532            &["alice".to_string(), "bob".to_string()]
533        );
534        assert_eq!(state.instances_for(&id1).len(), 2);
535    }
536
537    #[test]
538    fn add_weighted_attack_propagates_to_framework() {
539        let mut state = EncounterArgumentationState::new(default_catalog());
540        let a = ArgumentId::new("a");
541        let b = ArgumentId::new("b");
542        state.add_weighted_attack(&a, &b, 0.5).unwrap();
543        assert_eq!(state.edge_count(), 1);
544    }
545
546    #[test]
547    fn add_weighted_support_propagates_to_framework() {
548        let mut state = EncounterArgumentationState::new(default_catalog());
549        let a = ArgumentId::new("a");
550        let b = ArgumentId::new("b");
551        state.add_weighted_support(&a, &b, 0.5).unwrap();
552        assert_eq!(state.edge_count(), 1);
553    }
554
555    #[test]
556    fn add_weighted_support_rejects_self_support() {
557        let mut state = EncounterArgumentationState::new(default_catalog());
558        let a = ArgumentId::new("a");
559        let err = state.add_weighted_support(&a, &a, 0.5).unwrap_err();
560        assert!(matches!(err, Error::WeightedBipolar(_)));
561    }
562
563    #[test]
564    fn add_weighted_attack_rejects_invalid_weight() {
565        let mut state = EncounterArgumentationState::new(default_catalog());
566        let a = ArgumentId::new("a");
567        let b = ArgumentId::new("b");
568        let err = state.add_weighted_attack(&a, &b, -0.1).unwrap_err();
569        assert!(matches!(err, Error::WeightedBipolar(_)));
570    }
571
572    #[test]
573    fn at_intensity_sets_budget() {
574        let state = EncounterArgumentationState::new(default_catalog())
575            .at_intensity(Budget::new(0.5).unwrap());
576        assert_eq!(state.intensity().value(), 0.5);
577    }
578
579    #[test]
580    fn at_intensity_is_chainable_with_add() {
581        let mut state = EncounterArgumentationState::new(default_catalog())
582            .at_intensity(Budget::new(0.25).unwrap());
583        state
584            .add_weighted_attack(&ArgumentId::new("a"), &ArgumentId::new("b"), 0.3)
585            .unwrap();
586        assert_eq!(state.intensity().value(), 0.25);
587        assert_eq!(state.edge_count(), 1);
588    }
589
590    #[test]
591    fn unattacked_argument_is_credulously_accepted() {
592        let mut state = EncounterArgumentationState::new(default_catalog());
593        let a = ArgumentId::new("a");
594        state.add_weighted_attack(&a, &ArgumentId::new("unused"), 0.0).unwrap();
595        // `a` is unattacked: it appears only as attacker.
596        assert!(state.is_credulously_accepted(&a).unwrap());
597    }
598
599    #[test]
600    fn attacked_argument_is_not_credulously_accepted_at_zero_intensity() {
601        let mut state = EncounterArgumentationState::new(default_catalog());
602        let a = ArgumentId::new("a");
603        let b = ArgumentId::new("b");
604        state.add_weighted_attack(&a, &b, 0.5).unwrap();
605        // `b` is attacked by `a` (unattacked); at β=0 the attack binds.
606        assert!(!state.is_credulously_accepted(&b).unwrap());
607    }
608
609    #[test]
610    fn raising_intensity_flips_acceptance_when_budget_covers_attack() {
611        let mut state = EncounterArgumentationState::new(default_catalog())
612            .at_intensity(Budget::new(0.5).unwrap());
613        let a = ArgumentId::new("a");
614        let b = ArgumentId::new("b");
615        state.add_weighted_attack(&a, &b, 0.4).unwrap();
616        // At β=0.5 >= 0.4, the residual dropping a→b exists, so b is
617        // credulously accepted in that residual.
618        assert!(state.is_credulously_accepted(&b).unwrap());
619    }
620
621    #[test]
622    fn skeptical_is_stricter_than_credulous() {
623        let mut state = EncounterArgumentationState::new(default_catalog())
624            .at_intensity(Budget::new(0.5).unwrap());
625        let a = ArgumentId::new("a");
626        let b = ArgumentId::new("b");
627        state.add_weighted_attack(&a, &b, 0.4).unwrap();
628        // At β=0.5, b is credulous (residual drops the attack) but NOT
629        // skeptical (the full-framework residual still attacks b).
630        assert!(state.is_credulously_accepted(&b).unwrap());
631        assert!(!state.is_skeptically_accepted(&b).unwrap());
632    }
633
634    #[test]
635    fn no_supports_means_all_coalitions_are_singletons() {
636        let mut state = EncounterArgumentationState::new(default_catalog());
637        state.add_weighted_attack(&ArgumentId::new("a"), &ArgumentId::new("b"), 0.5).unwrap();
638        let coalitions = state.coalitions().unwrap();
639        // Each argument is its own singleton SCC; `detect_coalitions`
640        // returns singletons too, so the invariant is that every
641        // coalition has exactly one member when there are no supports.
642        assert!(coalitions.iter().all(|c| c.members.len() == 1));
643    }
644
645    #[test]
646    fn mutual_support_forms_coalition() {
647        let mut state = EncounterArgumentationState::new(default_catalog());
648        let a = ArgumentId::new("a");
649        let b = ArgumentId::new("b");
650        state.add_weighted_support(&a, &b, 0.5).unwrap();
651        state.add_weighted_support(&b, &a, 0.5).unwrap();
652        let coalitions = state.coalitions().unwrap();
653        // At least one coalition has both a and b.
654        assert!(coalitions.iter().any(|c| c.members.len() == 2
655            && c.members.contains(&a)
656            && c.members.contains(&b)));
657    }
658
659    #[test]
660    fn set_intensity_mutates_through_shared_ref() {
661        let state = EncounterArgumentationState::new(default_catalog())
662            .at_intensity(Budget::new(0.2).unwrap());
663        assert_eq!(state.intensity().value(), 0.2);
664        // set_intensity takes &self (shared ref). This line must
665        // compile without &mut state.
666        state.set_intensity(Budget::new(0.6).unwrap());
667        assert_eq!(state.intensity().value(), 0.6);
668    }
669
670    #[test]
671    fn intensity_is_mutable_from_two_shared_refs_in_sequence() {
672        let state = EncounterArgumentationState::new(default_catalog());
673        fn bump(s: &EncounterArgumentationState, b: f64) {
674            s.set_intensity(Budget::new(b).unwrap());
675        }
676        bump(&state, 0.3);
677        bump(&state, 0.5);
678        assert_eq!(state.intensity().value(), 0.5);
679    }
680
681    #[test]
682    fn add_scheme_instance_for_affordance_indexes_by_key() {
683        let registry = default_catalog();
684        let scheme = registry.by_key("argument_from_expert_opinion").unwrap();
685        let mut bindings = std::collections::HashMap::new();
686        bindings.insert("expert".to_string(), "alice".to_string());
687        bindings.insert("domain".to_string(), "military".to_string());
688        bindings.insert("claim".to_string(), "fortify_east".to_string());
689        let instance = scheme.instantiate(&bindings).unwrap();
690
691        let mut state = EncounterArgumentationState::new(registry);
692        let id = state.add_scheme_instance_for_affordance(
693            "alice",
694            "argue_fortify_east",
695            &bindings,
696            instance,
697        );
698
699        let key = AffordanceKey::new("alice", "argue_fortify_east", &bindings);
700        let looked_up = state.argument_id_for(&key);
701        assert_eq!(looked_up, Some(id));
702    }
703
704    #[test]
705    fn argument_id_for_returns_none_for_unseeded_key() {
706        let bindings = std::collections::HashMap::new();
707        let state = EncounterArgumentationState::new(default_catalog());
708        let key = AffordanceKey::new("nobody", "nothing", &bindings);
709        assert_eq!(state.argument_id_for(&key), None);
710    }
711
712    #[test]
713    fn add_scheme_instance_for_affordance_is_consistent_with_add_scheme_instance() {
714        let registry = default_catalog();
715        let scheme = registry.by_key("argument_from_expert_opinion").unwrap();
716        let mut bindings = std::collections::HashMap::new();
717        bindings.insert("expert".to_string(), "alice".to_string());
718        bindings.insert("domain".to_string(), "military".to_string());
719        bindings.insert("claim".to_string(), "fortify_east".to_string());
720        let instance = scheme.instantiate(&bindings).unwrap();
721        let mut state = EncounterArgumentationState::new(registry);
722        let id = state.add_scheme_instance_for_affordance(
723            "alice",
724            "argue_fortify_east",
725            &bindings,
726            instance,
727        );
728        assert_eq!(state.actors_for(&id), &["alice".to_string()]);
729        assert_eq!(state.instances_for(&id).len(), 1);
730    }
731
732    #[test]
733    fn two_distinct_affordance_keys_with_same_conclusion_point_at_shared_id() {
734        let registry = default_catalog();
735        let scheme = registry.by_key("argument_from_expert_opinion").unwrap();
736
737        let mut alice_bindings = std::collections::HashMap::new();
738        alice_bindings.insert("expert".to_string(), "alice".to_string());
739        alice_bindings.insert("domain".to_string(), "military".to_string());
740        alice_bindings.insert("claim".to_string(), "fortify_east".to_string());
741        let alice_instance = scheme.instantiate(&alice_bindings).unwrap();
742
743        let mut bob_bindings = std::collections::HashMap::new();
744        bob_bindings.insert("expert".to_string(), "bob".to_string());
745        bob_bindings.insert("domain".to_string(), "logistics".to_string());
746        bob_bindings.insert("claim".to_string(), "fortify_east".to_string());
747        let bob_instance = scheme.instantiate(&bob_bindings).unwrap();
748
749        let mut state = EncounterArgumentationState::new(registry);
750        let alice_id = state.add_scheme_instance_for_affordance(
751            "alice",
752            "argue_fortify_east",
753            &alice_bindings,
754            alice_instance,
755        );
756        let bob_id = state.add_scheme_instance_for_affordance(
757            "bob",
758            "second_expert_opinion",
759            &bob_bindings,
760            bob_instance,
761        );
762
763        assert_eq!(alice_id, bob_id, "same conclusion literal → shared ArgumentId");
764        assert_eq!(state.argument_count(), 1);
765
766        let alice_key = AffordanceKey::new("alice", "argue_fortify_east", &alice_bindings);
767        let bob_key = AffordanceKey::new("bob", "second_expert_opinion", &bob_bindings);
768        assert_eq!(state.argument_id_for(&alice_key), Some(alice_id.clone()));
769        assert_eq!(state.argument_id_for(&bob_key), Some(alice_id.clone()));
770
771        assert_eq!(
772            state.actors_for(&alice_id),
773            &["alice".to_string(), "bob".to_string()]
774        );
775    }
776
777    #[test]
778    fn attackers_of_returns_all_direct_attackers() {
779        let mut state = EncounterArgumentationState::new(default_catalog());
780        let target = ArgumentId::new("target");
781        let a1 = ArgumentId::new("a1");
782        let a2 = ArgumentId::new("a2");
783        let unrelated = ArgumentId::new("unrelated");
784        state.add_weighted_attack(&a1, &target, 0.5).unwrap();
785        state.add_weighted_attack(&a2, &target, 0.3).unwrap();
786        state.add_weighted_attack(&unrelated, &ArgumentId::new("x"), 0.5).unwrap();
787        let attackers: std::collections::HashSet<_> =
788            state.attackers_of(&target).into_iter().collect();
789        assert_eq!(attackers.len(), 2);
790        assert!(attackers.contains(&a1));
791        assert!(attackers.contains(&a2));
792    }
793
794    #[test]
795    fn attackers_of_returns_empty_for_unattacked() {
796        let state = EncounterArgumentationState::new(default_catalog());
797        let lonely = ArgumentId::new("lonely");
798        assert!(state.attackers_of(&lonely).is_empty());
799    }
800
801    #[test]
802    fn attackers_of_preserves_duplicate_edges() {
803        let mut state = EncounterArgumentationState::new(default_catalog());
804        let target = ArgumentId::new("target");
805        let a1 = ArgumentId::new("a1");
806        state.add_weighted_attack(&a1, &target, 0.5).unwrap();
807        state.add_weighted_attack(&a1, &target, 0.7).unwrap();
808        assert_eq!(state.attackers_of(&target).len(), 2);
809    }
810
811    #[test]
812    fn has_accepted_counter_by_detects_responder_attacker_at_beta() {
813        let registry = default_catalog();
814        let scheme = registry.by_key("argument_from_expert_opinion").unwrap();
815        let mut target_bindings = std::collections::HashMap::new();
816        target_bindings.insert("expert".to_string(), "alice".to_string());
817        target_bindings.insert("domain".to_string(), "military".to_string());
818        target_bindings.insert("claim".to_string(), "fortify_east".to_string());
819        let target_instance = scheme.instantiate(&target_bindings).unwrap();
820        let mut counter_bindings = std::collections::HashMap::new();
821        counter_bindings.insert("expert".to_string(), "bob".to_string());
822        counter_bindings.insert("domain".to_string(), "logistics".to_string());
823        counter_bindings.insert("claim".to_string(), "abandon_east".to_string());
824        let counter_instance = scheme.instantiate(&counter_bindings).unwrap();
825
826        let mut state = EncounterArgumentationState::new(registry);
827        let target_id = state.add_scheme_instance("alice", target_instance);
828        let counter_id = state.add_scheme_instance("bob", counter_instance);
829        state.add_weighted_attack(&counter_id, &target_id, 0.5).unwrap();
830
831        // At β=0 bob's counter is credulously accepted (unattacked) and
832        // attacks alice's target → has_accepted_counter_by(bob, target)=true,
833        // has_accepted_counter_by(alice, target)=false.
834        assert!(state.has_accepted_counter_by("bob", &target_id).unwrap());
835        assert!(!state.has_accepted_counter_by("alice", &target_id).unwrap());
836    }
837
838    #[test]
839    fn drain_errors_round_trips_stashed_errors() {
840        let state = EncounterArgumentationState::new(default_catalog());
841        assert!(state.drain_errors().is_empty());
842        state.record_error(Error::SchemeNotFound("x".into()));
843        state.record_error(Error::SchemeNotFound("y".into()));
844        let errs = state.drain_errors();
845        assert_eq!(errs.len(), 2);
846        assert!(matches!(&errs[0], Error::SchemeNotFound(s) if s == "x"));
847        assert!(matches!(&errs[1], Error::SchemeNotFound(s) if s == "y"));
848        // Second call: drained.
849        assert!(state.drain_errors().is_empty());
850    }
851
852    #[test]
853    fn actors_by_argument_exposes_actor_map() {
854        let registry = default_catalog();
855        let scheme = registry.by_key("argument_from_expert_opinion").unwrap();
856        let instance = scheme
857            .instantiate(
858                &[
859                    ("expert".to_string(), "alice".to_string()),
860                    ("domain".to_string(), "military".to_string()),
861                    ("claim".to_string(), "fortify_east".to_string()),
862                ]
863                .into_iter()
864                .collect(),
865            )
866            .unwrap();
867        let mut state = EncounterArgumentationState::new(registry);
868        let id = state.add_scheme_instance("alice", instance);
869        let map = state.actors_by_argument();
870        assert_eq!(map.len(), 1);
871        assert_eq!(map.get(&id), Some(&vec!["alice".to_string()]));
872    }
873
874    #[test]
875    fn actors_by_argument_is_empty_on_new_state() {
876        let state = EncounterArgumentationState::new(default_catalog());
877        assert!(state.actors_by_argument().is_empty());
878    }
879
880    #[test]
881    fn audiences_round_trip() {
882        use argumentation_values::{Audience, Value};
883        let registry = argumentation_schemes::catalog::default_catalog();
884        let state = EncounterArgumentationState::new(registry);
885        let audience = Audience::total([Value::new("life"), Value::new("property")]);
886        state.set_audience("alice", audience.clone());
887        let read = state.audience_for("alice").unwrap();
888        assert_eq!(read.value_count(), 2);
889        assert!(state.audience_for("bob").is_none());
890    }
891
892    #[test]
893    fn state_is_send_and_sync() {
894        // EncounterArgumentationState MUST be Send+Sync so that bridge
895        // impls like StateAcceptanceEval and StateActionScorer satisfy
896        // encounter's AcceptanceEval<P>: Send + Sync / ActionScorer<P>:
897        // Send + Sync trait bounds. This test locks down that contract
898        // at compile time — if a future change reintroduces a !Sync
899        // primitive (e.g. Cell, RefCell, Rc), it will fail here.
900        fn assert_send_sync<T: Send + Sync>() {}
901        assert_send_sync::<EncounterArgumentationState>();
902    }
903}