1use 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 pub notices: Vec<Notice>,
33 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#[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 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#[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#[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 InsufficientGrade {
221 fields: Vec<String>,
222 },
223 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 pub fn missing<I: IntoIterator<Item = String>>(fields: I) -> Self {
251 Unresolved::MissingEvidence {
252 fields: sorted(fields),
253 }
254 }
255
256 pub fn stale<I: IntoIterator<Item = String>>(fields: I) -> Self {
258 Unresolved::StaleEvidence {
259 fields: sorted(fields),
260 }
261 }
262
263 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 pub fn insufficient<I: IntoIterator<Item = String>>(fields: I) -> Self {
273 Unresolved::InsufficientGrade {
274 fields: sorted(fields),
275 }
276 }
277
278 pub fn screen_unavailable<I: IntoIterator<Item = String>>(fields: I) -> Self {
280 Unresolved::ScreenUnavailable {
281 fields: sorted(fields),
282 }
283 }
284
285 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#[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#[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 pub subject: String,
374 pub reason: String,
375 #[serde(default, skip_serializing_if = "Option::is_none")]
377 pub action: Option<Action>,
378 #[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 FieldDropped,
398 ConstraintsRelaxed,
399 GradeDowngraded,
400 CheckNotice,
401 Screening,
402}
403
404#[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 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#[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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
449#[serde(deny_unknown_fields)]
450pub struct Shortlist {
451 pub ids: Vec<String>,
452 pub truncated: u64,
454 pub excluded: Vec<Excluded>,
455}
456
457#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
459#[serde(deny_unknown_fields)]
460pub struct Ranking {
461 pub entries: Vec<RankEntry>,
462 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 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}