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