zenkey_fleet/report/judgement.rs
1//! The judgement core (RFC 13, v1.24) — one shape under every verdict.
2//!
3//! Every tool verdict in this workspace is a surface naming of one four-pole
4//! shape:
5//!
6//! > `Established(yes) | Established(no) | Unestablished(NotAsked) |
7//! > Unestablished(Unobservable{reason})`
8//!
9//! RFC 13 (v1.24) is the normative chapter for this shape; before v1.24 the
10//! material lived in RFC 09 §5.1 (the O1–O7 observation rules), and the O#
11//! citations across this crate still point there for the individual rules.
12//! The poles:
13//!
14//! * [`Judgement::Established`] — Established(yes): the question was put and
15//! the claim holds, conclusively.
16//! * [`Judgement::NotEstablished`] — Established(no): the question was put
17//! and the claim conclusively does not hold, with the reason.
18//! * [`Judgement::NotAsked`] — Unestablished: the question was never put.
19//! "Not asked" is not "answered no" (O4).
20//! * [`Judgement::Unobservable`] — Unestablished: the question was put and
21//! the observation could not carry the claim (a drop under a completeness
22//! claim, a window shorter than the claim's span, an ask that failed),
23//! with the reason. Neither "fine" nor "fire" (O6).
24//!
25//! Domain vocabularies — [`crate::report::CondState`],
26//! [`crate::report::ExpectVerdict`], [`crate::report::CutoverVerdict`],
27//! [`crate::report::WhyVerdict`], the `why` ladder's per-rung answer — remain
28//! surface namings with documented mappings onto this core; each mapping
29//! lives beside its vocabulary. The mapping convention every verdict-level
30//! `to_judgement()` follows: **the judged claim is the finding** — a verdict
31//! that found something maps to `Established`, a clean one to
32//! `NotEstablished`. That convention is what makes the exit projection below
33//! a pure function; a vocabulary whose own polarity is inverted
34//! ([`crate::report::WhyVerdict`]: `Explained` is the *finding* and its CLI
35//! historically exits 0) does the flip at its mapping, never downstream.
36//!
37//! ## Serialized form
38//!
39//! `Judgement` serializes with an `answer` tag —
40//! `{"answer": "established"}`, `{"answer": "not_established", "reason": …}`,
41//! `{"answer": "not_asked"}`, `{"answer": "unobservable", "reason": …}` —
42//! byte-identical, for the three poles it had, to the `why` ladder's shipped
43//! rung answer (#214), whose wire shape this type now carries directly.
44
45use serde::Serialize;
46
47/// One question's judgement — the four-pole core every tool verdict maps
48/// onto (RFC 13, v1.24; RFC 09 §5.1 pre-v1.24). See the module doc.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
50#[serde(tag = "answer", rename_all = "snake_case")]
51pub enum Judgement {
52 /// Established(yes): the question was put and the claim holds — positive
53 /// evidence, conclusive.
54 Established,
55 /// Established(no): the question was put and the claim conclusively does
56 /// not hold — with the reason, which is where the honesty lives.
57 NotEstablished { reason: String },
58 /// Unestablished: the question was not put — the input was not fetched,
59 /// was not requested, or does not exist for this subject. Not asked is
60 /// not answered no (RFC 09 §5.1 O4).
61 NotAsked,
62 /// Unestablished: the question was put and the observation cannot carry
63 /// the claim — a drop under a completeness claim, a window shorter than
64 /// the claim's span, or an ask that failed (RFC 09 §5.1 O6).
65 Unobservable { reason: String },
66}
67
68impl Judgement {
69 /// Whether the question reached a conclusive answer: `Some(true)` =
70 /// Established(yes), `Some(false)` = Established(no), `None` = neither
71 /// pole of Unestablished says anything.
72 pub fn conclusive(&self) -> Option<bool> {
73 match self {
74 Judgement::Established => Some(true),
75 Judgement::NotEstablished { .. } => Some(false),
76 Judgement::NotAsked | Judgement::Unobservable { .. } => None,
77 }
78 }
79
80 /// The observation was made and could not carry the claim.
81 pub fn is_unobservable(&self) -> bool {
82 matches!(self, Judgement::Unobservable { .. })
83 }
84
85 /// The question was never put.
86 pub fn is_not_asked(&self) -> bool {
87 matches!(self, Judgement::NotAsked)
88 }
89}
90
91/// The RFC 13 (v1.24) exit projection: `0` = established-clean, `1` =
92/// established-finding, `2` = unestablished (not asked, or unobservable).
93///
94/// The projection reads the core convention (module doc): the judged claim
95/// is the **finding**, so `Established` is the finding exit and
96/// `NotEstablished` the clean one. A vocabulary with inverted surface
97/// polarity handles the flip in its own `to_judgement()` mapping
98/// ([`crate::report::WhyVerdict`] is the documented case), never here — this
99/// function has exactly one spelling per pole.
100pub fn judgement_exit_code(j: &Judgement) -> i32 {
101 match j.conclusive() {
102 Some(false) => 0,
103 Some(true) => 1,
104 None => 2,
105 }
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111
112 /// The wire vocabulary of the core, pinned: the three poles the `why`
113 /// ladder shipped are byte-identical to #214's `RungAnswer`, and the
114 /// fourth pole gets its own tag.
115 #[test]
116 fn the_four_poles_serialize_with_the_shipped_answer_tags() {
117 assert_eq!(
118 serde_json::to_value(Judgement::Established).unwrap(),
119 serde_json::json!({"answer": "established"})
120 );
121 assert_eq!(
122 serde_json::to_value(Judgement::NotEstablished { reason: "r".into() }).unwrap(),
123 serde_json::json!({"answer": "not_established", "reason": "r"})
124 );
125 assert_eq!(
126 serde_json::to_value(Judgement::NotAsked).unwrap(),
127 serde_json::json!({"answer": "not_asked"})
128 );
129 assert_eq!(
130 serde_json::to_value(Judgement::Unobservable { reason: "r".into() }).unwrap(),
131 serde_json::json!({"answer": "unobservable", "reason": "r"})
132 );
133 }
134
135 /// The 0/1/2 projection (RFC 13 v1.24): established-clean /
136 /// established-finding / unestablished — and both unestablished poles
137 /// share the exit, because neither is a verdict.
138 #[test]
139 fn the_exit_projection_is_zero_one_two() {
140 assert_eq!(
141 judgement_exit_code(&Judgement::NotEstablished {
142 reason: "clean".into()
143 }),
144 0
145 );
146 assert_eq!(judgement_exit_code(&Judgement::Established), 1);
147 assert_eq!(judgement_exit_code(&Judgement::NotAsked), 2);
148 assert_eq!(
149 judgement_exit_code(&Judgement::Unobservable {
150 reason: "drops".into()
151 }),
152 2
153 );
154 }
155}