1use std::collections::HashMap;
7
8use organism_pack::{
9 CollaborationCharter, CollaborationRole, CollaborationTopology, CollaborationValidationError,
10 ConsensusRule, TeamFormation, TurnCadence,
11};
12
13pub trait CollaborationParticipant: Clone {
15 fn id(&self) -> &str;
16 fn display_name(&self) -> &str;
17 fn role(&self) -> CollaborationRole;
18}
19
20#[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#[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 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}