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