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}