Skip to main content

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}