Skip to main content

printobserver_types/
reported.rs

1//! Reported numbers, and the rule a plausibility range flags them under.
2//!
3//! A numeric field a domain declares a range for is typed [`Reported<f64>`],
4//! which serializes as an object carrying a `value` and a boolean
5//! `out_of_range` that is true exactly when the reported `value` is non-finite
6//! or lies outside the range declared for that field. A value outside the
7//! range is carried through as reported with that flag set, never coerced,
8//! never clamped and never dropped. The ranges themselves are the declaring
9//! domain's — the printer port's, for what a printer reports — and this module
10//! holds the representation and [`deserialize_ranged`], the one reading of a
11//! field against its range, rather than any range.
12//!
13//! The non-finite half of that definition is deliberate, because `f64` admits
14//! inhabitants an ordinary range comparison answers wrongly. `NaN` compares
15//! neither below a minimum nor above a maximum, so `value < min || value > max`
16//! would report the most implausible reading this type can hold as a plausible
17//! one; the two infinities lie outside every finite range. All three are what a
18//! disconnected thermistor or a division by a zero-valued reading produces,
19//! which is exactly the class the flag exists to carry, so all three are
20//! `out_of_range`.
21//!
22//! On the wire the type is closed instead: the JSON number grammar denotes no
23//! non-finite value, so a `Reported<f64>` never arrives by parse carrying one,
24//! and this crate refuses a non-finite `value` on emission rather than taking
25//! the serializer's own default of emitting `null` — which would parse back as
26//! a field the source did not report and turn an implausible reading into no
27//! reading at all.
28//!
29//! **A plausibility range is a validity range on what a source can plausibly
30//! report, and it is not the operator's safety envelope.** A reading outside
31//! one says the source reported something implausible — a disconnected
32//! thermistor, a firmware that answers in another unit. What an actor is
33//! *allowed to ask for* is the supervision domain's `SafetyEnvelope`, declared
34//! in `printobserver-core` as server configuration and narrower by orders of
35//! magnitude; nothing reads a plausibility range as a bound on an action, and
36//! the two are never intersected, compared or substituted for one another.
37
38use core::fmt;
39use std::borrow::Cow;
40
41use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
42use serde::ser::SerializeStruct as _;
43use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _, ser::Error as _};
44
45/// An inclusive pair of 64-bit floats.
46#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
47#[serde(deny_unknown_fields)]
48pub struct Range {
49    /// The lowest value the range admits, inclusive.
50    pub min: f64,
51    /// The highest value the range admits, inclusive.
52    pub max: f64,
53}
54
55impl Range {
56    /// The range from `min` to `max`, both inclusive.
57    #[must_use]
58    pub const fn new(min: f64, max: f64) -> Self {
59        Self { min, max }
60    }
61
62    /// Whether `value` lies inside this range.
63    ///
64    /// A non-finite value lies inside no range: `NaN` compares neither below
65    /// the minimum nor above the maximum, and the two infinities lie outside
66    /// every finite range.
67    #[must_use]
68    pub fn contains(&self, value: f64) -> bool {
69        value.is_finite() && value >= self.min && value <= self.max
70    }
71}
72
73/// A value a source reported, carrying whether it is outside the range this
74/// crate declares for the field it was reported in.
75///
76/// The value is carried exactly as reported: it is never coerced, clamped or
77/// dropped, whatever the flag says.
78#[derive(Debug, Clone, Copy, PartialEq)]
79pub struct Reported<T> {
80    /// The value exactly as the source reported it.
81    value: T,
82    /// Whether the value is non-finite or outside the declared range.
83    out_of_range: bool,
84}
85
86impl Reported<f64> {
87    /// The value as reported, flagged against the range declared for its field.
88    #[must_use]
89    pub fn new(value: f64, range: Range) -> Self {
90        Self {
91            value,
92            out_of_range: !range.contains(value),
93        }
94    }
95
96    /// The value exactly as the source reported it.
97    #[must_use]
98    pub const fn value(&self) -> f64 {
99        self.value
100    }
101
102    /// Whether the value is non-finite or outside its field's declared range.
103    #[must_use]
104    pub const fn out_of_range(&self) -> bool {
105        self.out_of_range
106    }
107}
108
109/// The wire shape of a [`Reported<f64>`]: the pair, and nothing else.
110#[derive(Deserialize)]
111#[serde(deny_unknown_fields)]
112struct ReportedWire {
113    value: f64,
114    out_of_range: bool,
115}
116
117impl Serialize for Reported<f64> {
118    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
119        if !self.value.is_finite() {
120            return Err(S::Error::custom(format!(
121                "a non-finite reported value ({}) has no JSON number spelling, and emitting it \
122                 as `null` would parse back as a field the source did not report",
123                self.value
124            )));
125        }
126        let mut state = serializer.serialize_struct("Reported", 2)?;
127        state.serialize_field("value", &self.value)?;
128        state.serialize_field("out_of_range", &self.out_of_range)?;
129        state.end()
130    }
131}
132
133impl<'de> Deserialize<'de> for Reported<f64> {
134    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
135        let wire = ReportedWire::deserialize(deserializer)?;
136        Ok(Self {
137            value: wire.value,
138            out_of_range: wire.out_of_range,
139        })
140    }
141}
142
143impl JsonSchema for Reported<f64> {
144    fn schema_name() -> Cow<'static, str> {
145        Cow::Borrowed("Reported")
146    }
147
148    fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
149        json_schema!({
150            "type": "object",
151            "title": "Reported",
152            "description": "A value as a source reported it, flagged when it is outside the \
153                            plausibility range this crate declares for its field.",
154            "properties": {
155                "value": { "type": "number" },
156                "out_of_range": { "type": "boolean" }
157            },
158            "required": ["value", "out_of_range"],
159            "additionalProperties": false
160        })
161    }
162}
163
164impl fmt::Display for Reported<f64> {
165    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
166        if self.out_of_range {
167            write!(formatter, "{} (out of range)", self.value)
168        } else {
169            write!(formatter, "{}", self.value)
170        }
171    }
172}
173
174/// Read an optional reported value, refusing a flag that disagrees with the
175/// value under the range declared for the field it was read in.
176///
177/// This is what a `deserialize_with` on a ranged field calls, naming the field
178/// and the range the declaring domain holds for it, so that a value never
179/// arrives carrying a flag some other range wrote.
180///
181/// # Errors
182///
183/// Answers the deserializer's own error for a value that is not a reported
184/// pair, and a refusal naming the field, the value and the range when the pair
185/// carries a flag the range disagrees with.
186pub fn deserialize_ranged<'de, D: Deserializer<'de>>(
187    deserializer: D,
188    field: &str,
189    range: Range,
190) -> Result<Option<Reported<f64>>, D::Error> {
191    let Some(reported) = Option::<Reported<f64>>::deserialize(deserializer)? else {
192        return Ok(None);
193    };
194    let expected = !range.contains(reported.value);
195    if reported.out_of_range != expected {
196        return Err(D::Error::custom(format!(
197            "`{field}` reported value {} with out_of_range {}, but the declared range \
198             {} to {} makes it {expected}",
199            reported.value, reported.out_of_range, range.min, range.max
200        )));
201    }
202    Ok(Some(reported))
203}