1use std::collections::BTreeMap;
21
22use serde::{Deserialize, Serialize};
23use time::OffsetDateTime;
24
25use crate::entities::proposal::Proposal;
26use crate::entities::validation::ValidationOutcome;
27use crate::error::DomainError;
28use crate::value_objects::{DurationMs, ProposalId, Rounds, Specialty, TaskId};
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
32pub enum DeliberationPhase {
33 Proposing,
35 Revising,
38 Validating,
40 Scoring,
42 Completed,
44}
45
46impl DeliberationPhase {
47 fn name(self) -> &'static str {
48 match self {
49 Self::Proposing => "Proposing",
50 Self::Revising => "Revising",
51 Self::Validating => "Validating",
52 Self::Scoring => "Scoring",
53 Self::Completed => "Completed",
54 }
55 }
56
57 fn next(self) -> Option<Self> {
58 Some(match self {
59 Self::Proposing => Self::Revising,
60 Self::Revising => Self::Validating,
61 Self::Validating => Self::Scoring,
62 Self::Scoring => Self::Completed,
63 Self::Completed => return None,
64 })
65 }
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct RankedOutcome {
71 proposal: Proposal,
72 outcome: ValidationOutcome,
73 rank: u32,
74}
75
76impl RankedOutcome {
77 #[must_use]
81 pub fn new(proposal: Proposal, outcome: ValidationOutcome, rank: u32) -> Self {
82 Self {
83 proposal,
84 outcome,
85 rank,
86 }
87 }
88
89 #[must_use]
90 pub fn proposal(&self) -> &Proposal {
91 &self.proposal
92 }
93 #[must_use]
94 pub fn outcome(&self) -> &ValidationOutcome {
95 &self.outcome
96 }
97 #[must_use]
98 pub fn rank(&self) -> u32 {
99 self.rank
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct Deliberation {
106 task_id: TaskId,
107 specialty: Specialty,
108 rounds_budget: Rounds,
109 phase: DeliberationPhase,
110
111 proposals: BTreeMap<ProposalId, Proposal>,
112 outcomes: BTreeMap<ProposalId, ValidationOutcome>,
113 ranking: Vec<ProposalId>,
114
115 #[serde(with = "time::serde::rfc3339")]
116 started_at: OffsetDateTime,
117 #[serde(with = "time::serde::rfc3339::option")]
118 completed_at: Option<OffsetDateTime>,
119}
120
121impl Deliberation {
122 #[must_use]
123 pub fn start(
124 task_id: TaskId,
125 specialty: Specialty,
126 rounds_budget: Rounds,
127 now: OffsetDateTime,
128 ) -> Self {
129 Self {
130 task_id,
131 specialty,
132 rounds_budget,
133 phase: DeliberationPhase::Proposing,
134 proposals: BTreeMap::new(),
135 outcomes: BTreeMap::new(),
136 ranking: Vec::new(),
137 started_at: now,
138 completed_at: None,
139 }
140 }
141
142 #[must_use]
143 pub fn task_id(&self) -> &TaskId {
144 &self.task_id
145 }
146 #[must_use]
147 pub fn specialty(&self) -> &Specialty {
148 &self.specialty
149 }
150 #[must_use]
151 pub fn rounds_budget(&self) -> Rounds {
152 self.rounds_budget
153 }
154 #[must_use]
155 pub fn phase(&self) -> DeliberationPhase {
156 self.phase
157 }
158 #[must_use]
159 pub fn proposals(&self) -> &BTreeMap<ProposalId, Proposal> {
160 &self.proposals
161 }
162 #[must_use]
163 pub fn outcomes(&self) -> &BTreeMap<ProposalId, ValidationOutcome> {
164 &self.outcomes
165 }
166 #[must_use]
167 pub fn started_at(&self) -> OffsetDateTime {
168 self.started_at
169 }
170 #[must_use]
171 pub fn completed_at(&self) -> Option<OffsetDateTime> {
172 self.completed_at
173 }
174
175 pub fn add_proposal(&mut self, proposal: Proposal) -> Result<(), DomainError> {
178 self.require_phase(DeliberationPhase::Proposing)?;
179 if self.proposals.contains_key(proposal.id()) {
180 return Err(DomainError::AlreadyExists {
181 what: "deliberation.proposal",
182 });
183 }
184 self.proposals.insert(proposal.id().clone(), proposal);
185 Ok(())
186 }
187
188 pub fn revise_proposal(
190 &mut self,
191 proposal_id: &ProposalId,
192 new_content: impl Into<String>,
193 now: OffsetDateTime,
194 ) -> Result<(), DomainError> {
195 self.require_phase(DeliberationPhase::Revising)?;
196 let proposal = self
197 .proposals
198 .get_mut(proposal_id)
199 .ok_or(DomainError::NotFound {
200 what: "deliberation.proposal",
201 })?;
202 proposal.revise(new_content, now)
203 }
204
205 pub fn attach_outcome(
209 &mut self,
210 proposal_id: &ProposalId,
211 outcome: ValidationOutcome,
212 ) -> Result<(), DomainError> {
213 self.require_phase(DeliberationPhase::Validating)?;
214 if !self.proposals.contains_key(proposal_id) {
215 return Err(DomainError::NotFound {
216 what: "deliberation.proposal",
217 });
218 }
219 if self.outcomes.contains_key(proposal_id) {
220 return Err(DomainError::AlreadyExists {
221 what: "deliberation.outcome",
222 });
223 }
224 self.outcomes.insert(proposal_id.clone(), outcome);
225 Ok(())
226 }
227
228 #[allow(unknown_lints, clippy::collapsible_match)] pub fn advance(&mut self) -> Result<DeliberationPhase, DomainError> {
236 let next = self.phase.next().ok_or(DomainError::InvalidTransition {
237 from: "Completed",
238 to: "Completed",
239 })?;
240
241 match (self.phase, next) {
242 (DeliberationPhase::Proposing, DeliberationPhase::Revising) => {
243 if self.proposals.is_empty() {
244 return Err(DomainError::InvariantViolated {
245 reason: "cannot leave Proposing without proposals",
246 });
247 }
248 }
249 (DeliberationPhase::Validating, DeliberationPhase::Scoring) => {
250 if self.outcomes.len() != self.proposals.len() {
251 return Err(DomainError::InvariantViolated {
252 reason: "every proposal must have an outcome before Scoring",
253 });
254 }
255 }
256 _ => {}
257 }
258
259 self.phase = next;
260 Ok(self.phase)
261 }
262
263 pub fn complete(&mut self, now: OffsetDateTime) -> Result<Vec<RankedOutcome>, DomainError> {
267 self.require_phase(DeliberationPhase::Scoring)?;
268
269 let mut ranked =
270 self.materialize_ranked_tuples(&self.proposals.keys().cloned().collect::<Vec<_>>())?;
271
272 ranked.sort_by(|a, b| b.2.score().cmp(&a.2.score()).then_with(|| a.0.cmp(&b.0)));
273
274 self.ranking = ranked.iter().map(|(id, _, _)| id.clone()).collect();
275 self.phase = DeliberationPhase::Completed;
276 self.completed_at = Some(now);
277
278 Ok(ranked
279 .into_iter()
280 .enumerate()
281 .map(|(i, (_, proposal, outcome))| RankedOutcome {
282 proposal,
283 outcome,
284 rank: u32::try_from(i).unwrap_or(u32::MAX),
285 })
286 .collect())
287 }
288
289 pub fn reprioritize(
295 &mut self,
296 ranking: Vec<ProposalId>,
297 ) -> Result<Vec<RankedOutcome>, DomainError> {
298 self.require_phase(DeliberationPhase::Completed)?;
299 let current: std::collections::BTreeSet<_> = self.ranking.iter().cloned().collect();
300 let proposed: std::collections::BTreeSet<_> = ranking.iter().cloned().collect();
301 if ranking.len() != self.ranking.len() || current != proposed {
302 return Err(DomainError::InvariantViolated {
303 reason: "reprioritized ranking must contain every completed proposal exactly once",
304 });
305 }
306
307 let ranked = self.materialize_ranked_tuples(&ranking)?;
308 self.ranking = ranking;
309 Ok(ranked
310 .into_iter()
311 .enumerate()
312 .map(|(i, (_, proposal, outcome))| RankedOutcome {
313 proposal,
314 outcome,
315 rank: u32::try_from(i).unwrap_or(u32::MAX),
316 })
317 .collect())
318 }
319
320 #[must_use]
322 pub fn duration(&self) -> Option<DurationMs> {
323 self.completed_at.map(|end| {
324 let delta = end - self.started_at;
325 let millis = delta.whole_milliseconds();
326 let bounded = u64::try_from(millis).unwrap_or(0);
327 DurationMs::from_millis(bounded)
328 })
329 }
330
331 #[must_use]
332 pub fn ranking(&self) -> &[ProposalId] {
333 &self.ranking
334 }
335
336 pub fn ranked_outcomes(&self) -> Result<Vec<RankedOutcome>, DomainError> {
345 self.require_phase(DeliberationPhase::Completed)?;
346 Ok(self
347 .materialize_ranked_tuples(&self.ranking)?
348 .into_iter()
349 .enumerate()
350 .map(|(i, (_, proposal, outcome))| RankedOutcome {
351 proposal,
352 outcome,
353 rank: u32::try_from(i).unwrap_or(u32::MAX),
354 })
355 .collect())
356 }
357
358 fn require_phase(&self, expected: DeliberationPhase) -> Result<(), DomainError> {
359 if self.phase == expected {
360 Ok(())
361 } else {
362 Err(DomainError::InvalidTransition {
363 from: self.phase.name(),
364 to: expected.name(),
365 })
366 }
367 }
368
369 fn materialize_ranked_tuples(
370 &self,
371 ranking: &[ProposalId],
372 ) -> Result<Vec<(ProposalId, Proposal, ValidationOutcome)>, DomainError> {
373 ranking
374 .iter()
375 .map(|id| {
376 let proposal =
377 self.proposals
378 .get(id)
379 .cloned()
380 .ok_or(DomainError::InvariantViolated {
381 reason: "missing proposal in ranking",
382 })?;
383 let outcome =
384 self.outcomes
385 .get(id)
386 .cloned()
387 .ok_or(DomainError::InvariantViolated {
388 reason: "missing outcome at Scoring",
389 })?;
390 Ok::<_, DomainError>((id.clone(), proposal, outcome))
391 })
392 .collect()
393 }
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399 use crate::entities::validation::ValidatorReport;
400 use crate::value_objects::{AgentId, Attributes, Score, TaskId};
401 use time::macros::datetime;
402
403 fn now() -> OffsetDateTime {
404 datetime!(2026-04-15 12:00:00 UTC)
405 }
406
407 fn specialty() -> Specialty {
408 Specialty::new("triage").unwrap()
409 }
410
411 fn start() -> Deliberation {
412 Deliberation::start(
413 TaskId::new("t1").unwrap(),
414 specialty(),
415 Rounds::default(),
416 now(),
417 )
418 }
419
420 fn proposal(id: &str, content: &str) -> Proposal {
421 Proposal::new(
422 ProposalId::new(id).unwrap(),
423 AgentId::new("a").unwrap(),
424 specialty(),
425 content,
426 Attributes::empty(),
427 now(),
428 )
429 .unwrap()
430 }
431
432 fn outcome(score: f64) -> ValidationOutcome {
433 ValidationOutcome::new(
434 Score::new(score).unwrap(),
435 vec![ValidatorReport::new("x", true, "", Attributes::empty()).unwrap()],
436 )
437 }
438
439 #[test]
440 fn starts_in_proposing() {
441 let d = start();
442 assert_eq!(d.phase(), DeliberationPhase::Proposing);
443 assert!(d.proposals().is_empty());
444 assert!(d.completed_at().is_none());
445 }
446
447 #[test]
448 fn proposals_only_accepted_while_proposing() {
449 let mut d = start();
450 d.add_proposal(proposal("p1", "x")).unwrap();
451 d.advance().unwrap(); let err = d.add_proposal(proposal("p2", "y")).unwrap_err();
453 assert!(matches!(err, DomainError::InvalidTransition { .. }));
454 }
455
456 #[test]
457 fn duplicate_proposal_id_is_rejected() {
458 let mut d = start();
459 d.add_proposal(proposal("p1", "x")).unwrap();
460 assert!(matches!(
461 d.add_proposal(proposal("p1", "y")).unwrap_err(),
462 DomainError::AlreadyExists { .. }
463 ));
464 }
465
466 #[test]
467 fn cannot_leave_proposing_without_proposals() {
468 let mut d = start();
469 assert!(matches!(
470 d.advance().unwrap_err(),
471 DomainError::InvariantViolated { .. }
472 ));
473 assert_eq!(d.phase(), DeliberationPhase::Proposing);
474 }
475
476 #[test]
477 fn revise_only_allowed_while_revising() {
478 let mut d = start();
479 d.add_proposal(proposal("p1", "x")).unwrap();
480 assert!(matches!(
481 d.revise_proposal(&ProposalId::new("p1").unwrap(), "y", now())
482 .unwrap_err(),
483 DomainError::InvalidTransition { .. }
484 ));
485 d.advance().unwrap(); d.revise_proposal(&ProposalId::new("p1").unwrap(), "y", now())
487 .unwrap();
488 assert_eq!(
489 d.proposals()
490 .get(&ProposalId::new("p1").unwrap())
491 .unwrap()
492 .content(),
493 "y"
494 );
495 }
496
497 #[test]
498 fn cannot_enter_scoring_with_missing_outcomes() {
499 let mut d = start();
500 d.add_proposal(proposal("p1", "x")).unwrap();
501 d.add_proposal(proposal("p2", "y")).unwrap();
502 for _ in 0..2 {
503 d.advance().unwrap();
504 }
505 d.attach_outcome(&ProposalId::new("p1").unwrap(), outcome(0.9))
507 .unwrap();
508 assert!(matches!(
509 d.advance().unwrap_err(),
510 DomainError::InvariantViolated { .. }
511 ));
512 }
513
514 #[test]
515 fn duplicate_outcome_is_rejected() {
516 let mut d = start();
517 d.add_proposal(proposal("p1", "x")).unwrap();
518 for _ in 0..2 {
519 d.advance().unwrap();
520 }
521 d.attach_outcome(&ProposalId::new("p1").unwrap(), outcome(0.8))
522 .unwrap();
523 assert!(matches!(
524 d.attach_outcome(&ProposalId::new("p1").unwrap(), outcome(0.9))
525 .unwrap_err(),
526 DomainError::AlreadyExists { .. }
527 ));
528 }
529
530 #[test]
531 fn complete_ranks_descending_by_score() {
532 let mut d = start();
533 d.add_proposal(proposal("p1", "a")).unwrap();
534 d.add_proposal(proposal("p2", "b")).unwrap();
535 d.add_proposal(proposal("p3", "c")).unwrap();
536 for _ in 0..2 {
537 d.advance().unwrap();
538 }
539 d.attach_outcome(&ProposalId::new("p1").unwrap(), outcome(0.5))
540 .unwrap();
541 d.attach_outcome(&ProposalId::new("p2").unwrap(), outcome(0.9))
542 .unwrap();
543 d.attach_outcome(&ProposalId::new("p3").unwrap(), outcome(0.7))
544 .unwrap();
545 d.advance().unwrap(); let ranked = d.complete(datetime!(2026-04-15 12:00:01 UTC)).unwrap();
548 assert_eq!(d.phase(), DeliberationPhase::Completed);
549 assert_eq!(ranked[0].rank(), 0);
550 assert_eq!(ranked[0].proposal().id().as_str(), "p2");
551 assert_eq!(ranked[1].proposal().id().as_str(), "p3");
552 assert_eq!(ranked[2].proposal().id().as_str(), "p1");
553 }
554
555 #[test]
556 fn reprioritize_reorders_completed_ranking() {
557 let mut d = start();
558 d.add_proposal(proposal("p1", "a")).unwrap();
559 d.add_proposal(proposal("p2", "b")).unwrap();
560 for _ in 0..2 {
561 d.advance().unwrap();
562 }
563 d.attach_outcome(&ProposalId::new("p1").unwrap(), outcome(0.9))
564 .unwrap();
565 d.attach_outcome(&ProposalId::new("p2").unwrap(), outcome(0.1))
566 .unwrap();
567 d.advance().unwrap();
568 d.complete(now()).unwrap();
569
570 let reprioritized = d
571 .reprioritize(vec![
572 ProposalId::new("p2").unwrap(),
573 ProposalId::new("p1").unwrap(),
574 ])
575 .unwrap();
576 assert_eq!(reprioritized[0].proposal().id().as_str(), "p2");
577 assert_eq!(reprioritized[1].proposal().id().as_str(), "p1");
578 assert_eq!(d.ranking()[0].as_str(), "p2");
579 }
580
581 #[test]
582 fn ties_are_broken_by_proposal_id() {
583 let mut d = start();
584 d.add_proposal(proposal("p2", "a")).unwrap();
585 d.add_proposal(proposal("p1", "b")).unwrap();
586 for _ in 0..2 {
587 d.advance().unwrap();
588 }
589 d.attach_outcome(&ProposalId::new("p1").unwrap(), outcome(0.7))
590 .unwrap();
591 d.attach_outcome(&ProposalId::new("p2").unwrap(), outcome(0.7))
592 .unwrap();
593 d.advance().unwrap();
594
595 let ranked = d.complete(now()).unwrap();
596 assert_eq!(ranked[0].proposal().id().as_str(), "p1");
597 assert_eq!(ranked[1].proposal().id().as_str(), "p2");
598 }
599
600 #[test]
601 fn complete_only_allowed_from_scoring() {
602 let mut d = start();
603 d.add_proposal(proposal("p1", "x")).unwrap();
604 assert!(matches!(
605 d.complete(now()).unwrap_err(),
606 DomainError::InvalidTransition { .. }
607 ));
608 }
609
610 #[test]
611 fn completed_deliberation_has_duration() {
612 let mut d = start();
613 d.add_proposal(proposal("p1", "x")).unwrap();
614 for _ in 0..2 {
615 d.advance().unwrap();
616 }
617 d.attach_outcome(&ProposalId::new("p1").unwrap(), outcome(0.5))
618 .unwrap();
619 d.advance().unwrap();
620 d.complete(datetime!(2026-04-15 12:00:00.750 UTC)).unwrap();
621
622 assert_eq!(d.duration().unwrap().get(), 750);
623 }
624
625 #[test]
626 fn cannot_advance_past_completed() {
627 let mut d = start();
628 d.add_proposal(proposal("p1", "x")).unwrap();
629 for _ in 0..2 {
630 d.advance().unwrap();
631 }
632 d.attach_outcome(&ProposalId::new("p1").unwrap(), outcome(0.5))
633 .unwrap();
634 d.advance().unwrap();
635 d.complete(now()).unwrap();
636 assert!(matches!(
637 d.advance().unwrap_err(),
638 DomainError::InvalidTransition { .. }
639 ));
640 }
641}