1use canwu_core::{
2 CommandRequestId, DecisionRequestId, DecisionTicketId, DecisionTraceId, EntityRef, PersonId,
3 RandomDrawId,
4};
5use canwu_time::SimTime;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::collections::BTreeMap;
9use std::error::Error;
10use std::fmt::{Display, Formatter};
11
12#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
13#[serde(rename_all = "snake_case")]
14pub enum DecisionErrorCode {
15 ClosedTicket,
16 DuplicateController,
17 DuplicateResponse,
18 DuplicateTicket,
19 DecisionHistoryUnavailable,
20 InvalidController,
21 InvalidDecision,
22 InvalidOption,
23 PolicyMismatch,
24 TicketNotFound,
25 QueryBudgetExceeded,
26 VersionConflict,
27}
28
29#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
30#[serde(rename_all = "snake_case")]
31pub enum DecisionAttemptErrorCode {
32 SimulationRevisionConflict,
33 CommandRequestConflict,
34 EntityUnavailable,
35 ClosedTicket,
36 DuplicateController,
37 DuplicateResponse,
38 DuplicateTicket,
39 DecisionHistoryUnavailable,
40 InvalidController,
41 InvalidDecision,
42 InvalidOption,
43 PolicyMismatch,
44 TicketNotFound,
45 QueryBudgetExceeded,
46 VersionConflict,
47}
48
49impl From<DecisionErrorCode> for DecisionAttemptErrorCode {
50 fn from(value: DecisionErrorCode) -> Self {
51 match value {
52 DecisionErrorCode::ClosedTicket => Self::ClosedTicket,
53 DecisionErrorCode::DuplicateController => Self::DuplicateController,
54 DecisionErrorCode::DuplicateResponse => Self::DuplicateResponse,
55 DecisionErrorCode::DuplicateTicket => Self::DuplicateTicket,
56 DecisionErrorCode::DecisionHistoryUnavailable => Self::DecisionHistoryUnavailable,
57 DecisionErrorCode::InvalidController => Self::InvalidController,
58 DecisionErrorCode::InvalidDecision => Self::InvalidDecision,
59 DecisionErrorCode::InvalidOption => Self::InvalidOption,
60 DecisionErrorCode::PolicyMismatch => Self::PolicyMismatch,
61 DecisionErrorCode::TicketNotFound => Self::TicketNotFound,
62 DecisionErrorCode::QueryBudgetExceeded => Self::QueryBudgetExceeded,
63 DecisionErrorCode::VersionConflict => Self::VersionConflict,
64 }
65 }
66}
67
68#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
69pub struct DecisionError {
70 pub code: DecisionErrorCode,
71 pub message: String,
72}
73
74impl DecisionError {
75 #[must_use]
76 pub fn new(code: DecisionErrorCode, message: impl Into<String>) -> Self {
77 Self {
78 code,
79 message: message.into(),
80 }
81 }
82}
83
84impl Display for DecisionError {
85 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
86 write!(formatter, "{:?}: {}", self.code, self.message)
87 }
88}
89
90impl Error for DecisionError {}
91
92#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
93#[serde(rename_all = "snake_case")]
94pub enum DecisionPolicyKind {
95 Utility,
96 Rule,
97 Random,
98 Human,
99 External,
100 Llm,
101}
102
103#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
104pub struct DecisionPolicyIdentity {
105 pub kind: DecisionPolicyKind,
106 pub id: String,
107 pub version: String,
108}
109
110impl DecisionPolicyIdentity {
111 #[must_use]
112 pub fn new(
113 kind: DecisionPolicyKind,
114 id: impl Into<String>,
115 version: impl Into<String>,
116 ) -> Self {
117 Self {
118 kind,
119 id: id.into(),
120 version: version.into(),
121 }
122 }
123
124 pub(crate) fn validate(&self) -> Result<(), DecisionError> {
125 require_identifier(&self.id, "policy ID")?;
126 require_text(&self.version, "policy version")
127 }
128}
129
130#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
131#[serde(tag = "type", rename_all = "snake_case")]
132pub enum DecisionAuthority {
133 Actor {
134 actor: PersonId,
135 },
136 Institution {
137 institution: EntityRef,
138 responsible_actor: Option<PersonId>,
139 },
140 Council {
141 council_id: String,
142 },
143 NoResponsibleActor {
144 reason: String,
145 },
146}
147
148impl DecisionAuthority {
149 pub(crate) fn validate(&self) -> Result<(), DecisionError> {
150 match self {
151 Self::Actor { .. } | Self::Institution { .. } => Ok(()),
152 Self::Council { council_id } => require_identifier(council_id, "council ID"),
153 Self::NoResponsibleActor { reason } => require_text(reason, "authority reason"),
154 }
155 }
156}
157
158#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
159pub struct DecisionControllerBinding {
160 pub id: String,
161 pub policy: DecisionPolicyIdentity,
162 pub authority: DecisionAuthority,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub seat_id: Option<String>,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub permission_profile_id: Option<String>,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub command_subject: Option<EntityRef>,
169}
170
171impl DecisionControllerBinding {
172 #[must_use]
173 pub fn new(
174 id: impl Into<String>,
175 policy: DecisionPolicyIdentity,
176 authority: DecisionAuthority,
177 ) -> Self {
178 Self {
179 id: id.into(),
180 policy,
181 authority,
182 seat_id: None,
183 permission_profile_id: None,
184 command_subject: None,
185 }
186 }
187
188 #[must_use]
189 pub fn with_seat(
190 mut self,
191 seat_id: impl Into<String>,
192 permission_profile_id: impl Into<String>,
193 ) -> Self {
194 self.seat_id = Some(seat_id.into());
195 self.permission_profile_id = Some(permission_profile_id.into());
196 self
197 }
198
199 #[must_use]
200 pub fn with_command_subject(mut self, command_subject: EntityRef) -> Self {
201 self.command_subject = Some(command_subject);
202 self
203 }
204
205 pub(crate) fn validate(&self) -> Result<(), DecisionError> {
206 require_identifier(&self.id, "controller ID")?;
207 self.policy.validate()?;
208 self.authority.validate()?;
209 if self.seat_id.is_some() != self.permission_profile_id.is_some() {
210 return Err(DecisionError::new(
211 DecisionErrorCode::InvalidController,
212 "seat ID and permission-profile ID must be supplied together",
213 ));
214 }
215 if let Some(seat_id) = &self.seat_id {
216 require_identifier(seat_id, "seat ID")?;
217 }
218 if let Some(profile) = &self.permission_profile_id {
219 require_identifier(profile, "permission-profile ID")?;
220 }
221 Ok(())
222 }
223}
224
225#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
226pub struct DecisionContext {
227 pub schema: String,
228 pub payload: Value,
229}
230
231impl DecisionContext {
232 #[must_use]
233 pub fn new(schema: impl Into<String>, payload: Value) -> Self {
234 Self {
235 schema: schema.into(),
236 payload,
237 }
238 }
239
240 pub(crate) fn validate(&self) -> Result<(), DecisionError> {
241 require_identifier(&self.schema, "decision context schema")
242 }
243}
244
245#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
246#[serde(tag = "type", rename_all = "snake_case")]
247pub enum DecisionAction {
248 #[default]
249 None,
250 Command { command: Value },
253}
254
255#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
256pub struct DecisionOption {
257 pub id: String,
258 pub label: String,
259 pub description: String,
260 #[serde(default)]
261 pub action: DecisionAction,
262 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
263 pub utility_inputs: BTreeMap<String, i64>,
264 #[serde(default, skip_serializing_if = "Vec::is_empty")]
265 pub blockers: Vec<String>,
266 #[serde(default)]
267 pub metadata: Value,
268}
269
270impl DecisionOption {
271 #[must_use]
272 pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
273 Self {
274 id: id.into(),
275 label: label.into(),
276 description: String::new(),
277 action: DecisionAction::None,
278 utility_inputs: BTreeMap::new(),
279 blockers: Vec::new(),
280 metadata: Value::Null,
281 }
282 }
283
284 #[must_use]
285 pub fn with_command(mut self, command: Value) -> Self {
286 self.action = DecisionAction::Command { command };
287 self
288 }
289
290 #[must_use]
291 pub fn is_available(&self) -> bool {
292 self.blockers.is_empty()
293 }
294
295 pub(crate) fn validate(&self) -> Result<(), DecisionError> {
296 require_identifier(&self.id, "option ID")?;
297 require_text(&self.label, "option label")?;
298 for key in self.utility_inputs.keys() {
299 require_identifier(key, "utility factor")?;
300 }
301 if self.blockers.iter().any(|value| !is_canonical_text(value)) {
302 return Err(DecisionError::new(
303 DecisionErrorCode::InvalidOption,
304 "option blockers must be non-empty canonical text",
305 ));
306 }
307 Ok(())
308 }
309}
310
311#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
312pub struct DecisionTicketDraft {
313 pub id: DecisionTicketId,
314 pub definition: String,
315 pub decision_maker: EntityRef,
316 pub assigned_controller: String,
317 pub summary: String,
318 pub context: DecisionContext,
319 pub options: Vec<DecisionOption>,
320 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub deadline: Option<SimTime>,
322}
323
324impl DecisionTicketDraft {
325 pub(crate) fn validate(&mut self) -> Result<(), DecisionError> {
326 if self.id.get() == 0 {
327 return Err(DecisionError::new(
328 DecisionErrorCode::InvalidDecision,
329 "decision ticket IDs must be nonzero",
330 ));
331 }
332 require_identifier(&self.definition, "decision definition")?;
333 require_identifier(&self.assigned_controller, "assigned controller")?;
334 require_text(&self.summary, "decision summary")?;
335 self.context.validate()?;
336 canonicalize_options(&mut self.options)
337 }
338}
339
340#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
341#[serde(tag = "state", rename_all = "snake_case")]
342pub enum DecisionTicketState {
343 Open,
344 Resolved {
345 option_id: String,
346 trace_id: DecisionTraceId,
347 },
348 Cancelled {
349 reason: String,
350 },
351 Expired,
352}
353
354#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
355pub struct DecisionTicket {
356 pub id: DecisionTicketId,
357 pub definition: String,
358 pub decision_maker: EntityRef,
359 pub assigned_controller: String,
360 pub summary: String,
361 pub context: DecisionContext,
362 pub options: Vec<DecisionOption>,
363 pub opened_at: SimTime,
364 pub updated_at: SimTime,
365 #[serde(default, skip_serializing_if = "Option::is_none")]
366 pub deadline: Option<SimTime>,
367 pub version: u64,
368 pub state: DecisionTicketState,
369}
370
371impl DecisionTicket {
372 #[must_use]
373 pub fn option(&self, id: &str) -> Option<&DecisionOption> {
374 self.options
375 .binary_search_by(|option| option.id.as_str().cmp(id))
376 .ok()
377 .and_then(|index| self.options.get(index))
378 }
379
380 #[must_use]
381 pub const fn is_open(&self) -> bool {
382 matches!(self.state, DecisionTicketState::Open)
383 }
384
385 pub(crate) fn validate(&self) -> Result<(), DecisionError> {
386 require_identifier(&self.definition, "decision definition")?;
387 require_identifier(&self.assigned_controller, "assigned controller")?;
388 require_text(&self.summary, "decision summary")?;
389 self.context.validate()?;
390 let mut options = self.options.clone();
391 canonicalize_options(&mut options)?;
392 if options != self.options || self.version == 0 || self.updated_at < self.opened_at {
393 return Err(DecisionError::new(
394 DecisionErrorCode::InvalidDecision,
395 "decision ticket ordering, version, or timestamps are invalid",
396 ));
397 }
398 if self
399 .deadline
400 .is_some_and(|deadline| deadline < self.opened_at)
401 {
402 return Err(DecisionError::new(
403 DecisionErrorCode::InvalidDecision,
404 "decision deadline precedes the ticket opening time",
405 ));
406 }
407 match &self.state {
408 DecisionTicketState::Resolved { option_id, .. } if self.option(option_id).is_none() => {
409 Err(DecisionError::new(
410 DecisionErrorCode::InvalidDecision,
411 "resolved decision references an unknown option",
412 ))
413 }
414 DecisionTicketState::Cancelled { reason } => {
415 require_text(reason, "decision cancellation reason")
416 }
417 DecisionTicketState::Open
418 | DecisionTicketState::Resolved { .. }
419 | DecisionTicketState::Expired => Ok(()),
420 }
421 }
422}
423
424#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
425pub struct DecisionFactorContribution {
426 pub factor: String,
427 pub value: i64,
428 pub weight: i64,
429 pub contribution: i64,
430}
431
432#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
433pub struct DecisionOptionEvaluation {
434 pub option_id: String,
435 pub available: bool,
436 #[serde(default, skip_serializing_if = "Option::is_none")]
437 pub score: Option<i64>,
438 #[serde(default, skip_serializing_if = "Vec::is_empty")]
439 pub factors: Vec<DecisionFactorContribution>,
440 #[serde(default, skip_serializing_if = "Vec::is_empty")]
441 pub blockers: Vec<String>,
442}
443
444#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
445pub struct DecisionExternalEvidence {
446 pub provider: String,
447 #[serde(default, skip_serializing_if = "Option::is_none")]
448 pub model: Option<String>,
449 #[serde(default, skip_serializing_if = "Option::is_none")]
450 pub prompt_contract: Option<String>,
451 #[serde(default, skip_serializing_if = "Option::is_none")]
452 pub request_id: Option<String>,
453 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
454 pub metadata: BTreeMap<String, String>,
455}
456
457#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
458pub struct DecisionOptionWeight {
459 pub option_id: String,
460 pub weight: u64,
461}
462
463impl DecisionOptionWeight {
464 #[must_use]
465 pub fn new(option_id: impl Into<String>, weight: u64) -> Self {
466 Self {
467 option_id: option_id.into(),
468 weight,
469 }
470 }
471}
472
473#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
474pub struct DecisionRandomEvidence {
475 pub draw_id: RandomDrawId,
476 pub value: u64,
477 pub upper_exclusive: u64,
478 pub option_weights: Vec<DecisionOptionWeight>,
479}
480
481impl DecisionRandomEvidence {
482 pub fn selected_option(
483 ticket: &DecisionTicket,
484 option_weights: &[DecisionOptionWeight],
485 value: u64,
486 ) -> Result<String, DecisionError> {
487 validate_option_weights(ticket, option_weights)?;
488 let upper_exclusive = checked_option_weight_total(option_weights)?;
489 Self::selected_option_from_weights(option_weights, value, upper_exclusive)
490 }
491
492 pub fn selected_option_from_weights(
493 option_weights: &[DecisionOptionWeight],
494 value: u64,
495 upper_exclusive: u64,
496 ) -> Result<String, DecisionError> {
497 let observed_total = checked_option_weight_total(option_weights)?;
498 if observed_total != upper_exclusive {
499 return Err(DecisionError::new(
500 DecisionErrorCode::InvalidDecision,
501 "random decision option weights disagree with the draw bound",
502 ));
503 }
504 if value >= upper_exclusive {
505 return Err(DecisionError::new(
506 DecisionErrorCode::InvalidDecision,
507 "random decision value is outside its positive total weight",
508 ));
509 }
510 let mut cursor = 0_u64;
511 for option in option_weights {
512 cursor = cursor.checked_add(option.weight).ok_or_else(|| {
513 DecisionError::new(
514 DecisionErrorCode::InvalidDecision,
515 "random decision option weights overflow the supported range",
516 )
517 })?;
518 if value < cursor {
519 return Ok(option.option_id.clone());
520 }
521 }
522 Err(DecisionError::new(
523 DecisionErrorCode::InvalidDecision,
524 "random decision weights did not select an option",
525 ))
526 }
527
528 fn validate(
529 &self,
530 ticket: &DecisionTicket,
531 selected_option: &str,
532 ) -> Result<(), DecisionError> {
533 if self.draw_id.get() == 0 {
534 return Err(DecisionError::new(
535 DecisionErrorCode::InvalidDecision,
536 "random decision evidence requires a nonzero draw ID",
537 ));
538 }
539 let observed = Self::selected_option(ticket, &self.option_weights, self.value)?;
540 if checked_option_weight_total(&self.option_weights)? != self.upper_exclusive {
541 return Err(DecisionError::new(
542 DecisionErrorCode::InvalidDecision,
543 "random decision total weight disagrees with its draw bound",
544 ));
545 }
546 if observed != selected_option {
547 return Err(DecisionError::new(
548 DecisionErrorCode::InvalidDecision,
549 "random decision evidence does not select the recorded option",
550 ));
551 }
552 Ok(())
553 }
554}
555
556#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
557#[serde(tag = "type", rename_all = "snake_case")]
558pub enum DecisionOutcome {
559 Selected { option_id: String },
560 Deferred { reason: String },
561 Pending { reason: String },
562}
563
564#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
565pub struct PolicyDecision {
566 pub outcome: DecisionOutcome,
567 pub summary: String,
568 #[serde(default, skip_serializing_if = "Vec::is_empty")]
569 pub evaluations: Vec<DecisionOptionEvaluation>,
570 #[serde(default, skip_serializing_if = "Option::is_none")]
571 pub external: Option<DecisionExternalEvidence>,
572 #[serde(default, skip_serializing_if = "Option::is_none")]
573 pub random: Option<DecisionRandomEvidence>,
574}
575
576impl PolicyDecision {
577 #[must_use]
578 pub fn selected(option_id: impl Into<String>, summary: impl Into<String>) -> Self {
579 Self {
580 outcome: DecisionOutcome::Selected {
581 option_id: option_id.into(),
582 },
583 summary: summary.into(),
584 evaluations: Vec::new(),
585 external: None,
586 random: None,
587 }
588 }
589
590 #[must_use]
591 pub fn pending(reason: impl Into<String>) -> Self {
592 let reason = reason.into();
593 Self {
594 outcome: DecisionOutcome::Pending {
595 reason: reason.clone(),
596 },
597 summary: reason,
598 evaluations: Vec::new(),
599 external: None,
600 random: None,
601 }
602 }
603
604 pub(crate) fn validate(&self, ticket: &DecisionTicket) -> Result<(), DecisionError> {
605 require_text(&self.summary, "policy decision summary")?;
606 match &self.outcome {
607 DecisionOutcome::Selected { option_id } => {
608 let option = ticket.option(option_id).ok_or_else(|| {
609 DecisionError::new(
610 DecisionErrorCode::InvalidOption,
611 format!("policy selected unknown option {option_id}"),
612 )
613 })?;
614 if !option.is_available() {
615 return Err(DecisionError::new(
616 DecisionErrorCode::InvalidOption,
617 format!("policy selected blocked option {option_id}"),
618 ));
619 }
620 }
621 DecisionOutcome::Deferred { reason } | DecisionOutcome::Pending { reason } => {
622 require_text(reason, "decision outcome reason")?;
623 }
624 }
625 for evaluation in &self.evaluations {
626 if ticket.option(&evaluation.option_id).is_none() {
627 return Err(DecisionError::new(
628 DecisionErrorCode::InvalidDecision,
629 "policy evaluation references an unknown option",
630 ));
631 }
632 }
633 if self.external.is_some() && self.random.is_some() {
634 return Err(DecisionError::new(
635 DecisionErrorCode::InvalidDecision,
636 "a decision cannot carry both external and random evidence",
637 ));
638 }
639 if let Some(random) = &self.random {
640 let DecisionOutcome::Selected { option_id } = &self.outcome else {
641 return Err(DecisionError::new(
642 DecisionErrorCode::InvalidDecision,
643 "random decision evidence requires a selected outcome",
644 ));
645 };
646 random.validate(ticket, option_id)?;
647 }
648 Ok(())
649 }
650}
651
652#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
653pub struct DecisionTrace {
654 pub id: DecisionTraceId,
655 pub ticket_id: DecisionTicketId,
656 pub ticket_version: u64,
657 pub controller_id: String,
658 pub policy: DecisionPolicyIdentity,
659 pub decided_at: SimTime,
660 pub outcome: DecisionOutcome,
661 pub summary: String,
662 #[serde(default, skip_serializing_if = "Vec::is_empty")]
663 pub evaluations: Vec<DecisionOptionEvaluation>,
664 #[serde(default, skip_serializing_if = "Option::is_none")]
665 pub external: Option<DecisionExternalEvidence>,
666 #[serde(default, skip_serializing_if = "Option::is_none")]
667 pub random: Option<DecisionRandomEvidence>,
668 #[serde(default, skip_serializing_if = "Option::is_none")]
669 pub command_request_id: Option<CommandRequestId>,
670}
671
672#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
673#[serde(tag = "outcome", rename_all = "snake_case")]
674pub enum DecisionAttemptOutcome {
675 Accepted {
676 #[serde(default, skip_serializing_if = "Option::is_none")]
677 trace_id: Option<DecisionTraceId>,
678 #[serde(default, skip_serializing_if = "Option::is_none")]
679 command_request_id: Option<CommandRequestId>,
680 },
681 Rejected {
682 code: DecisionAttemptErrorCode,
683 message: String,
684 },
685}
686
687#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
688pub struct DecisionAttemptRecord {
689 pub request_id: DecisionRequestId,
690 #[serde(default, skip_serializing_if = "String::is_empty")]
692 pub request_commitment: String,
693 pub at: SimTime,
694 pub revision_before: u64,
696 pub expected_revision: u64,
697 pub outcome: DecisionAttemptOutcome,
698}
699
700#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
701#[serde(tag = "type", rename_all = "snake_case")]
702pub enum DecisionMutation {
703 RegisterController {
704 controller: DecisionControllerBinding,
705 },
706 Open {
707 ticket: DecisionTicketDraft,
708 },
709 ReplaceOptions {
710 ticket_id: DecisionTicketId,
711 expected_version: u64,
712 context: DecisionContext,
713 options: Vec<DecisionOption>,
714 },
715 Resolve {
716 ticket_id: DecisionTicketId,
717 expected_version: u64,
718 controller_id: String,
719 policy: DecisionPolicyIdentity,
720 decision: PolicyDecision,
721 #[serde(default, skip_serializing_if = "Option::is_none")]
722 command_request_id: Option<CommandRequestId>,
723 },
724 Cancel {
725 ticket_id: DecisionTicketId,
726 expected_version: u64,
727 reason: String,
728 },
729}
730
731pub(crate) fn canonicalize_options(options: &mut Vec<DecisionOption>) -> Result<(), DecisionError> {
732 for option in &mut *options {
733 option.blockers.sort();
734 option.blockers.dedup();
735 option.validate()?;
736 }
737 options.sort_by(|left, right| left.id.cmp(&right.id));
738 if options.is_empty() || options.windows(2).any(|pair| pair[0].id == pair[1].id) {
739 return Err(DecisionError::new(
740 DecisionErrorCode::InvalidOption,
741 "decision options must contain at least one unique option",
742 ));
743 }
744 Ok(())
745}
746
747fn validate_option_weights(
748 ticket: &DecisionTicket,
749 option_weights: &[DecisionOptionWeight],
750) -> Result<(), DecisionError> {
751 checked_option_weight_total(option_weights)?;
752 let available = ticket
753 .options
754 .iter()
755 .filter(|option| option.is_available())
756 .map(|option| option.id.as_str())
757 .collect::<Vec<_>>();
758 if available.len() != option_weights.len()
759 || available
760 .iter()
761 .zip(option_weights)
762 .any(|(option_id, weight)| *option_id != weight.option_id)
763 {
764 return Err(DecisionError::new(
765 DecisionErrorCode::InvalidDecision,
766 "random decision weights must cover every available option exactly once",
767 ));
768 }
769 Ok(())
770}
771
772fn checked_option_weight_total(
773 option_weights: &[DecisionOptionWeight],
774) -> Result<u64, DecisionError> {
775 if option_weights
776 .windows(2)
777 .any(|pair| pair[0].option_id >= pair[1].option_id)
778 {
779 return Err(DecisionError::new(
780 DecisionErrorCode::InvalidDecision,
781 "random decision option weights must be in canonical option-ID order",
782 ));
783 }
784 for option in option_weights {
785 require_identifier(&option.option_id, "random decision option ID")?;
786 }
787 let total = option_weights
788 .iter()
789 .try_fold(0_u64, |sum, option| sum.checked_add(option.weight))
790 .ok_or_else(|| {
791 DecisionError::new(
792 DecisionErrorCode::InvalidDecision,
793 "random decision option weights overflow the supported range",
794 )
795 })?;
796 if total == 0 {
797 return Err(DecisionError::new(
798 DecisionErrorCode::InvalidDecision,
799 "random decision option weights require a positive total",
800 ));
801 }
802 Ok(total)
803}
804
805pub(crate) fn require_identifier(value: &str, label: &str) -> Result<(), DecisionError> {
806 if !is_canonical_text(value) || value.chars().any(char::is_whitespace) {
807 return Err(DecisionError::new(
808 DecisionErrorCode::InvalidDecision,
809 format!("{label} must be non-empty canonical text without whitespace"),
810 ));
811 }
812 Ok(())
813}
814
815pub(crate) fn require_text(value: &str, label: &str) -> Result<(), DecisionError> {
816 if !is_canonical_text(value) {
817 return Err(DecisionError::new(
818 DecisionErrorCode::InvalidDecision,
819 format!("{label} must be non-empty canonical text"),
820 ));
821 }
822 Ok(())
823}
824
825fn is_canonical_text(value: &str) -> bool {
826 !value.is_empty() && value == value.trim()
827}