Skip to main content

canwu_decision/
policy.rs

1use crate::{
2    DecisionError, DecisionErrorCode, DecisionExternalEvidence, DecisionFactorContribution,
3    DecisionOption, DecisionOptionEvaluation, DecisionOutcome, DecisionPolicyIdentity,
4    DecisionPolicyKind, DecisionTicket, PolicyDecision,
5};
6use serde::{Deserialize, Serialize};
7use std::cmp::Ordering;
8use std::collections::BTreeMap;
9
10pub trait DecisionPolicy {
11    fn identity(&self) -> &DecisionPolicyIdentity;
12    fn decide(&self, ticket: &DecisionTicket) -> Result<PolicyDecision, DecisionError>;
13}
14
15pub trait UtilityEvaluator {
16    fn evaluate(
17        &self,
18        ticket: &DecisionTicket,
19        option: &DecisionOption,
20    ) -> Result<DecisionOptionEvaluation, DecisionError>;
21}
22
23pub trait UtilityPolicy: DecisionPolicy + UtilityEvaluator {}
24
25impl<T: DecisionPolicy + UtilityEvaluator> UtilityPolicy for T {}
26
27#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
28pub struct UtilityProfile {
29    pub weights: BTreeMap<String, i64>,
30}
31
32#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
33pub struct WeightedUtilityEvaluator {
34    pub profile: UtilityProfile,
35}
36
37impl WeightedUtilityEvaluator {
38    #[must_use]
39    pub const fn new(profile: UtilityProfile) -> Self {
40        Self { profile }
41    }
42}
43
44impl UtilityEvaluator for WeightedUtilityEvaluator {
45    fn evaluate(
46        &self,
47        _ticket: &DecisionTicket,
48        option: &DecisionOption,
49    ) -> Result<DecisionOptionEvaluation, DecisionError> {
50        if !option.is_available() {
51            return Ok(DecisionOptionEvaluation {
52                option_id: option.id.clone(),
53                available: false,
54                score: None,
55                factors: Vec::new(),
56                blockers: option.blockers.clone(),
57            });
58        }
59        let mut score = 0_i64;
60        let mut factors = Vec::new();
61        for (factor, value) in &option.utility_inputs {
62            let weight = self
63                .profile
64                .weights
65                .get(factor)
66                .copied()
67                .unwrap_or_default();
68            let contribution = value.checked_mul(weight).ok_or_else(|| {
69                DecisionError::new(
70                    DecisionErrorCode::InvalidDecision,
71                    format!("utility contribution for factor {factor} exceeds the i64 range"),
72                )
73            })?;
74            score = score.checked_add(contribution).ok_or_else(|| {
75                DecisionError::new(
76                    DecisionErrorCode::InvalidDecision,
77                    "utility score exceeds the i64 range",
78                )
79            })?;
80            factors.push(DecisionFactorContribution {
81                factor: factor.clone(),
82                value: *value,
83                weight,
84                contribution,
85            });
86        }
87        Ok(DecisionOptionEvaluation {
88            option_id: option.id.clone(),
89            available: true,
90            score: Some(score),
91            factors,
92            blockers: Vec::new(),
93        })
94    }
95}
96
97#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
98pub struct WeightedUtilityPolicy {
99    pub identity: DecisionPolicyIdentity,
100    pub evaluator: WeightedUtilityEvaluator,
101}
102
103impl WeightedUtilityPolicy {
104    #[must_use]
105    pub fn new(id: impl Into<String>, version: impl Into<String>, profile: UtilityProfile) -> Self {
106        Self {
107            identity: DecisionPolicyIdentity::new(DecisionPolicyKind::Utility, id, version),
108            evaluator: WeightedUtilityEvaluator::new(profile),
109        }
110    }
111}
112
113impl UtilityEvaluator for WeightedUtilityPolicy {
114    fn evaluate(
115        &self,
116        ticket: &DecisionTicket,
117        option: &DecisionOption,
118    ) -> Result<DecisionOptionEvaluation, DecisionError> {
119        self.evaluator.evaluate(ticket, option)
120    }
121}
122
123impl DecisionPolicy for WeightedUtilityPolicy {
124    fn identity(&self) -> &DecisionPolicyIdentity {
125        &self.identity
126    }
127
128    fn decide(&self, ticket: &DecisionTicket) -> Result<PolicyDecision, DecisionError> {
129        let mut evaluations = ticket
130            .options
131            .iter()
132            .map(|option| self.evaluate(ticket, option))
133            .collect::<Result<Vec<_>, _>>()?;
134        evaluations.sort_by(|left, right| left.option_id.cmp(&right.option_id));
135        let selected = evaluations
136            .iter()
137            .filter_map(|evaluation| evaluation.score.map(|score| (score, &evaluation.option_id)))
138            .max_by(|left, right| left.0.cmp(&right.0).then_with(|| right.1.cmp(left.1)))
139            .map(|(_, option_id)| option_id.clone());
140        let Some(option_id) = selected else {
141            return Ok(PolicyDecision {
142                outcome: DecisionOutcome::Deferred {
143                    reason: "no available option".to_owned(),
144                },
145                summary: "utility policy deferred because every option was blocked".to_owned(),
146                evaluations,
147                external: None,
148                random: None,
149            });
150        };
151        Ok(PolicyDecision {
152            outcome: DecisionOutcome::Selected {
153                option_id: option_id.clone(),
154            },
155            summary: format!("utility policy selected {option_id}"),
156            evaluations,
157            external: None,
158            random: None,
159        })
160    }
161}
162
163#[derive(Clone, Debug, Eq, PartialEq)]
164pub enum RuleChoice {
165    Select(String),
166    Defer(String),
167    NoMatch,
168}
169
170pub trait DecisionRule {
171    fn id(&self) -> &str;
172    fn evaluate(&self, ticket: &DecisionTicket) -> Result<RuleChoice, DecisionError>;
173}
174
175pub trait RulePolicy: DecisionPolicy {
176    fn rules(&self) -> &[Box<dyn DecisionRule>];
177}
178
179pub struct OrderedRulePolicy {
180    identity: DecisionPolicyIdentity,
181    rules: Vec<Box<dyn DecisionRule>>,
182}
183
184impl OrderedRulePolicy {
185    #[must_use]
186    pub fn new(
187        id: impl Into<String>,
188        version: impl Into<String>,
189        rules: Vec<Box<dyn DecisionRule>>,
190    ) -> Self {
191        Self {
192            identity: DecisionPolicyIdentity::new(DecisionPolicyKind::Rule, id, version),
193            rules,
194        }
195    }
196}
197
198impl RulePolicy for OrderedRulePolicy {
199    fn rules(&self) -> &[Box<dyn DecisionRule>] {
200        &self.rules
201    }
202}
203
204impl DecisionPolicy for OrderedRulePolicy {
205    fn identity(&self) -> &DecisionPolicyIdentity {
206        &self.identity
207    }
208
209    fn decide(&self, ticket: &DecisionTicket) -> Result<PolicyDecision, DecisionError> {
210        for rule in &self.rules {
211            match rule.evaluate(ticket)? {
212                RuleChoice::Select(option_id) => {
213                    return Ok(PolicyDecision::selected(
214                        option_id,
215                        format!("rule {} selected an option", rule.id()),
216                    ));
217                }
218                RuleChoice::Defer(reason) => {
219                    return Ok(PolicyDecision {
220                        outcome: DecisionOutcome::Deferred {
221                            reason: reason.clone(),
222                        },
223                        summary: format!("rule {} deferred: {reason}", rule.id()),
224                        evaluations: Vec::new(),
225                        external: None,
226                        random: None,
227                    });
228                }
229                RuleChoice::NoMatch => {}
230            }
231        }
232        Ok(PolicyDecision {
233            outcome: DecisionOutcome::Deferred {
234                reason: "no rule matched".to_owned(),
235            },
236            summary: "ordered rule policy exhausted its rules".to_owned(),
237            evaluations: Vec::new(),
238            external: None,
239            random: None,
240        })
241    }
242}
243
244#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
245pub struct HumanDecisionResponse {
246    pub ticket_version: u64,
247    pub option_id: String,
248    pub operator_id: String,
249}
250
251pub trait HumanPolicy: DecisionPolicy {
252    fn submitted_response(&self, ticket: &DecisionTicket) -> Option<HumanDecisionResponse>;
253}
254
255#[derive(Clone, Debug)]
256pub struct QueuedHumanPolicy {
257    identity: DecisionPolicyIdentity,
258    responses: BTreeMap<canwu_core::DecisionTicketId, HumanDecisionResponse>,
259}
260
261impl QueuedHumanPolicy {
262    #[must_use]
263    pub fn new(id: impl Into<String>, version: impl Into<String>) -> Self {
264        Self {
265            identity: DecisionPolicyIdentity::new(DecisionPolicyKind::Human, id, version),
266            responses: BTreeMap::new(),
267        }
268    }
269
270    pub fn submit(
271        &mut self,
272        ticket_id: canwu_core::DecisionTicketId,
273        response: HumanDecisionResponse,
274    ) -> Result<(), DecisionError> {
275        if let Some(existing) = self.responses.get(&ticket_id) {
276            return match existing.ticket_version.cmp(&response.ticket_version) {
277                Ordering::Less => {
278                    self.responses.insert(ticket_id, response);
279                    Ok(())
280                }
281                Ordering::Equal => Err(DecisionError::new(
282                    DecisionErrorCode::DuplicateResponse,
283                    "a response has already been submitted for this decision ticket version",
284                )),
285                Ordering::Greater => Err(DecisionError::new(
286                    DecisionErrorCode::VersionConflict,
287                    "a stale decision response cannot replace a newer queued response",
288                )),
289            };
290        }
291        self.responses.insert(ticket_id, response);
292        Ok(())
293    }
294}
295
296impl HumanPolicy for QueuedHumanPolicy {
297    fn submitted_response(&self, ticket: &DecisionTicket) -> Option<HumanDecisionResponse> {
298        self.responses.get(&ticket.id).cloned()
299    }
300}
301
302impl DecisionPolicy for QueuedHumanPolicy {
303    fn identity(&self) -> &DecisionPolicyIdentity {
304        &self.identity
305    }
306
307    fn decide(&self, ticket: &DecisionTicket) -> Result<PolicyDecision, DecisionError> {
308        let Some(response) = self.submitted_response(ticket) else {
309            return Ok(PolicyDecision::pending("awaiting human selection"));
310        };
311        if response.ticket_version != ticket.version {
312            return Err(DecisionError::new(
313                DecisionErrorCode::VersionConflict,
314                "human response targets a stale decision ticket version",
315            ));
316        }
317        Ok(PolicyDecision {
318            outcome: DecisionOutcome::Selected {
319                option_id: response.option_id,
320            },
321            summary: format!("human operator {} selected an option", response.operator_id),
322            evaluations: Vec::new(),
323            external: Some(DecisionExternalEvidence {
324                provider: "human".to_owned(),
325                model: None,
326                prompt_contract: None,
327                request_id: Some(response.operator_id),
328                metadata: BTreeMap::new(),
329            }),
330            random: None,
331        })
332    }
333}
334
335#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
336pub struct ExternalDecisionOption {
337    pub id: String,
338    pub label: String,
339    pub description: String,
340    pub metadata: serde_json::Value,
341}
342
343#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
344pub struct ExternalDecisionRequest {
345    pub ticket_id: canwu_core::DecisionTicketId,
346    pub ticket_version: u64,
347    pub definition: String,
348    pub summary: String,
349    pub context: crate::DecisionContext,
350    pub options: Vec<ExternalDecisionOption>,
351}
352
353impl From<&DecisionTicket> for ExternalDecisionRequest {
354    fn from(ticket: &DecisionTicket) -> Self {
355        Self {
356            ticket_id: ticket.id,
357            ticket_version: ticket.version,
358            definition: ticket.definition.clone(),
359            summary: ticket.summary.clone(),
360            context: ticket.context.clone(),
361            options: ticket
362                .options
363                .iter()
364                .filter(|option| option.is_available())
365                .map(|option| ExternalDecisionOption {
366                    id: option.id.clone(),
367                    label: option.label.clone(),
368                    description: option.description.clone(),
369                    metadata: option.metadata.clone(),
370                })
371                .collect(),
372        }
373    }
374}
375
376#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
377pub struct ExternalDecisionResponse {
378    pub ticket_version: u64,
379    pub option_id: String,
380    pub provider: String,
381    pub request_id: String,
382    #[serde(default)]
383    pub metadata: BTreeMap<String, String>,
384}
385
386pub trait ExternalPolicy: DecisionPolicy {
387    fn external_request(&self, ticket: &DecisionTicket) -> ExternalDecisionRequest {
388        ticket.into()
389    }
390
391    fn submitted_response(&self, ticket: &DecisionTicket) -> Option<ExternalDecisionResponse>;
392}
393
394#[derive(Clone, Debug)]
395pub struct QueuedExternalPolicy {
396    identity: DecisionPolicyIdentity,
397    responses: BTreeMap<canwu_core::DecisionTicketId, ExternalDecisionResponse>,
398}
399
400impl QueuedExternalPolicy {
401    #[must_use]
402    pub fn new(id: impl Into<String>, version: impl Into<String>) -> Self {
403        Self {
404            identity: DecisionPolicyIdentity::new(DecisionPolicyKind::External, id, version),
405            responses: BTreeMap::new(),
406        }
407    }
408
409    pub fn submit(
410        &mut self,
411        ticket_id: canwu_core::DecisionTicketId,
412        response: ExternalDecisionResponse,
413    ) -> Result<(), DecisionError> {
414        if let Some(existing) = self.responses.get(&ticket_id) {
415            return match existing.ticket_version.cmp(&response.ticket_version) {
416                Ordering::Less => {
417                    self.responses.insert(ticket_id, response);
418                    Ok(())
419                }
420                Ordering::Equal => Err(DecisionError::new(
421                    DecisionErrorCode::DuplicateResponse,
422                    "a response has already been submitted for this decision ticket version",
423                )),
424                Ordering::Greater => Err(DecisionError::new(
425                    DecisionErrorCode::VersionConflict,
426                    "a stale decision response cannot replace a newer queued response",
427                )),
428            };
429        }
430        self.responses.insert(ticket_id, response);
431        Ok(())
432    }
433}
434
435impl ExternalPolicy for QueuedExternalPolicy {
436    fn submitted_response(&self, ticket: &DecisionTicket) -> Option<ExternalDecisionResponse> {
437        self.responses.get(&ticket.id).cloned()
438    }
439}
440
441impl DecisionPolicy for QueuedExternalPolicy {
442    fn identity(&self) -> &DecisionPolicyIdentity {
443        &self.identity
444    }
445
446    fn decide(&self, ticket: &DecisionTicket) -> Result<PolicyDecision, DecisionError> {
447        let Some(response) = self.submitted_response(ticket) else {
448            return Ok(PolicyDecision::pending("awaiting external policy response"));
449        };
450        if response.ticket_version != ticket.version {
451            return Err(DecisionError::new(
452                DecisionErrorCode::VersionConflict,
453                "external response targets a stale decision ticket version",
454            ));
455        }
456        Ok(PolicyDecision {
457            outcome: DecisionOutcome::Selected {
458                option_id: response.option_id,
459            },
460            summary: format!("external provider {} selected an option", response.provider),
461            evaluations: Vec::new(),
462            external: Some(DecisionExternalEvidence {
463                provider: response.provider,
464                model: None,
465                prompt_contract: None,
466                request_id: Some(response.request_id),
467                metadata: response.metadata,
468            }),
469            random: None,
470        })
471    }
472}
473
474#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
475pub struct LlmModelIdentity {
476    pub provider: String,
477    pub model: String,
478    pub prompt_contract: String,
479}
480
481pub trait LlmPolicy: ExternalPolicy {
482    fn model_identity(&self) -> &LlmModelIdentity;
483}
484
485#[derive(Clone, Debug)]
486pub struct QueuedLlmPolicy {
487    identity: DecisionPolicyIdentity,
488    model: LlmModelIdentity,
489    responses: BTreeMap<canwu_core::DecisionTicketId, ExternalDecisionResponse>,
490}
491
492impl QueuedLlmPolicy {
493    #[must_use]
494    pub fn new(id: impl Into<String>, version: impl Into<String>, model: LlmModelIdentity) -> Self {
495        Self {
496            identity: DecisionPolicyIdentity::new(DecisionPolicyKind::Llm, id, version),
497            model,
498            responses: BTreeMap::new(),
499        }
500    }
501
502    pub fn submit(
503        &mut self,
504        ticket_id: canwu_core::DecisionTicketId,
505        response: ExternalDecisionResponse,
506    ) -> Result<(), DecisionError> {
507        if let Some(existing) = self.responses.get(&ticket_id) {
508            return match existing.ticket_version.cmp(&response.ticket_version) {
509                Ordering::Less => {
510                    self.responses.insert(ticket_id, response);
511                    Ok(())
512                }
513                Ordering::Equal => Err(DecisionError::new(
514                    DecisionErrorCode::DuplicateResponse,
515                    "a response has already been submitted for this decision ticket version",
516                )),
517                Ordering::Greater => Err(DecisionError::new(
518                    DecisionErrorCode::VersionConflict,
519                    "a stale decision response cannot replace a newer queued response",
520                )),
521            };
522        }
523        self.responses.insert(ticket_id, response);
524        Ok(())
525    }
526}
527
528impl ExternalPolicy for QueuedLlmPolicy {
529    fn submitted_response(&self, ticket: &DecisionTicket) -> Option<ExternalDecisionResponse> {
530        self.responses.get(&ticket.id).cloned()
531    }
532}
533
534impl LlmPolicy for QueuedLlmPolicy {
535    fn model_identity(&self) -> &LlmModelIdentity {
536        &self.model
537    }
538}
539
540impl DecisionPolicy for QueuedLlmPolicy {
541    fn identity(&self) -> &DecisionPolicyIdentity {
542        &self.identity
543    }
544
545    fn decide(&self, ticket: &DecisionTicket) -> Result<PolicyDecision, DecisionError> {
546        let Some(response) = self.submitted_response(ticket) else {
547            return Ok(PolicyDecision::pending(
548                "awaiting constrained LLM option selection",
549            ));
550        };
551        if response.ticket_version != ticket.version {
552            return Err(DecisionError::new(
553                DecisionErrorCode::VersionConflict,
554                "LLM response targets a stale decision ticket version",
555            ));
556        }
557        if response.provider != self.model.provider {
558            return Err(DecisionError::new(
559                DecisionErrorCode::PolicyMismatch,
560                "LLM response provider does not match the configured model identity",
561            ));
562        }
563        Ok(PolicyDecision {
564            outcome: DecisionOutcome::Selected {
565                option_id: response.option_id,
566            },
567            summary: format!(
568                "LLM {}:{} selected an existing option",
569                self.model.provider, self.model.model
570            ),
571            evaluations: Vec::new(),
572            external: Some(DecisionExternalEvidence {
573                provider: response.provider,
574                model: Some(self.model.model.clone()),
575                prompt_contract: Some(self.model.prompt_contract.clone()),
576                request_id: Some(response.request_id),
577                metadata: response.metadata,
578            }),
579            random: None,
580        })
581    }
582}