Skip to main content

organism_planning/
collaboration.rs

1//! Collaboration models for multi-agent planning and research teams.
2//!
3//! Organism's planning loop is broader than a generic huddle. Different
4//! workflows need different collaboration contracts:
5//! - a strict huddle with explicit turns and synthesis checkpoints
6//! - a moderated discussion group with some structure but softer commitments
7//! - a demanding panel where roles, dissent, and decision policy are explicit
8//! - a very loose self-organizing swarm that is allowed to self-organize
9
10use serde::{Deserialize, Serialize};
11
12/// The overall collaboration shape.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum CollaborationTopology {
16    Huddle,
17    DiscussionGroup,
18    Panel,
19    SelfOrganizing,
20}
21
22impl CollaborationTopology {
23    #[must_use]
24    pub const fn label(self) -> &'static str {
25        match self {
26            Self::Huddle => "huddle",
27            Self::DiscussionGroup => "discussion_group",
28            Self::Panel => "panel",
29            Self::SelfOrganizing => "self_organizing",
30        }
31    }
32}
33
34/// How a team is assembled.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum TeamFormationMode {
38    Curated,
39    CapabilityMatched,
40    SelfSelected,
41    OpenCall,
42}
43
44impl TeamFormationMode {
45    #[must_use]
46    pub const fn label(self) -> &'static str {
47        match self {
48            Self::Curated => "curated",
49            Self::CapabilityMatched => "capability_matched",
50            Self::SelfSelected => "self_selected",
51            Self::OpenCall => "open_call",
52        }
53    }
54}
55
56/// How demanding the collaboration contract is.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum CollaborationDiscipline {
60    Enforced,
61    Moderated,
62    Loose,
63}
64
65impl CollaborationDiscipline {
66    #[must_use]
67    pub const fn label(self) -> &'static str {
68        match self {
69            Self::Enforced => "enforced",
70            Self::Moderated => "moderated",
71            Self::Loose => "loose",
72        }
73    }
74}
75
76/// Role a collaborator plays in the team.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
78#[serde(rename_all = "snake_case")]
79pub enum CollaborationRole {
80    Lead,
81    Domain,
82    Critic,
83    Synthesizer,
84    Judge,
85    ReportWriter,
86    Moderator,
87    Generalist,
88    Observer,
89}
90
91impl CollaborationRole {
92    #[must_use]
93    pub const fn label(self) -> &'static str {
94        match self {
95            Self::Lead => "lead",
96            Self::Domain => "domain",
97            Self::Critic => "critic",
98            Self::Synthesizer => "synthesizer",
99            Self::Judge => "judge",
100            Self::ReportWriter => "report_writer",
101            Self::Moderator => "moderator",
102            Self::Generalist => "generalist",
103            Self::Observer => "observer",
104        }
105    }
106
107    #[must_use]
108    pub const fn contributes_in_rounds(self) -> bool {
109        matches!(
110            self,
111            Self::Lead | Self::Domain | Self::Critic | Self::Synthesizer | Self::Generalist
112        )
113    }
114
115    #[must_use]
116    pub const fn votes_on_done_gate(self) -> bool {
117        matches!(
118            self,
119            Self::Lead | Self::Domain | Self::Critic | Self::Judge | Self::Generalist
120        )
121    }
122
123    #[must_use]
124    pub const fn can_write_report(self) -> bool {
125        matches!(
126            self,
127            Self::ReportWriter | Self::Synthesizer | Self::Lead | Self::Generalist
128        )
129    }
130}
131
132/// How turns should be organized.
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(rename_all = "snake_case")]
135pub enum TurnCadence {
136    RoundRobin,
137    LeadThenRoundRobin,
138    ModeratorThenRoundRobin,
139    SynthesisOnly,
140    FigureItOut,
141}
142
143impl TurnCadence {
144    #[must_use]
145    pub const fn label(self) -> &'static str {
146        match self {
147            Self::RoundRobin => "round_robin",
148            Self::LeadThenRoundRobin => "lead_then_round_robin",
149            Self::ModeratorThenRoundRobin => "moderator_then_round_robin",
150            Self::SynthesisOnly => "synthesis_only",
151            Self::FigureItOut => "figure_it_out",
152        }
153    }
154}
155
156/// Decision rule used to decide when a team is done or aligned.
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
158#[serde(rename_all = "snake_case")]
159pub enum ConsensusRule {
160    Majority,
161    Supermajority,
162    Unanimous,
163    LeadDecides,
164    AdvisoryOnly,
165}
166
167impl ConsensusRule {
168    #[must_use]
169    pub fn passes(self, yes_votes: usize, total_voters: usize) -> bool {
170        match self {
171            Self::Majority => yes_votes * 2 > total_voters,
172            Self::Supermajority => yes_votes * 3 >= total_voters * 2,
173            Self::Unanimous => yes_votes == total_voters,
174            Self::LeadDecides => yes_votes >= 1,
175            Self::AdvisoryOnly => true,
176        }
177    }
178
179    #[must_use]
180    pub const fn label(self) -> &'static str {
181        match self {
182            Self::Majority => "majority",
183            Self::Supermajority => "supermajority",
184            Self::Unanimous => "unanimous",
185            Self::LeadDecides => "lead_decides",
186            Self::AdvisoryOnly => "advisory_only",
187        }
188    }
189}
190
191/// A collaborator in a planning or research team.
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(rename_all = "camelCase")]
194pub struct CollaborationMember {
195    pub id: String,
196    pub display_name: String,
197    pub role: CollaborationRole,
198    pub persona: Option<String>,
199}
200
201impl CollaborationMember {
202    #[must_use]
203    pub fn new(
204        id: impl Into<String>,
205        display_name: impl Into<String>,
206        role: CollaborationRole,
207    ) -> Self {
208        Self {
209            id: id.into(),
210            display_name: display_name.into(),
211            role,
212            persona: None,
213        }
214    }
215
216    #[must_use]
217    pub fn with_persona(mut self, persona: impl Into<String>) -> Self {
218        self.persona = Some(persona.into());
219        self
220    }
221}
222
223/// Team formation input for a collaboration.
224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
225#[serde(rename_all = "camelCase")]
226pub struct TeamFormation {
227    pub mode: TeamFormationMode,
228    pub members: Vec<CollaborationMember>,
229}
230
231impl TeamFormation {
232    #[must_use]
233    pub fn new(mode: TeamFormationMode, members: Vec<CollaborationMember>) -> Self {
234        Self { mode, members }
235    }
236
237    #[must_use]
238    pub fn curated(members: Vec<CollaborationMember>) -> Self {
239        Self::new(TeamFormationMode::Curated, members)
240    }
241
242    #[must_use]
243    pub fn member_count(&self) -> usize {
244        self.members.len()
245    }
246
247    #[must_use]
248    pub fn roles(&self) -> Vec<CollaborationRole> {
249        self.members.iter().map(|member| member.role).collect()
250    }
251
252    #[must_use]
253    pub fn contributors(&self) -> Vec<&CollaborationMember> {
254        self.members
255            .iter()
256            .filter(|member| member.role.contributes_in_rounds())
257            .collect()
258    }
259
260    #[must_use]
261    pub fn voters(&self) -> Vec<&CollaborationMember> {
262        self.members
263            .iter()
264            .filter(|member| member.role.votes_on_done_gate())
265            .collect()
266    }
267
268    #[must_use]
269    pub fn report_owner(&self) -> Option<&CollaborationMember> {
270        self.members
271            .iter()
272            .find(|member| member.role.can_write_report())
273    }
274}
275
276/// Explicit rules for how a team collaborates.
277#[allow(clippy::struct_excessive_bools)]
278#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
279#[serde(rename_all = "camelCase")]
280pub struct CollaborationCharter {
281    pub topology: CollaborationTopology,
282    pub formation_mode: TeamFormationMode,
283    pub discipline: CollaborationDiscipline,
284    pub turn_cadence: TurnCadence,
285    pub consensus_rule: ConsensusRule,
286    pub minimum_members: usize,
287    pub require_explicit_turns: bool,
288    pub require_round_synthesis: bool,
289    pub require_dissent_map: bool,
290    pub require_done_gate: bool,
291    pub require_report_owner: bool,
292    pub expected_roles: Vec<CollaborationRole>,
293}
294
295impl CollaborationCharter {
296    #[must_use]
297    pub fn huddle() -> Self {
298        Self {
299            topology: CollaborationTopology::Huddle,
300            formation_mode: TeamFormationMode::CapabilityMatched,
301            discipline: CollaborationDiscipline::Enforced,
302            turn_cadence: TurnCadence::RoundRobin,
303            consensus_rule: ConsensusRule::Majority,
304            minimum_members: 3,
305            require_explicit_turns: true,
306            require_round_synthesis: true,
307            require_dissent_map: true,
308            require_done_gate: true,
309            require_report_owner: true,
310            expected_roles: vec![
311                CollaborationRole::Lead,
312                CollaborationRole::Domain,
313                CollaborationRole::Critic,
314                CollaborationRole::Synthesizer,
315            ],
316        }
317    }
318
319    #[must_use]
320    pub fn discussion_group() -> Self {
321        Self {
322            topology: CollaborationTopology::DiscussionGroup,
323            formation_mode: TeamFormationMode::CapabilityMatched,
324            discipline: CollaborationDiscipline::Moderated,
325            turn_cadence: TurnCadence::ModeratorThenRoundRobin,
326            consensus_rule: ConsensusRule::AdvisoryOnly,
327            minimum_members: 3,
328            require_explicit_turns: true,
329            require_round_synthesis: true,
330            require_dissent_map: false,
331            require_done_gate: false,
332            require_report_owner: true,
333            expected_roles: vec![
334                CollaborationRole::Moderator,
335                CollaborationRole::Domain,
336                CollaborationRole::Generalist,
337            ],
338        }
339    }
340
341    #[must_use]
342    pub fn panel() -> Self {
343        Self {
344            topology: CollaborationTopology::Panel,
345            formation_mode: TeamFormationMode::Curated,
346            discipline: CollaborationDiscipline::Enforced,
347            turn_cadence: TurnCadence::LeadThenRoundRobin,
348            consensus_rule: ConsensusRule::Majority,
349            minimum_members: 3,
350            require_explicit_turns: true,
351            require_round_synthesis: true,
352            require_dissent_map: true,
353            require_done_gate: true,
354            require_report_owner: true,
355            expected_roles: vec![
356                CollaborationRole::Lead,
357                CollaborationRole::Domain,
358                CollaborationRole::Critic,
359            ],
360        }
361    }
362
363    /// Very loose self-organizing collaboration.
364    #[must_use]
365    pub fn self_organizing() -> Self {
366        Self {
367            topology: CollaborationTopology::SelfOrganizing,
368            formation_mode: TeamFormationMode::OpenCall,
369            discipline: CollaborationDiscipline::Loose,
370            turn_cadence: TurnCadence::FigureItOut,
371            consensus_rule: ConsensusRule::AdvisoryOnly,
372            minimum_members: 1,
373            require_explicit_turns: false,
374            require_round_synthesis: true,
375            require_dissent_map: false,
376            require_done_gate: true,
377            require_report_owner: true,
378            expected_roles: vec![CollaborationRole::Generalist],
379        }
380    }
381
382    #[must_use]
383    pub fn with_consensus_rule(mut self, rule: ConsensusRule) -> Self {
384        self.consensus_rule = rule;
385        self
386    }
387
388    #[must_use]
389    pub fn with_turn_cadence(mut self, cadence: TurnCadence) -> Self {
390        self.turn_cadence = cadence;
391        self
392    }
393
394    #[must_use]
395    pub fn with_discipline(mut self, discipline: CollaborationDiscipline) -> Self {
396        self.discipline = discipline;
397        self
398    }
399
400    #[must_use]
401    pub fn with_topology(mut self, topology: CollaborationTopology) -> Self {
402        self.topology = topology;
403        self
404    }
405
406    #[must_use]
407    pub fn with_minimum_members(mut self, n: usize) -> Self {
408        self.minimum_members = n;
409        self
410    }
411
412    #[must_use]
413    pub fn with_formation_mode(mut self, mode: TeamFormationMode) -> Self {
414        self.formation_mode = mode;
415        self
416    }
417
418    #[must_use]
419    pub fn with_expected_roles(mut self, roles: Vec<CollaborationRole>) -> Self {
420        self.expected_roles = roles;
421        self
422    }
423
424    pub fn validate(&self, team: &TeamFormation) -> Result<(), CollaborationValidationError> {
425        if team.members.len() < self.minimum_members {
426            return Err(CollaborationValidationError::TooFewMembers {
427                required: self.minimum_members,
428                actual: team.members.len(),
429            });
430        }
431
432        if team.mode != self.formation_mode && self.discipline == CollaborationDiscipline::Enforced
433        {
434            return Err(CollaborationValidationError::FormationModeMismatch {
435                expected: self.formation_mode,
436                actual: team.mode,
437            });
438        }
439
440        for expected in &self.expected_roles {
441            if !team.members.iter().any(|member| member.role == *expected) {
442                return Err(CollaborationValidationError::MissingRole { role: *expected });
443            }
444        }
445
446        if self.require_done_gate && team.voters().is_empty() {
447            return Err(CollaborationValidationError::NoVoters);
448        }
449
450        if self.require_report_owner && team.report_owner().is_none() {
451            return Err(CollaborationValidationError::MissingReportOwner);
452        }
453
454        Ok(())
455    }
456}
457
458#[derive(Debug, thiserror::Error, PartialEq, Eq)]
459pub enum CollaborationValidationError {
460    #[error("team requires at least {required} members, found {actual}")]
461    TooFewMembers { required: usize, actual: usize },
462    #[error("expected formation mode {expected:?}, found {actual:?}")]
463    FormationModeMismatch {
464        expected: TeamFormationMode,
465        actual: TeamFormationMode,
466    },
467    #[error("team is missing required role {role:?}")]
468    MissingRole { role: CollaborationRole },
469    #[error("collaboration requires at least one voter")]
470    NoVoters,
471    #[error("collaboration requires a report owner")]
472    MissingReportOwner,
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    fn sample_panel_team() -> TeamFormation {
480        TeamFormation::curated(vec![
481            CollaborationMember::new("lead", "Lead", CollaborationRole::Lead),
482            CollaborationMember::new("domain", "Domain", CollaborationRole::Domain),
483            CollaborationMember::new("critic", "Critic", CollaborationRole::Critic),
484            CollaborationMember::new("judge", "Judge", CollaborationRole::Judge),
485            CollaborationMember::new("writer", "Writer", CollaborationRole::ReportWriter),
486        ])
487    }
488
489    #[test]
490    fn panel_requires_expected_roles() {
491        let team = sample_panel_team();
492        assert!(CollaborationCharter::panel().validate(&team).is_ok());
493    }
494
495    #[test]
496    fn self_organizing_accepts_loose_team() {
497        let team = TeamFormation::new(
498            TeamFormationMode::OpenCall,
499            vec![CollaborationMember::new(
500                "generalist",
501                "Generalist",
502                CollaborationRole::Generalist,
503            )],
504        );
505        assert!(
506            CollaborationCharter::self_organizing()
507                .validate(&team)
508                .is_ok()
509        );
510    }
511
512    #[test]
513    fn enforced_panel_rejects_missing_critic() {
514        let team = TeamFormation::curated(vec![
515            CollaborationMember::new("lead", "Lead", CollaborationRole::Lead),
516            CollaborationMember::new("domain", "Domain", CollaborationRole::Domain),
517            CollaborationMember::new("judge", "Judge", CollaborationRole::Judge),
518        ]);
519        assert_eq!(
520            CollaborationCharter::panel().validate(&team),
521            Err(CollaborationValidationError::MissingRole {
522                role: CollaborationRole::Critic,
523            })
524        );
525    }
526
527    // ── Negative tests ────────────────────────────────────────────
528
529    #[test]
530    fn huddle_rejects_empty_team() {
531        let team = TeamFormation::new(TeamFormationMode::CapabilityMatched, vec![]);
532        assert_eq!(
533            CollaborationCharter::huddle().validate(&team),
534            Err(CollaborationValidationError::TooFewMembers {
535                required: 3,
536                actual: 0,
537            })
538        );
539    }
540
541    #[test]
542    fn huddle_rejects_undersized_team() {
543        let team = TeamFormation::new(
544            TeamFormationMode::CapabilityMatched,
545            vec![
546                CollaborationMember::new("lead", "Lead", CollaborationRole::Lead),
547                CollaborationMember::new("domain", "Domain", CollaborationRole::Domain),
548            ],
549        );
550        assert_eq!(
551            CollaborationCharter::huddle().validate(&team),
552            Err(CollaborationValidationError::TooFewMembers {
553                required: 3,
554                actual: 2,
555            })
556        );
557    }
558
559    #[test]
560    fn enforced_charter_rejects_formation_mode_mismatch() {
561        let team = TeamFormation::new(
562            TeamFormationMode::OpenCall,
563            vec![
564                CollaborationMember::new("lead", "Lead", CollaborationRole::Lead),
565                CollaborationMember::new("domain", "Domain", CollaborationRole::Domain),
566                CollaborationMember::new("critic", "Critic", CollaborationRole::Critic),
567            ],
568        );
569        assert_eq!(
570            CollaborationCharter::panel().validate(&team),
571            Err(CollaborationValidationError::FormationModeMismatch {
572                expected: TeamFormationMode::Curated,
573                actual: TeamFormationMode::OpenCall,
574            })
575        );
576    }
577
578    #[test]
579    fn moderated_charter_ignores_formation_mode_mismatch() {
580        let team = TeamFormation::new(
581            TeamFormationMode::SelfSelected,
582            vec![
583                CollaborationMember::new("mod", "Mod", CollaborationRole::Moderator),
584                CollaborationMember::new("domain", "Domain", CollaborationRole::Domain),
585                CollaborationMember::new("gen", "Gen", CollaborationRole::Generalist),
586            ],
587        );
588        assert!(
589            CollaborationCharter::discussion_group()
590                .validate(&team)
591                .is_ok()
592        );
593    }
594
595    #[test]
596    fn done_gate_requires_at_least_one_voter() {
597        let team = TeamFormation::new(
598            TeamFormationMode::OpenCall,
599            vec![CollaborationMember::new(
600                "writer",
601                "Writer",
602                CollaborationRole::ReportWriter,
603            )],
604        );
605        let charter =
606            CollaborationCharter::self_organizing().with_consensus_rule(ConsensusRule::Majority);
607
608        let result = charter.validate(&team);
609        assert_eq!(
610            result,
611            Err(CollaborationValidationError::MissingRole {
612                role: CollaborationRole::Generalist,
613            })
614        );
615    }
616
617    #[test]
618    fn team_of_only_observers_has_no_voters() {
619        let mut charter = CollaborationCharter::self_organizing();
620        charter.expected_roles = vec![];
621        charter.require_report_owner = false;
622
623        let team = TeamFormation::new(
624            TeamFormationMode::OpenCall,
625            vec![
626                CollaborationMember::new("obs1", "Observer1", CollaborationRole::Observer),
627                CollaborationMember::new("obs2", "Observer2", CollaborationRole::Observer),
628            ],
629        );
630        assert_eq!(
631            charter.validate(&team),
632            Err(CollaborationValidationError::NoVoters)
633        );
634    }
635
636    #[test]
637    fn missing_report_owner_rejected() {
638        let mut charter = CollaborationCharter::self_organizing();
639        charter.expected_roles = vec![];
640        charter.require_done_gate = false;
641
642        let team = TeamFormation::new(
643            TeamFormationMode::OpenCall,
644            vec![CollaborationMember::new(
645                "critic",
646                "Critic",
647                CollaborationRole::Critic,
648            )],
649        );
650        assert_eq!(
651            charter.validate(&team),
652            Err(CollaborationValidationError::MissingReportOwner)
653        );
654    }
655
656    // ── ConsensusRule edge cases ──────────────────────────────────
657
658    #[test]
659    fn majority_needs_strict_majority() {
660        assert!(!ConsensusRule::Majority.passes(2, 4));
661        assert!(ConsensusRule::Majority.passes(3, 4));
662        assert!(!ConsensusRule::Majority.passes(0, 1));
663        assert!(ConsensusRule::Majority.passes(1, 1));
664    }
665
666    #[test]
667    fn supermajority_threshold() {
668        assert!(!ConsensusRule::Supermajority.passes(1, 3));
669        assert!(ConsensusRule::Supermajority.passes(2, 3));
670        assert!(ConsensusRule::Supermajority.passes(4, 6));
671        assert!(!ConsensusRule::Supermajority.passes(3, 6));
672    }
673
674    #[test]
675    fn unanimous_requires_all() {
676        assert!(ConsensusRule::Unanimous.passes(5, 5));
677        assert!(!ConsensusRule::Unanimous.passes(4, 5));
678        assert!(!ConsensusRule::Unanimous.passes(0, 1));
679    }
680
681    #[test]
682    fn lead_decides_needs_one_yes() {
683        assert!(ConsensusRule::LeadDecides.passes(1, 100));
684        assert!(!ConsensusRule::LeadDecides.passes(0, 100));
685    }
686
687    #[test]
688    fn advisory_always_passes() {
689        assert!(ConsensusRule::AdvisoryOnly.passes(0, 0));
690        assert!(ConsensusRule::AdvisoryOnly.passes(0, 100));
691    }
692
693    #[test]
694    fn consensus_with_zero_voters() {
695        assert!(!ConsensusRule::Majority.passes(0, 0));
696        assert!(ConsensusRule::Unanimous.passes(0, 0));
697        assert!(!ConsensusRule::LeadDecides.passes(0, 0));
698    }
699
700    // ── Role capability matrix ────────────────────────────────────
701
702    #[test]
703    fn observer_cannot_contribute_vote_or_write() {
704        assert!(!CollaborationRole::Observer.contributes_in_rounds());
705        assert!(!CollaborationRole::Observer.votes_on_done_gate());
706        assert!(!CollaborationRole::Observer.can_write_report());
707    }
708
709    #[test]
710    fn report_writer_can_write_but_not_vote() {
711        assert!(!CollaborationRole::ReportWriter.contributes_in_rounds());
712        assert!(!CollaborationRole::ReportWriter.votes_on_done_gate());
713        assert!(CollaborationRole::ReportWriter.can_write_report());
714    }
715
716    #[test]
717    fn judge_can_vote_but_not_contribute_or_write() {
718        assert!(!CollaborationRole::Judge.contributes_in_rounds());
719        assert!(CollaborationRole::Judge.votes_on_done_gate());
720        assert!(!CollaborationRole::Judge.can_write_report());
721    }
722
723    #[test]
724    fn moderator_has_no_capabilities() {
725        assert!(!CollaborationRole::Moderator.contributes_in_rounds());
726        assert!(!CollaborationRole::Moderator.votes_on_done_gate());
727        assert!(!CollaborationRole::Moderator.can_write_report());
728    }
729
730    #[test]
731    fn generalist_can_do_everything() {
732        assert!(CollaborationRole::Generalist.contributes_in_rounds());
733        assert!(CollaborationRole::Generalist.votes_on_done_gate());
734        assert!(CollaborationRole::Generalist.can_write_report());
735    }
736
737    // ── TeamFormation helpers ─────────────────────────────────────
738
739    #[test]
740    fn contributors_excludes_non_contributing_roles() {
741        let team = sample_panel_team();
742        let contributors = team.contributors();
743        assert!(contributors.iter().all(|m| m.role.contributes_in_rounds()));
744        assert!(!contributors.iter().any(
745            |m| m.role == CollaborationRole::Judge || m.role == CollaborationRole::ReportWriter
746        ));
747    }
748
749    #[test]
750    fn voters_excludes_non_voting_roles() {
751        let team = sample_panel_team();
752        let voters = team.voters();
753        assert!(voters.iter().all(|m| m.role.votes_on_done_gate()));
754    }
755
756    #[test]
757    fn report_owner_picks_first_capable() {
758        let team = TeamFormation::curated(vec![
759            CollaborationMember::new("observer", "Observer", CollaborationRole::Observer),
760            CollaborationMember::new("critic", "Critic", CollaborationRole::Critic),
761            CollaborationMember::new("writer", "Writer", CollaborationRole::ReportWriter),
762        ]);
763        assert_eq!(team.report_owner().unwrap().id, "writer");
764    }
765
766    #[test]
767    fn report_owner_none_when_no_capable() {
768        let team = TeamFormation::curated(vec![
769            CollaborationMember::new("observer", "Observer", CollaborationRole::Observer),
770            CollaborationMember::new("critic", "Critic", CollaborationRole::Critic),
771        ]);
772        assert!(team.report_owner().is_none());
773    }
774
775    #[test]
776    fn member_with_persona() {
777        let member = CollaborationMember::new("critic", "Red Team", CollaborationRole::Critic)
778            .with_persona("Aggressive skeptic who challenges every assumption");
779        assert_eq!(
780            member.persona.as_deref(),
781            Some("Aggressive skeptic who challenges every assumption")
782        );
783    }
784
785    // ── Charter preset invariants ─────────────────────────────────
786
787    #[test]
788    fn all_presets_require_round_synthesis() {
789        assert!(CollaborationCharter::huddle().require_round_synthesis);
790        assert!(CollaborationCharter::discussion_group().require_round_synthesis);
791        assert!(CollaborationCharter::panel().require_round_synthesis);
792        assert!(CollaborationCharter::self_organizing().require_round_synthesis);
793    }
794
795    #[test]
796    fn self_organizing_is_the_only_single_member_preset() {
797        assert_eq!(CollaborationCharter::self_organizing().minimum_members, 1);
798        assert!(CollaborationCharter::huddle().minimum_members >= 3);
799        assert!(CollaborationCharter::discussion_group().minimum_members >= 3);
800        assert!(CollaborationCharter::panel().minimum_members >= 3);
801    }
802
803    // ── Proptest ──────────────────────────────────────────────────
804
805    mod proptests {
806        use super::*;
807        use proptest::prelude::*;
808
809        fn arb_consensus_rule() -> impl Strategy<Value = ConsensusRule> {
810            prop_oneof![
811                Just(ConsensusRule::Majority),
812                Just(ConsensusRule::Supermajority),
813                Just(ConsensusRule::Unanimous),
814                Just(ConsensusRule::LeadDecides),
815                Just(ConsensusRule::AdvisoryOnly),
816            ]
817        }
818
819        proptest! {
820            #[test]
821            fn consensus_yes_votes_never_exceed_total(
822                rule in arb_consensus_rule(),
823                total in 0_usize..100,
824                yes in 0_usize..100,
825            ) {
826                if yes <= total {
827                    let _ = rule.passes(yes, total);
828                }
829            }
830
831            #[test]
832            fn unanimous_passes_iff_all_vote_yes(
833                total in 0_usize..50,
834                yes in 0_usize..50,
835            ) {
836                prop_assume!(yes <= total);
837                let result = ConsensusRule::Unanimous.passes(yes, total);
838                prop_assert_eq!(result, yes == total);
839            }
840
841            #[test]
842            fn advisory_always_passes_regardless_of_votes(
843                total in 0_usize..100,
844                yes in 0_usize..100,
845            ) {
846                prop_assert!(ConsensusRule::AdvisoryOnly.passes(yes, total));
847            }
848
849            #[test]
850            fn majority_monotonic_in_yes_votes(
851                total in 1_usize..50,
852                yes1_frac in 0.0..=1.0_f64,
853                yes2_frac in 0.0..=1.0_f64,
854            ) {
855                let (lo, hi) = if yes1_frac <= yes2_frac {
856                    (yes1_frac, yes2_frac)
857                } else {
858                    (yes2_frac, yes1_frac)
859                };
860                #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss)]
861                let yes1 = (lo * total as f64) as usize;
862                #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss)]
863                let yes2 = (hi * total as f64) as usize;
864                if ConsensusRule::Majority.passes(yes1, total) {
865                    prop_assert!(ConsensusRule::Majority.passes(yes2, total));
866                }
867            }
868
869            #[test]
870            fn supermajority_is_stricter_than_majority(
871                total in 1_usize..50,
872                yes in 0_usize..50,
873            ) {
874                prop_assume!(yes <= total);
875                if ConsensusRule::Supermajority.passes(yes, total) {
876                    prop_assert!(ConsensusRule::Majority.passes(yes, total));
877                }
878            }
879        }
880    }
881}