Skip to main content

made_core/entities/
deliberation.rs

1//! [`Deliberation`] aggregate.
2//!
3//! The Deliberation is the central aggregate root of MADE.
4//! It owns the lifecycle of one deliberation from proposal generation
5//! through peer review to scoring and completion. State transitions
6//! are explicit and protected so no caller can place the aggregate in
7//! an inconsistent shape.
8//!
9//! Phase graph (linear):
10//!
11//! ```text
12//! Proposing -> Revising -> Validating -> Scoring -> Completed
13//! ```
14//!
15//! Transitions are one-way. `Revising` accepts many `revise_proposal`
16//! calls so the use-case layer can run multiple peer-review rounds
17//! (critique → revise) without adding externally-observable phases.
18//! Methods reject operations that do not match the current phase.
19
20use 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/// Lifecycle phases of a deliberation.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
32pub enum DeliberationPhase {
33    /// Agents are producing their initial proposals.
34    Proposing,
35    /// Peer-review rounds are running: critique → revise, possibly
36    /// many times within this single phase.
37    Revising,
38    /// Validators are running against proposals.
39    Validating,
40    /// Validator reports are being aggregated into a final ranking.
41    Scoring,
42    /// The deliberation is finished; ranked outcomes are immutable.
43    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/// A proposal paired with its validation outcome and final rank.
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct RankedOutcome {
71    proposal: Proposal,
72    outcome: ValidationOutcome,
73    rank: u32,
74}
75
76impl RankedOutcome {
77    /// Pair a proposal with its validation outcome and a rank. The real
78    /// ranking is produced by [`Deliberation::complete`]; this constructor
79    /// also lets scoring policies and tests assemble outcomes directly.
80    #[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/// Aggregate root: one deliberation over one task.
104#[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    /// Add a new proposal. Only allowed while `Proposing`. Duplicate
176    /// proposal ids are rejected.
177    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    /// Revise an existing proposal. Only allowed in the `Revising` phase.
189    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    /// Attach a validation outcome for a proposal. Only allowed in
206    /// `Validating`. Every proposal must receive exactly one outcome
207    /// before advancing to `Scoring`.
208    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    /// Advance to the next phase, enforcing the preconditions of the
229    /// transition:
230    ///
231    /// - `Proposing -> Revising`: at least one proposal present.
232    /// - `Validating -> Scoring`: every proposal has an outcome.
233    /// - Other transitions are unconditional.
234    #[allow(unknown_lints, clippy::collapsible_match)] // collapsible_match added in clippy 1.95; rust 1.90 toolchain doesn't know it
235    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    /// Compute the ranking and mark the deliberation complete. Only
264    /// allowed from `Scoring`. The winning proposal gets rank 0; ties
265    /// are broken by proposal id to keep the ordering deterministic.
266    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    /// Reorder the final ranking after completion while preserving the
290    /// same proposal set. This lets the application layer impose a
291    /// deterministic post-scoring preference (for example, valid
292    /// structured outputs before invalid ones) without mutating
293    /// proposals or outcomes.
294    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    /// Total duration from start to completion, when completed.
321    #[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    /// Reconstruct [`RankedOutcome`]s from the persisted ranking + the
337    /// stored proposals and outcomes. Useful when an upstream caller
338    /// (e.g. `RunCouncilDecisionUseCase` in Warn mode) needs to read a
339    /// completed deliberation back from a repository without re-running
340    /// the algorithm.
341    ///
342    /// Fails with [`DomainError::InvalidTransition`] if the deliberation
343    /// is not yet in the `Completed` phase.
344    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(); // Revising
452        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(); // Revising
486        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        // Now in Validating. Attach only one outcome.
506        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(); // Scoring
546
547        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}