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