Skip to main content

rustev_contract/
judgment.rs

1//! Judgments, `rustev.judgment/1`, and `rustev.judgment/2` for plans with
2//! `actionability` (spec 033, 3.1.3): the typed result of a plan. A judgment
3//! is a proposal, an escalation, an unresolved outcome, or (version 2) a
4//! refusal or a request for input; it is never a grant and never an effect
5//! (spec 001, 3.2). Version 2 carries an action on every unresolved
6//! outcome, refusal, request for input and notice (spec 033, 3.5).
7
8use std::collections::BTreeMap;
9use std::fmt;
10
11use serde::{Deserialize, Serialize};
12
13use crate::actionability::{Action, NeedsInput, Party, Refusal};
14use crate::decimal::Decimal;
15use crate::definition::ReasonKind;
16use crate::ids::{PlanId, SnapshotId};
17use crate::limits::RECORD_V1;
18use crate::schema;
19use crate::time::{DurationMs, Timestamp};
20
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
22#[serde(deny_unknown_fields)]
23pub struct Judgment {
24    pub schema: String,
25    pub plan_id: PlanId,
26    pub snapshot_id: SnapshotId,
27    pub evaluation_time_ms: Timestamp,
28    pub outcome: Outcome,
29    pub derivation: Derivation,
30    pub lineage: Lineage,
31    /// Uncalibrated thresholds used, truncation, fallbacks.
32    pub notices: Vec<Notice>,
33    /// Which handler, rule and adjustments produced the outcome, in order.
34    pub trace: Vec<String>,
35}
36
37crate::document::versioned_document!(
38    Judgment,
39    [schema::JUDGMENT, schema::JUDGMENT_V2],
40    "rustev.judgment/1 or rustev.judgment/2",
41    RECORD_V1
42);
43
44/// A judgment's outcome. Version 1 uses `Propose`, `Escalate` and
45/// `Unresolved`; version 2 (spec 033, 3.5.1) replaces `Unresolved` with
46/// `UnresolvedAction` and adds `Refused` and `NeedsInput`. Both unresolved
47/// forms share the wire key `unresolved`: `{"unresolved": <reason>}` in
48/// version 1, `{"unresolved": {"reason": <reason>, "action": <action>}}` in
49/// version 2.
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
51#[serde(from = "OutcomeWire", into = "OutcomeWire")]
52pub enum Outcome {
53    Propose {
54        action: String,
55        params: BTreeMap<String, OutValue>,
56    },
57    Escalate {
58        reason: String,
59    },
60    Unresolved(Unresolved),
61    UnresolvedAction {
62        reason: Unresolved,
63        action: Action,
64    },
65    Refused {
66        refusals: Vec<Refusal>,
67        addressed_to: Party,
68    },
69    NeedsInput(Box<NeedsInput>),
70}
71
72impl Outcome {
73    /// The unresolved reason of either version, if the outcome is one.
74    pub fn unresolved(&self) -> Option<&Unresolved> {
75        match self {
76            Outcome::Unresolved(u) | Outcome::UnresolvedAction { reason: u, .. } => Some(u),
77            _ => None,
78        }
79    }
80}
81
82#[derive(Serialize, Deserialize, schemars::JsonSchema)]
83#[serde(rename_all = "snake_case", deny_unknown_fields)]
84enum OutcomeWire {
85    Propose {
86        action: String,
87        params: BTreeMap<String, OutValue>,
88    },
89    Escalate {
90        reason: String,
91    },
92    Unresolved(UnresolvedWire),
93    Refused {
94        refusals: Vec<Refusal>,
95        addressed_to: Party,
96    },
97    NeedsInput(Box<NeedsInput>),
98}
99
100/// Version 2's `{reason, action}` is tried first; no version 1 reason is
101/// named `reason` or `action`, so the two forms never overlap.
102#[derive(Serialize, Deserialize, schemars::JsonSchema)]
103#[serde(untagged)]
104enum UnresolvedWire {
105    Actionable(ActionableUnresolved),
106    Plain(Unresolved),
107}
108
109#[derive(Serialize, Deserialize, schemars::JsonSchema)]
110#[serde(deny_unknown_fields)]
111struct ActionableUnresolved {
112    reason: Unresolved,
113    action: Action,
114}
115
116impl From<OutcomeWire> for Outcome {
117    fn from(w: OutcomeWire) -> Self {
118        match w {
119            OutcomeWire::Propose { action, params } => Outcome::Propose { action, params },
120            OutcomeWire::Escalate { reason } => Outcome::Escalate { reason },
121            OutcomeWire::Unresolved(UnresolvedWire::Plain(u)) => Outcome::Unresolved(u),
122            OutcomeWire::Unresolved(UnresolvedWire::Actionable(a)) => Outcome::UnresolvedAction {
123                reason: a.reason,
124                action: a.action,
125            },
126            OutcomeWire::Refused {
127                refusals,
128                addressed_to,
129            } => Outcome::Refused {
130                refusals,
131                addressed_to,
132            },
133            OutcomeWire::NeedsInput(n) => Outcome::NeedsInput(n),
134        }
135    }
136}
137
138impl From<Outcome> for OutcomeWire {
139    fn from(o: Outcome) -> Self {
140        match o {
141            Outcome::Propose { action, params } => OutcomeWire::Propose { action, params },
142            Outcome::Escalate { reason } => OutcomeWire::Escalate { reason },
143            Outcome::Unresolved(u) => OutcomeWire::Unresolved(UnresolvedWire::Plain(u)),
144            Outcome::UnresolvedAction { reason, action } => {
145                OutcomeWire::Unresolved(UnresolvedWire::Actionable(ActionableUnresolved {
146                    reason,
147                    action,
148                }))
149            }
150            Outcome::Refused {
151                refusals,
152                addressed_to,
153            } => OutcomeWire::Refused {
154                refusals,
155                addressed_to,
156            },
157            Outcome::NeedsInput(n) => OutcomeWire::NeedsInput(n),
158        }
159    }
160}
161
162/// The closed set of unresolved reasons (spec 002, 3.5.6). Field and fact
163/// lists are sorted and deduplicated.
164#[derive(
165    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, schemars::JsonSchema,
166)]
167#[serde(rename_all = "snake_case", deny_unknown_fields)]
168pub enum Unresolved {
169    MissingEvidence {
170        fields: Vec<String>,
171    },
172    StaleEvidence {
173        fields: Vec<String>,
174    },
175    InvalidInput {
176        fields: Vec<String>,
177        detail: String,
178    },
179    Abstained {
180        rule: String,
181    },
182    BackendUnavailable {
183        detail: String,
184    },
185    InvalidBackendOutput {
186        detail: String,
187    },
188    BudgetExhausted {
189        resource: String,
190    },
191    DeadlineExceeded,
192    Conflict {
193        facts: Vec<String>,
194    },
195    Unsupported {
196        capability: String,
197    },
198    /// A read whose value is below a required grade (spec 032, 3.9.4).
199    InsufficientGrade {
200        fields: Vec<String>,
201    },
202    /// Unscreened fields a model step would read (spec 035, 3.9.1).
203    ScreenUnavailable {
204        #[serde(deserialize_with = "crate::screening::unavailable_fields")]
205        #[schemars(length(min = 1))]
206        fields: Vec<String>,
207    },
208}
209
210impl Unresolved {
211    pub fn kind(&self) -> ReasonKind {
212        match self {
213            Unresolved::ScreenUnavailable { .. } => ReasonKind::ScreenUnavailable,
214            Unresolved::MissingEvidence { .. } => ReasonKind::MissingEvidence,
215            Unresolved::StaleEvidence { .. } => ReasonKind::StaleEvidence,
216            Unresolved::InvalidInput { .. } => ReasonKind::InvalidInput,
217            Unresolved::Abstained { .. } => ReasonKind::Abstained,
218            Unresolved::BackendUnavailable { .. } => ReasonKind::BackendUnavailable,
219            Unresolved::InvalidBackendOutput { .. } => ReasonKind::InvalidBackendOutput,
220            Unresolved::BudgetExhausted { .. } => ReasonKind::BudgetExhausted,
221            Unresolved::DeadlineExceeded => ReasonKind::DeadlineExceeded,
222            Unresolved::Conflict { .. } => ReasonKind::Conflict,
223            Unresolved::Unsupported { .. } => ReasonKind::Unsupported,
224            Unresolved::InsufficientGrade { .. } => ReasonKind::InsufficientGrade,
225        }
226    }
227
228    /// `missing_evidence` with sorted, deduplicated fields.
229    pub fn missing<I: IntoIterator<Item = String>>(fields: I) -> Self {
230        Unresolved::MissingEvidence {
231            fields: sorted(fields),
232        }
233    }
234
235    /// `stale_evidence` with sorted, deduplicated fields.
236    pub fn stale<I: IntoIterator<Item = String>>(fields: I) -> Self {
237        Unresolved::StaleEvidence {
238            fields: sorted(fields),
239        }
240    }
241
242    /// `invalid_input` with sorted, deduplicated fields.
243    pub fn invalid<I: IntoIterator<Item = String>>(fields: I, detail: impl Into<String>) -> Self {
244        Unresolved::InvalidInput {
245            fields: sorted(fields),
246            detail: detail.into(),
247        }
248    }
249
250    /// `insufficient_grade` with sorted, deduplicated fields.
251    pub fn insufficient<I: IntoIterator<Item = String>>(fields: I) -> Self {
252        Unresolved::InsufficientGrade {
253            fields: sorted(fields),
254        }
255    }
256
257    /// `screen_unavailable` with sorted, deduplicated field references.
258    pub fn screen_unavailable<I: IntoIterator<Item = String>>(fields: I) -> Self {
259        Unresolved::ScreenUnavailable {
260            fields: sorted(fields),
261        }
262    }
263
264    /// `conflict` with sorted, deduplicated facts.
265    pub fn conflict<I: IntoIterator<Item = String>>(facts: I) -> Self {
266        Unresolved::Conflict {
267            facts: sorted(facts),
268        }
269    }
270}
271
272fn sorted<I: IntoIterator<Item = String>>(items: I) -> Vec<String> {
273    let mut v: Vec<String> = items.into_iter().collect();
274    v.sort();
275    v.dedup();
276    v
277}
278
279impl fmt::Display for Unresolved {
280    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
281        match self {
282            Unresolved::ScreenUnavailable { fields } => {
283                write!(f, "screen_unavailable{{{}}}", fields.join(","))
284            }
285            Unresolved::MissingEvidence { fields } => {
286                write!(f, "missing_evidence{{{}}}", fields.join(","))
287            }
288            Unresolved::StaleEvidence { fields } => {
289                write!(f, "stale_evidence{{{}}}", fields.join(","))
290            }
291            Unresolved::InvalidInput { fields, detail } => {
292                write!(f, "invalid_input{{{}}}: {detail}", fields.join(","))
293            }
294            Unresolved::Abstained { rule } => write!(f, "abstained{{{rule}}}"),
295            Unresolved::BackendUnavailable { detail } => write!(f, "backend_unavailable: {detail}"),
296            Unresolved::InvalidBackendOutput { detail } => {
297                write!(f, "invalid_backend_output: {detail}")
298            }
299            Unresolved::BudgetExhausted { resource } => write!(f, "budget_exhausted{{{resource}}}"),
300            Unresolved::DeadlineExceeded => write!(f, "deadline_exceeded"),
301            Unresolved::Conflict { facts } => write!(f, "conflict{{{}}}", facts.join(",")),
302            Unresolved::Unsupported { capability } => write!(f, "unsupported{{{capability}}}"),
303            Unresolved::InsufficientGrade { fields } => {
304                write!(f, "insufficient_grade{{{}}}", fields.join(","))
305            }
306        }
307    }
308}
309
310/// Derivation classes (spec 001, 3.3; spec 002, 3.12).
311#[derive(
312    Debug,
313    Clone,
314    Copy,
315    PartialEq,
316    Eq,
317    PartialOrd,
318    Ord,
319    Hash,
320    Serialize,
321    Deserialize,
322    schemars::JsonSchema,
323)]
324#[serde(rename_all = "kebab-case")]
325pub enum Derivation {
326    ExactDerived,
327    ModelDerived,
328    MixedDerived,
329}
330
331/// What a value was computed from: input fields and steps, sorted.
332#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
333#[serde(deny_unknown_fields)]
334pub struct Lineage {
335    pub inputs: Vec<String>,
336    pub steps: Vec<String>,
337}
338
339#[derive(
340    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
341)]
342#[serde(deny_unknown_fields)]
343pub struct Notice {
344    pub kind: NoticeKind,
345    /// The step or policy item it concerns.
346    pub subject: String,
347    pub reason: String,
348    /// Judgment 2: who can act on it (spec 033, 3.5.4); omitted in version 1.
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    pub action: Option<Action>,
351    /// Entry, field, consequence and item ids only (spec 035, 3.6.4).
352    #[serde(
353        default,
354        skip_serializing_if = "Option::is_none",
355        deserialize_with = "crate::screening::present"
356    )]
357    #[schemars(with = "crate::screening::ScreeningNotice")]
358    pub screening: Option<crate::screening::ScreeningNotice>,
359}
360
361#[derive(
362    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
363)]
364#[serde(rename_all = "snake_case")]
365pub enum NoticeKind {
366    UncalibratedThreshold,
367    Truncation,
368    Fallback,
369    /// Spec 033, 3.3: a check's field-level consequences.
370    FieldDropped,
371    ConstraintsRelaxed,
372    GradeDowngraded,
373    CheckNotice,
374    Screening,
375}
376
377/// A value placed in a proposal's parameters.
378#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
379#[serde(rename_all = "snake_case", deny_unknown_fields)]
380pub enum OutValue {
381    Bool(bool),
382    Integer(i64),
383    Decimal(Decimal),
384    Text(String),
385    Enum(String),
386    Timestamp(Timestamp),
387    Duration(DurationMs),
388    /// The `none` of a `maybe` value.
389    None,
390    List(Vec<OutValue>),
391    Record(BTreeMap<String, OutValue>),
392    Filtered(Filtered),
393    Shortlist(Shortlist),
394    Ranking(Ranking),
395}
396
397/// The result of `filter_with_reasons@1`.
398#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
399#[serde(deny_unknown_fields)]
400pub struct Filtered {
401    pub eligible: Vec<String>,
402    pub excluded: Vec<Excluded>,
403    /// How many items each rule excluded.
404    pub counts: BTreeMap<String, u64>,
405}
406
407#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
408#[serde(deny_unknown_fields)]
409pub struct Excluded {
410    pub id: String,
411    pub reasons: Vec<String>,
412}
413
414/// The result of `top_k@1`.
415#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
416#[serde(deny_unknown_fields)]
417pub struct Shortlist {
418    pub ids: Vec<String>,
419    /// Eligible ids not kept because of `k`.
420    pub truncated: u64,
421    pub excluded: Vec<Excluded>,
422}
423
424/// A `RankPosition` in wire form: an order, not a probability of being best.
425#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
426#[serde(deny_unknown_fields)]
427pub struct Ranking {
428    pub entries: Vec<RankEntry>,
429    /// Candidates left out because a component was unresolved.
430    pub excluded: Vec<Excluded>,
431}
432
433#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
434#[serde(deny_unknown_fields)]
435pub struct RankEntry {
436    pub candidate: String,
437    /// 1-based.
438    pub position: u64,
439    pub score: f64,
440    pub components: BTreeMap<String, f64>,
441}
442
443impl ReasonKind {
444    pub fn name(self) -> &'static str {
445        match self {
446            ReasonKind::ScreenUnavailable => "screen_unavailable",
447            ReasonKind::Skipped => "skipped",
448            ReasonKind::MissingEvidence => "missing_evidence",
449            ReasonKind::StaleEvidence => "stale_evidence",
450            ReasonKind::InvalidInput => "invalid_input",
451            ReasonKind::Abstained => "abstained",
452            ReasonKind::BackendUnavailable => "backend_unavailable",
453            ReasonKind::InvalidBackendOutput => "invalid_backend_output",
454            ReasonKind::BudgetExhausted => "budget_exhausted",
455            ReasonKind::DeadlineExceeded => "deadline_exceeded",
456            ReasonKind::Conflict => "conflict",
457            ReasonKind::Unsupported => "unsupported",
458            ReasonKind::InsufficientGrade => "insufficient_grade",
459        }
460    }
461}