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/// Kind of a [`Value`], used in column descriptors (spec §3.3).
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "lowercase")]
36pub enum ValueKind {
37    Number,
38    Integer,
39    Boolean,
40    Text,
41    Timestamp,
42}
43
44/// One typed value inside a fragment (spec §3.3). Unit strings are display
45/// text — a structured unit system in this layer is an explicit non-goal
46/// (spec §1).
47#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
48#[serde(tag = "type", rename_all = "camelCase")]
49pub enum Value {
50    Number {
51        value: f64,
52        #[serde(skip_serializing_if = "Option::is_none")]
53        unit: Option<String>,
54    },
55    Integer {
56        value: i64,
57    },
58    Boolean {
59        value: bool,
60    },
61    Text {
62        value: String,
63    },
64    /// RFC 3339 timestamp text.
65    Timestamp {
66        value: String,
67    },
68    /// Explicitly missing (rendered as a gap, not zero).
69    Absent,
70}
71
72impl Value {
73    /// The [`ValueKind`] this value belongs to; `None` for [`Value::Absent`],
74    /// which is valid under any column kind.
75    pub fn kind(&self) -> Option<ValueKind> {
76        match self {
77            Value::Number { .. } => Some(ValueKind::Number),
78            Value::Integer { .. } => Some(ValueKind::Integer),
79            Value::Boolean { .. } => Some(ValueKind::Boolean),
80            Value::Text { .. } => Some(ValueKind::Text),
81            Value::Timestamp { .. } => Some(ValueKind::Timestamp),
82            Value::Absent => None,
83        }
84    }
85}
86
87/// One (label, value) pair in a key-value list (spec §3.3).
88#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
89#[serde(rename_all = "camelCase")]
90pub struct KeyValue {
91    pub label: String,
92    pub value: Value,
93}
94
95/// Column descriptor of a table (spec §3.3).
96#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
97#[serde(rename_all = "camelCase")]
98pub struct Column {
99    pub name: String,
100    /// Unit display text applying to every value in this column.
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub unit: Option<String>,
103    pub kind: ValueKind,
104}
105
106/// Column descriptors plus row-major values (spec §3.3).
107#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
108#[serde(rename_all = "camelCase")]
109pub struct Table {
110    pub columns: Vec<Column>,
111    pub rows: Vec<Vec<Value>>,
112}
113
114/// One named series of (x, y) points in x order (spec §3.3).
115#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
116#[serde(rename_all = "camelCase")]
117pub struct LineSeries {
118    pub name: String,
119    pub points: Vec<[f64; 2]>,
120}
121
122/// Chart data (spec §3.3).
123#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
124#[serde(tag = "type", rename_all = "camelCase")]
125pub enum ChartData {
126    /// Parallel category labels and values (distributions, rankings).
127    /// Single-series in this revision.
128    Bar {
129        categories: Vec<String>,
130        values: Vec<f64>,
131    },
132    /// One or more named series over a continuous x axis (time series).
133    Line { series: Vec<LineSeries> },
134}
135
136/// A declarative chart (spec §3.3): data plus axis labels only — engines
137/// describe *what* is charted, never colors, geometry, or layout. Every
138/// chart is table-derivable so it never gates information behind a
139/// graphics-capable format.
140#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
141#[serde(rename_all = "camelCase")]
142pub struct Chart {
143    pub x_label: String,
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub x_unit: Option<String>,
146    pub y_label: String,
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub y_unit: Option<String>,
149    pub data: ChartData,
150}
151
152/// One item of a fragment (spec §3.3).
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154#[serde(tag = "type", rename_all = "camelCase")]
155pub enum FragmentItem {
156    KeyValues {
157        entries: Vec<KeyValue>,
158    },
159    Table {
160        table: Table,
161    },
162    /// Plain-text paragraph for caveats and methodological remarks.
163    Note {
164        text: String,
165    },
166    /// A declarative chart; renderers without graphics support present
167    /// its mechanical table derivation instead.
168    Chart {
169        chart: Chart,
170    },
171}
172
173/// The materialized content of one block for one completed simulation
174/// (spec §3.1): a titled sequence of items.
175#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
176#[serde(rename_all = "camelCase")]
177pub struct Fragment {
178    pub title: String,
179    pub items: Vec<FragmentItem>,
180}
181
182/// Failure producing a block (spec §3.4). The report layer decides how an
183/// unavailable or failed block renders (placeholder, omission) — the
184/// engine never does, and the contract carries no engine vocabulary for
185/// *why* beyond the engine-authored reason text.
186#[derive(Debug, Clone, PartialEq)]
187pub enum BlockError {
188    /// The id is not in this engine's catalog.
189    UnknownBlock { id: String },
190    /// The block does not apply to this run — an expected condition, not a
191    /// fault. `reason` is engine-authored human-readable text.
192    Unavailable { reason: String },
193    /// Reading or deriving from the simulation artifacts failed.
194    Failed { message: String },
195}
196
197impl std::fmt::Display for BlockError {
198    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199        match self {
200            BlockError::UnknownBlock { id } => write!(f, "unknown report block: {id:?}"),
201            BlockError::Unavailable { reason } => {
202                write!(f, "report block unavailable for this run: {reason}")
203            }
204            BlockError::Failed { message } => write!(f, "report block failed: {message}"),
205        }
206    }
207}
208
209impl std::error::Error for BlockError {}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn value_serde_wire_shape_is_stable() {
217        // The JSON shape is a compatibility surface (templates, IPC):
218        // internally tagged with camelCase type names.
219        let v = Value::Number {
220            value: 1.5,
221            unit: Some("m".into()),
222        };
223        assert_eq!(
224            serde_json::to_string(&v).unwrap(),
225            r#"{"type":"number","value":1.5,"unit":"m"}"#
226        );
227        assert_eq!(
228            serde_json::to_string(&Value::Absent).unwrap(),
229            r#"{"type":"absent"}"#
230        );
231    }
232
233    #[test]
234    fn fragment_round_trips_through_json() {
235        let fragment = Fragment {
236            title: "Run Summary".into(),
237            items: vec![
238                FragmentItem::KeyValues {
239                    entries: vec![KeyValue {
240                        label: "Junctions".into(),
241                        value: Value::Integer { value: 42 },
242                    }],
243                },
244                FragmentItem::Table {
245                    table: Table {
246                        columns: vec![Column {
247                            name: "Quantity".into(),
248                            unit: None,
249                            kind: ValueKind::Text,
250                        }],
251                        rows: vec![vec![Value::Text {
252                            value: "Pressure".into(),
253                        }]],
254                    },
255                },
256                FragmentItem::Note {
257                    text: "Sampled.".into(),
258                },
259            ],
260        };
261        let json = serde_json::to_string(&fragment).unwrap();
262        let back: Fragment = serde_json::from_str(&json).unwrap();
263        assert_eq!(back, fragment);
264    }
265
266    #[test]
267    fn chart_serde_wire_shape_is_stable() {
268        let chart = Chart {
269            x_label: "Minimum pressure".into(),
270            x_unit: Some("m".into()),
271            y_label: "Junctions".into(),
272            y_unit: None,
273            data: ChartData::Bar {
274                categories: vec!["0 – 14".into()],
275                values: vec![3.0],
276            },
277        };
278        assert_eq!(
279            serde_json::to_string(&FragmentItem::Chart {
280                chart: chart.clone()
281            })
282            .unwrap(),
283            r#"{"type":"chart","chart":{"xLabel":"Minimum pressure","xUnit":"m","yLabel":"Junctions","data":{"type":"bar","categories":["0 – 14"],"values":[3.0]}}}"#
284        );
285        let json = serde_json::to_string(&chart).unwrap();
286        assert_eq!(serde_json::from_str::<Chart>(&json).unwrap(), chart);
287    }
288
289    #[test]
290    fn value_kind_mapping() {
291        assert_eq!(Value::Integer { value: 1 }.kind(), Some(ValueKind::Integer));
292        assert_eq!(Value::Absent.kind(), None);
293    }
294
295    #[test]
296    fn block_error_messages_are_descriptive() {
297        let e = BlockError::Unavailable {
298            reason: "the run has no water-quality results".into(),
299        };
300        assert!(e.to_string().contains("no water-quality results"));
301        let e = BlockError::UnknownBlock {
302            id: "wds.nope".into(),
303        };
304        assert!(e.to_string().contains("wds.nope"));
305    }
306}