Skip to main content

organism_runtime/
collaboration.rs

1//! Shared collaboration runtime helpers.
2//!
3//! Products can define their own participant metadata while reusing Organism's
4//! team formation and collaboration charter semantics.
5
6use std::collections::HashMap;
7
8use organism_pack::{
9    CollaborationCharter, CollaborationRole, CollaborationTopology, CollaborationValidationError,
10    ConsensusRule, TeamFormation, TurnCadence,
11};
12
13/// Runtime-side participant contract.
14pub trait CollaborationParticipant: Clone {
15    fn id(&self) -> &str;
16    fn display_name(&self) -> &str;
17    fn role(&self) -> CollaborationRole;
18}
19
20/// Record of a topology transition.
21#[derive(Debug, Clone)]
22pub struct TransitionRecord {
23    pub from: CollaborationTopology,
24    pub to: CollaborationTopology,
25    pub reason: String,
26    pub at_cycle: u32,
27}
28
29/// Validated runtime collaboration view.
30#[derive(Debug, Clone)]
31pub struct CollaborationRunner<P: CollaborationParticipant> {
32    charter: CollaborationCharter,
33    team: TeamFormation,
34    members_by_id: HashMap<String, P>,
35    contributors: Vec<P>,
36    voters: Vec<P>,
37    report_owner: Option<P>,
38    transitions: Vec<TransitionRecord>,
39}
40
41impl<P: CollaborationParticipant> CollaborationRunner<P> {
42    pub fn new(
43        team: TeamFormation,
44        charter: CollaborationCharter,
45        participants: Vec<P>,
46    ) -> Result<Self, CollaborationRunnerError> {
47        charter
48            .validate(&team)
49            .map_err(CollaborationRunnerError::InvalidTeam)?;
50
51        let mut members_by_id = HashMap::new();
52        for participant in participants {
53            members_by_id.insert(participant.id().to_string(), participant);
54        }
55
56        for member in &team.members {
57            let Some(participant) = members_by_id.get(&member.id) else {
58                return Err(CollaborationRunnerError::MissingParticipant {
59                    id: member.id.clone(),
60                    display_name: member.display_name.clone(),
61                });
62            };
63
64            if participant.role() != member.role {
65                return Err(CollaborationRunnerError::RoleMismatch {
66                    id: member.id.clone(),
67                    expected: member.role,
68                    actual: participant.role(),
69                });
70            }
71        }
72
73        let contributors = members_by_id
74            .values()
75            .filter(|participant| participant.role().contributes_in_rounds())
76            .cloned()
77            .collect();
78        let voters = members_by_id
79            .values()
80            .filter(|participant| participant.role().votes_on_done_gate())
81            .cloned()
82            .collect();
83        let report_owner = members_by_id
84            .values()
85            .find(|participant| participant.role().can_write_report())
86            .cloned();
87
88        Ok(Self {
89            charter,
90            team,
91            members_by_id,
92            contributors,
93            voters,
94            report_owner,
95            transitions: Vec::new(),
96        })
97    }
98
99    #[must_use]
100    pub fn team(&self) -> &TeamFormation {
101        &self.team
102    }
103
104    #[must_use]
105    pub fn charter(&self) -> &CollaborationCharter {
106        &self.charter
107    }
108
109    #[must_use]
110    pub fn member(&self, id: &str) -> Option<&P> {
111        self.members_by_id.get(id)
112    }
113
114    #[must_use]
115    pub fn contributors(&self) -> &[P] {
116        &self.contributors
117    }
118
119    #[must_use]
120    pub fn voters(&self) -> &[P] {
121        &self.voters
122    }
123
124    #[must_use]
125    pub fn report_owner(&self) -> Option<&P> {
126        self.report_owner.as_ref()
127    }
128
129    #[must_use]
130    pub fn require_round_synthesis(&self) -> bool {
131        self.charter.require_round_synthesis
132    }
133
134    #[must_use]
135    pub fn require_done_gate(&self) -> bool {
136        self.charter.require_done_gate
137    }
138
139    #[must_use]
140    pub fn require_dissent_map(&self) -> bool {
141        self.charter.require_dissent_map
142    }
143
144    #[must_use]
145    pub fn require_report_owner(&self) -> bool {
146        self.charter.require_report_owner
147    }
148
149    #[must_use]
150    pub fn consensus_rule(&self) -> ConsensusRule {
151        self.charter.consensus_rule
152    }
153
154    #[must_use]
155    pub fn turn_cadence(&self) -> TurnCadence {
156        self.charter.turn_cadence
157    }
158
159    #[must_use]
160    pub fn transitions(&self) -> &[TransitionRecord] {
161        &self.transitions
162    }
163
164    /// Transition to a new charter with a new team and participants.
165    /// Re-validates everything and rebuilds internal state.
166    pub fn transition(
167        &mut self,
168        new_charter: CollaborationCharter,
169        new_team: TeamFormation,
170        new_participants: Vec<P>,
171        reason: String,
172        at_cycle: u32,
173    ) -> Result<(), CollaborationRunnerError> {
174        let from = self.charter.topology;
175        let to = new_charter.topology;
176
177        let rebuilt = Self::new(new_team, new_charter, new_participants)?;
178
179        self.transitions.push(TransitionRecord {
180            from,
181            to,
182            reason,
183            at_cycle,
184        });
185
186        self.charter = rebuilt.charter;
187        self.team = rebuilt.team;
188        self.members_by_id = rebuilt.members_by_id;
189        self.contributors = rebuilt.contributors;
190        self.voters = rebuilt.voters;
191        self.report_owner = rebuilt.report_owner;
192
193        Ok(())
194    }
195}
196
197#[derive(Debug, thiserror::Error, PartialEq, Eq)]
198pub enum CollaborationRunnerError {
199    #[error(transparent)]
200    InvalidTeam(#[from] CollaborationValidationError),
201    #[error("missing runtime participant '{display_name}' ({id})")]
202    MissingParticipant { id: String, display_name: String },
203    #[error("participant '{id}' has role {actual:?}, expected {expected:?}")]
204    RoleMismatch {
205        id: String,
206        expected: CollaborationRole,
207        actual: CollaborationRole,
208    },
209}
210
211#[cfg(test)]
212mod tests {
213    use organism_pack::{CollaborationMember, CollaborationRole, TeamFormation, TeamFormationMode};
214
215    use super::*;
216
217    #[derive(Debug, Clone, PartialEq, Eq)]
218    struct TestParticipant {
219        id: String,
220        display_name: String,
221        role: CollaborationRole,
222    }
223
224    impl CollaborationParticipant for TestParticipant {
225        fn id(&self) -> &str {
226            &self.id
227        }
228
229        fn display_name(&self) -> &str {
230            &self.display_name
231        }
232
233        fn role(&self) -> CollaborationRole {
234            self.role
235        }
236    }
237
238    #[test]
239    fn runner_builds_contributors_and_voters() {
240        let team = TeamFormation::curated(vec![
241            CollaborationMember::new("lead", "Lead", CollaborationRole::Lead),
242            CollaborationMember::new("domain", "Domain", CollaborationRole::Domain),
243            CollaborationMember::new("critic", "Critic", CollaborationRole::Critic),
244            CollaborationMember::new("writer", "Writer", CollaborationRole::ReportWriter),
245        ]);
246        let participants = vec![
247            TestParticipant {
248                id: "lead".into(),
249                display_name: "Lead".into(),
250                role: CollaborationRole::Lead,
251            },
252            TestParticipant {
253                id: "domain".into(),
254                display_name: "Domain".into(),
255                role: CollaborationRole::Domain,
256            },
257            TestParticipant {
258                id: "critic".into(),
259                display_name: "Critic".into(),
260                role: CollaborationRole::Critic,
261            },
262            TestParticipant {
263                id: "writer".into(),
264                display_name: "Writer".into(),
265                role: CollaborationRole::ReportWriter,
266            },
267        ];
268
269        let runner = CollaborationRunner::new(team, CollaborationCharter::panel(), participants)
270            .expect("runner should build");
271
272        assert_eq!(runner.contributors().len(), 3);
273        assert_eq!(runner.voters().len(), 3);
274        assert!(runner.report_owner().unwrap().role().can_write_report());
275    }
276
277    #[test]
278    fn runner_rejects_missing_runtime_participant() {
279        let team = TeamFormation::new(
280            TeamFormationMode::OpenCall,
281            vec![CollaborationMember::new(
282                "generalist",
283                "Generalist",
284                CollaborationRole::Generalist,
285            )],
286        );
287        let err = CollaborationRunner::<TestParticipant>::new(
288            team,
289            CollaborationCharter::self_organizing(),
290            vec![],
291        )
292        .expect_err("runner should reject missing participant");
293
294        assert!(matches!(
295            err,
296            CollaborationRunnerError::MissingParticipant { .. }
297        ));
298    }
299
300    #[test]
301    fn runner_rejects_role_mismatch() {
302        let team = TeamFormation::new(
303            TeamFormationMode::OpenCall,
304            vec![CollaborationMember::new(
305                "gen",
306                "Generalist",
307                CollaborationRole::Generalist,
308            )],
309        );
310        let participants = vec![TestParticipant {
311            id: "gen".into(),
312            display_name: "Generalist".into(),
313            role: CollaborationRole::Critic,
314        }];
315        let err =
316            CollaborationRunner::new(team, CollaborationCharter::self_organizing(), participants)
317                .expect_err("runner should reject role mismatch");
318
319        assert!(matches!(
320            err,
321            CollaborationRunnerError::RoleMismatch {
322                expected: CollaborationRole::Generalist,
323                actual: CollaborationRole::Critic,
324                ..
325            }
326        ));
327    }
328
329    #[test]
330    fn runner_propagates_charter_validation_errors() {
331        let team = TeamFormation::curated(vec![]);
332        let err = CollaborationRunner::<TestParticipant>::new(
333            team,
334            CollaborationCharter::panel(),
335            vec![],
336        )
337        .expect_err("runner should propagate validation error");
338
339        assert!(matches!(err, CollaborationRunnerError::InvalidTeam(_)));
340    }
341
342    #[test]
343    fn runner_member_lookup_returns_none_for_unknown_id() {
344        let team = TeamFormation::new(
345            TeamFormationMode::OpenCall,
346            vec![CollaborationMember::new(
347                "gen",
348                "Gen",
349                CollaborationRole::Generalist,
350            )],
351        );
352        let participants = vec![TestParticipant {
353            id: "gen".into(),
354            display_name: "Gen".into(),
355            role: CollaborationRole::Generalist,
356        }];
357        let runner =
358            CollaborationRunner::new(team, CollaborationCharter::self_organizing(), participants)
359                .unwrap();
360
361        assert!(runner.member("gen").is_some());
362        assert!(runner.member("nonexistent").is_none());
363    }
364
365    #[test]
366    fn runner_delegates_charter_flags() {
367        let charter = CollaborationCharter::huddle();
368        let team = TeamFormation::new(
369            TeamFormationMode::CapabilityMatched,
370            vec![
371                CollaborationMember::new("lead", "Lead", CollaborationRole::Lead),
372                CollaborationMember::new("domain", "Domain", CollaborationRole::Domain),
373                CollaborationMember::new("critic", "Critic", CollaborationRole::Critic),
374                CollaborationMember::new("synth", "Synth", CollaborationRole::Synthesizer),
375            ],
376        );
377        let participants = vec![
378            TestParticipant {
379                id: "lead".into(),
380                display_name: "Lead".into(),
381                role: CollaborationRole::Lead,
382            },
383            TestParticipant {
384                id: "domain".into(),
385                display_name: "Domain".into(),
386                role: CollaborationRole::Domain,
387            },
388            TestParticipant {
389                id: "critic".into(),
390                display_name: "Critic".into(),
391                role: CollaborationRole::Critic,
392            },
393            TestParticipant {
394                id: "synth".into(),
395                display_name: "Synth".into(),
396                role: CollaborationRole::Synthesizer,
397            },
398        ];
399        let runner = CollaborationRunner::new(team, charter, participants).unwrap();
400
401        assert!(runner.require_round_synthesis());
402        assert!(runner.require_done_gate());
403        assert!(runner.require_dissent_map());
404        assert!(runner.require_report_owner());
405        assert_eq!(runner.consensus_rule(), ConsensusRule::Majority);
406        assert_eq!(runner.turn_cadence(), TurnCadence::RoundRobin);
407    }
408
409    #[test]
410    fn runner_report_owner_prefers_synthesizer_over_lead() {
411        let team = TeamFormation::new(
412            TeamFormationMode::OpenCall,
413            vec![
414                CollaborationMember::new("lead", "Lead", CollaborationRole::Lead),
415                CollaborationMember::new("synth", "Synth", CollaborationRole::Synthesizer),
416                CollaborationMember::new("gen", "Gen", CollaborationRole::Generalist),
417            ],
418        );
419        let participants = vec![
420            TestParticipant {
421                id: "lead".into(),
422                display_name: "Lead".into(),
423                role: CollaborationRole::Lead,
424            },
425            TestParticipant {
426                id: "synth".into(),
427                display_name: "Synth".into(),
428                role: CollaborationRole::Synthesizer,
429            },
430            TestParticipant {
431                id: "gen".into(),
432                display_name: "Gen".into(),
433                role: CollaborationRole::Generalist,
434            },
435        ];
436        let mut charter = CollaborationCharter::self_organizing();
437        charter.expected_roles = vec![];
438        let runner = CollaborationRunner::new(team, charter, participants).unwrap();
439
440        let owner = runner.report_owner().unwrap();
441        assert!(owner.role().can_write_report());
442    }
443}