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}