Skip to main content

hydra_common/
report.rs

1//! Reportable-output contract: block descriptors and the neutral fragment
2//! model (spec §3).
3//!
4//! Everything here is *data*, deliberately free of presentation (no colors,
5//! fonts, page geometry, or format hints) and free of engine knowledge. An
6//! engine's catalog describes what it can produce; fragments carry the
7//! materialized content of one block for one completed simulation; the
8//! report layer renders fragments without knowing which engine made them.
9
10use serde::{Deserialize, Serialize};
11
12/// Descriptor of one block in an engine's catalog (spec §3.2).
13///
14/// `id` is namespaced by engine key (`wds.pressure-summary`) and **never
15/// changes once released** — report templates reference it; removing or
16/// repurposing an id is a compatibility break on par with a file-format
17/// break.
18///
19/// Deliberately carries no result-class or prerequisite vocabulary: what a
20/// block needs from a simulation is the producing engine's internal
21/// concern, surfaced only through [`BlockError::Unavailable`].
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
23#[serde(rename_all = "camelCase")]
24pub struct BlockDescriptor {
25    /// Stable namespaced identifier: `<engine>.<name>`.
26    pub id: &'static str,
27    /// Default human-facing heading.
28    pub title: &'static str,
29    /// What this block contains, for the template-builder UI.
30    pub summary: &'static str,
31    /// Engine-authored grouping heading (spec §3.2). Blocks sharing the
32    /// exact string belong together; group order is catalog order. Display
33    /// text only — carries no semantics beyond equality.
34    pub category: &'static str,
35}
36
37/// One selectable item of a [`OptionKind::Choice`] or
38/// [`OptionKind::MultiChoice`] (spec §3.2.1).
39///
40/// `value` is what goes into the options object and is opaque here; `label`
41/// is display text. Both are engine-authored.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase")]
44pub struct ChoiceItem {
45    /// Opaque value stored in the options object.
46    pub value: String,
47    /// Human-facing label for this item.
48    pub label: String,
49}
50
51/// Shape and bounds of one describable block option (spec §3.2.1).
52///
53/// Bounds and defaults are advisory — they tell a UI what to offer, and are
54/// never the validation authority. Production validates independently, so an
55/// engine may accept a value no descriptor advertised.
56#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
57#[serde(tag = "type", rename_all = "camelCase")]
58pub enum OptionKind {
59    /// Real number, with optional inclusive bounds.
60    Number {
61        default: Option<f64>,
62        min: Option<f64>,
63        max: Option<f64>,
64    },
65    /// Whole number, with optional inclusive bounds.
66    Integer {
67        default: Option<i64>,
68        min: Option<i64>,
69        max: Option<i64>,
70    },
71    Boolean {
72        default: Option<bool>,
73    },
74    Text {
75        default: Option<String>,
76    },
77    /// Ordered list of reals — threshold edges and the like.
78    NumberList {
79        default: Option<Vec<f64>>,
80        /// Fewest entries the block will accept, when it requires any.
81        min_len: Option<usize>,
82        /// Whether entries must strictly ascend.
83        ascending: bool,
84    },
85    /// Exactly one of `items`.
86    Choice {
87        default: Option<String>,
88        items: Vec<ChoiceItem>,
89    },
90    /// Any subset of `items`.
91    MultiChoice {
92        default: Option<Vec<String>>,
93        items: Vec<ChoiceItem>,
94    },
95}
96
97/// Description of one option a block accepts (spec §3.2.1).
98///
99/// Resolved by an engine **against a model**, because permissible values and
100/// correct defaults are often properties of the model in hand — which
101/// constituents exist, and what unit system the file declares. A consumer
102/// displays what it is given and computes nothing.
103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
104#[serde(rename_all = "camelCase")]
105pub struct OptionDescriptor {
106    /// Field name in the block's options object.
107    pub key: String,
108    /// Human-facing control label.
109    pub label: String,
110    /// One or two sentences explaining what the option does.
111    pub help: String,
112    /// Value shape and bounds.
113    pub kind: OptionKind,
114    /// Display unit text, or `None`. Display only — never a unit system.
115    pub unit: Option<String>,
116}
117
118/// Kind of a [`Value`], used in column descriptors (spec §3.3).
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(rename_all = "lowercase")]
121pub enum ValueKind {
122    Number,
123    Integer,
124    Boolean,
125    Text,
126    Timestamp,
127}
128
129/// One typed value inside a fragment (spec §3.3). Unit strings are display
130/// text — a structured unit system in this layer is an explicit non-goal
131/// (spec §1).
132#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
133#[serde(tag = "type", rename_all = "camelCase")]
134pub enum Value {
135    Number {
136        value: f64,
137        #[serde(skip_serializing_if = "Option::is_none")]
138        unit: Option<String>,
139        /// Quantity key from the producing engine's catalog (spec §3.3,
140        /// v1.7). When present, `value` is in that quantity's SI display
141        /// unit and `unit` is its SI label; a consumer holding the catalog
142        /// may re-express both in a chosen display family. Absent, the
143        /// value renders as written.
144        #[serde(default, skip_serializing_if = "Option::is_none")]
145        quantity: Option<String>,
146    },
147    Integer {
148        value: i64,
149    },
150    Boolean {
151        value: bool,
152    },
153    Text {
154        value: String,
155    },
156    /// RFC 3339 timestamp text.
157    Timestamp {
158        value: String,
159    },
160    /// Explicitly missing (rendered as a gap, not zero).
161    Absent,
162}
163
164impl Value {
165    /// The [`ValueKind`] this value belongs to; `None` for [`Value::Absent`],
166    /// which is valid under any column kind.
167    pub fn kind(&self) -> Option<ValueKind> {
168        match self {
169            Value::Number { .. } => Some(ValueKind::Number),
170            Value::Integer { .. } => Some(ValueKind::Integer),
171            Value::Boolean { .. } => Some(ValueKind::Boolean),
172            Value::Text { .. } => Some(ValueKind::Text),
173            Value::Timestamp { .. } => Some(ValueKind::Timestamp),
174            Value::Absent => None,
175        }
176    }
177}
178
179/// One (label, value) pair in a key-value list (spec §3.3).
180#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
181#[serde(rename_all = "camelCase")]
182pub struct KeyValue {
183    pub label: String,
184    pub value: Value,
185}
186
187/// Column descriptor of a table (spec §3.3).
188#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
189#[serde(rename_all = "camelCase")]
190pub struct Column {
191    pub name: String,
192    /// Unit display text applying to every value in this column.
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub unit: Option<String>,
195    pub kind: ValueKind,
196    /// Quantity key applying to every number in this column (spec §3.3,
197    /// v1.7): values are in the quantity's SI display unit and `unit` is
198    /// its SI label, re-expressible by a consumer holding the catalog.
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub quantity: Option<String>,
201}
202
203/// Column descriptors plus row-major values (spec §3.3).
204#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
205#[serde(rename_all = "camelCase")]
206pub struct Table {
207    pub columns: Vec<Column>,
208    pub rows: Vec<Vec<Value>>,
209}
210
211/// One named series of (x, y) points in x order (spec §3.3).
212#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
213#[serde(rename_all = "camelCase")]
214pub struct LineSeries {
215    pub name: String,
216    pub points: Vec<[f64; 2]>,
217}
218
219/// Chart data (spec §3.3).
220#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
221#[serde(tag = "type", rename_all = "camelCase")]
222pub enum ChartData {
223    /// Parallel category labels and values (distributions, rankings).
224    /// Single-series in this revision.
225    Bar {
226        categories: Vec<String>,
227        values: Vec<f64>,
228    },
229    /// One or more named series over a continuous x axis (time series).
230    Line { series: Vec<LineSeries> },
231}
232
233/// A declarative chart (spec §3.3): data plus axis labels only — engines
234/// describe *what* is charted, never colors, geometry, or layout. Every
235/// chart is table-derivable so it never gates information behind a
236/// graphics-capable format.
237#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
238#[serde(rename_all = "camelCase")]
239pub struct Chart {
240    pub x_label: String,
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub x_unit: Option<String>,
243    /// Quantity key for the x coordinates (spec §3.3, v1.7): tagged
244    /// coordinates are in the quantity's SI display unit.
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub x_quantity: Option<String>,
247    pub y_label: String,
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub y_unit: Option<String>,
250    /// Quantity key for the y values (spec §3.3, v1.7), as `x_quantity`.
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub y_quantity: Option<String>,
253    pub data: ChartData,
254}
255
256/// One item of a fragment (spec §3.3).
257#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
258#[serde(tag = "type", rename_all = "camelCase")]
259pub enum FragmentItem {
260    KeyValues {
261        entries: Vec<KeyValue>,
262    },
263    Table {
264        table: Table,
265    },
266    /// Plain-text paragraph for caveats and methodological remarks.
267    Note {
268        text: String,
269    },
270    /// A declarative chart; renderers without graphics support present
271    /// its mechanical table derivation instead.
272    Chart {
273        chart: Chart,
274    },
275}
276
277/// The materialized content of one block for one completed simulation
278/// (spec §3.1): a titled sequence of items.
279#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
280#[serde(rename_all = "camelCase")]
281pub struct Fragment {
282    pub title: String,
283    pub items: Vec<FragmentItem>,
284}
285
286/// Failure producing a block (spec §3.4). The report layer decides how an
287/// unavailable or failed block renders (placeholder, omission) — the
288/// engine never does, and the contract carries no engine vocabulary for
289/// *why* beyond the engine-authored reason text.
290#[derive(Debug, Clone, PartialEq)]
291pub enum BlockError {
292    /// The id is not in this engine's catalog.
293    UnknownBlock { id: String },
294    /// The block does not apply to this run — an expected condition, not a
295    /// fault. `reason` is engine-authored human-readable text.
296    Unavailable { reason: String },
297    /// Reading or deriving from the simulation artifacts failed.
298    Failed { message: String },
299}
300
301impl std::fmt::Display for BlockError {
302    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
303        match self {
304            BlockError::UnknownBlock { id } => write!(f, "unknown report block: {id:?}"),
305            BlockError::Unavailable { reason } => {
306                write!(f, "report block unavailable for this run: {reason}")
307            }
308            BlockError::Failed { message } => write!(f, "report block failed: {message}"),
309        }
310    }
311}
312
313impl std::error::Error for BlockError {}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    #[test]
320    fn value_serde_wire_shape_is_stable() {
321        // The JSON shape is a compatibility surface (templates, IPC):
322        // internally tagged with camelCase type names.
323        let v = Value::Number {
324            value: 1.5,
325            unit: Some("m".into()),
326            quantity: None,
327        };
328        assert_eq!(
329            serde_json::to_string(&v).unwrap(),
330            r#"{"type":"number","value":1.5,"unit":"m"}"#
331        );
332        assert_eq!(
333            serde_json::to_string(&Value::Absent).unwrap(),
334            r#"{"type":"absent"}"#
335        );
336    }
337
338    /// The v1.7 tag is additive on the wire: absent means absent from the
339    /// JSON (the pre-v1.7 shape, byte for byte), and pre-v1.7 JSON still
340    /// deserializes. Both directions matter — saved templates and IPC
341    /// peers were written against the old shape.
342    #[test]
343    fn quantity_tags_are_additive_on_the_wire() {
344        let tagged = Value::Number {
345            value: 51.25,
346            unit: Some("m".into()),
347            quantity: Some("pressure".into()),
348        };
349        assert_eq!(
350            serde_json::to_string(&tagged).unwrap(),
351            r#"{"type":"number","value":51.25,"unit":"m","quantity":"pressure"}"#
352        );
353        let old: Value =
354            serde_json::from_str(r#"{"type":"number","value":1.5,"unit":"m"}"#).unwrap();
355        assert_eq!(
356            old,
357            Value::Number {
358                value: 1.5,
359                unit: Some("m".into()),
360                quantity: None,
361            }
362        );
363    }
364
365    #[test]
366    fn fragment_round_trips_through_json() {
367        let fragment = Fragment {
368            title: "Run Summary".into(),
369            items: vec![
370                FragmentItem::KeyValues {
371                    entries: vec![KeyValue {
372                        label: "Junctions".into(),
373                        value: Value::Integer { value: 42 },
374                    }],
375                },
376                FragmentItem::Table {
377                    table: Table {
378                        columns: vec![Column {
379                            name: "Quantity".into(),
380                            unit: None,
381                            kind: ValueKind::Text,
382                            quantity: None,
383                        }],
384                        rows: vec![vec![Value::Text {
385                            value: "Pressure".into(),
386                        }]],
387                    },
388                },
389                FragmentItem::Note {
390                    text: "Sampled.".into(),
391                },
392            ],
393        };
394        let json = serde_json::to_string(&fragment).unwrap();
395        let back: Fragment = serde_json::from_str(&json).unwrap();
396        assert_eq!(back, fragment);
397    }
398
399    #[test]
400    fn chart_serde_wire_shape_is_stable() {
401        let chart = Chart {
402            x_label: "Minimum pressure".into(),
403            x_unit: Some("m".into()),
404            x_quantity: None,
405            y_label: "Junctions".into(),
406            y_unit: None,
407            y_quantity: None,
408            data: ChartData::Bar {
409                categories: vec!["0 – 14".into()],
410                values: vec![3.0],
411            },
412        };
413        assert_eq!(
414            serde_json::to_string(&FragmentItem::Chart {
415                chart: chart.clone()
416            })
417            .unwrap(),
418            r#"{"type":"chart","chart":{"xLabel":"Minimum pressure","xUnit":"m","yLabel":"Junctions","data":{"type":"bar","categories":["0 – 14"],"values":[3.0]}}}"#
419        );
420        let json = serde_json::to_string(&chart).unwrap();
421        assert_eq!(serde_json::from_str::<Chart>(&json).unwrap(), chart);
422    }
423
424    #[test]
425    fn value_kind_mapping() {
426        assert_eq!(Value::Integer { value: 1 }.kind(), Some(ValueKind::Integer));
427        assert_eq!(Value::Absent.kind(), None);
428    }
429
430    #[test]
431    fn block_error_messages_are_descriptive() {
432        let e = BlockError::Unavailable {
433            reason: "the run has no water-quality results".into(),
434        };
435        assert!(e.to_string().contains("no water-quality results"));
436        let e = BlockError::UnknownBlock {
437            id: "wds.nope".into(),
438        };
439        assert!(e.to_string().contains("wds.nope"));
440    }
441}