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}