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