spectroxide/
output.rs

1//! Structured output types and serialization for solver results.
2//!
3//! Provides [`SolverResult`], [`SweepResult`], and [`GreensResult`] as owned,
4//! self-contained representations of completed runs, with zero-dependency
5//! JSON/CSV/table serialization.
6//!
7//! All result types implement [`Serializable`] for uniform JSON/CSV/table output.
8
9use crate::solver::SolverSnapshot;
10
11/// Common serialization interface for all result types.
12///
13/// Enables generic output handling (see `main.rs::write_output`).
14/// Uses `&mut dyn Write` for dyn-compatibility.
15pub trait Serializable {
16    fn to_json(&self) -> String;
17    fn write_csv_dyn(&self, w: &mut dyn std::io::Write) -> std::io::Result<()>;
18    fn write_table_dyn(&self, w: &mut dyn std::io::Write) -> std::io::Result<()>;
19}
20
21/// Owned result from a completed solver run.
22///
23/// Contains the observed-redshift snapshot, the frequency grid, and
24/// diagnostic info. Does not borrow the solver, so it can outlive it.
25/// For runs that need multiple snapshots, use
26/// [`crate::solver::ThermalizationSolver::run_with_snapshots`] directly.
27#[derive(Debug, Clone)]
28#[must_use]
29pub struct SolverResult {
30    /// Snapshot at the requested observation redshift.
31    pub snapshot: SolverSnapshot,
32    /// The frequency grid `x[i]` used by the solver.
33    pub x_grid: Vec<f64>,
34    /// Total number of timesteps taken.
35    pub step_count: usize,
36    /// Number of steps where Newton iteration hit the max iteration limit.
37    pub diag_newton_exhausted: usize,
38    /// Diagnostic warnings collected during the run (Newton non-convergence,
39    /// ρ_e clamping, NaN emission rates, untested regimes, validation soft
40    /// warnings). Empty for a clean run.
41    pub warnings: Vec<String>,
42}
43
44impl SolverResult {
45    /// Serialize to a JSON string (zero dependencies).
46    ///
47    /// Output format matches the CLI convention used by the Python client:
48    /// `{"results":[{"pde_mu":..., "pde_y":..., "drho":..., ...}], "diag_newton_exhausted":N}`
49    pub fn to_json(&self) -> String {
50        let s = &self.snapshot;
51        let mut out = String::with_capacity(self.x_grid.len() * 30 + 256);
52        out.push_str("{\"results\":[{");
53        write_json_kv(&mut out, "pde_mu", s.mu);
54        out.push(',');
55        write_json_kv(&mut out, "pde_y", s.y);
56        out.push(',');
57        write_json_kv(&mut out, "drho", s.delta_rho_over_rho);
58        out.push(',');
59        write_json_kv(&mut out, "accumulated_delta_t", s.accumulated_delta_t);
60        out.push(',');
61        write_json_kv(&mut out, "z", s.z);
62        out.push(',');
63        write_json_kv(&mut out, "rho_e", s.rho_e);
64        out.push(',');
65        write_json_kv(&mut out, "step_count", self.step_count as f64);
66        out.push(',');
67        write_json_array(&mut out, "x", &self.x_grid);
68        out.push(',');
69        write_json_array(&mut out, "delta_n", &s.delta_n);
70        out.push_str("}],");
71        write_json_kv(
72            &mut out,
73            "diag_newton_exhausted",
74            self.diag_newton_exhausted as f64,
75        );
76        if !self.warnings.is_empty() {
77            out.push(',');
78            write_json_string_array(&mut out, "warnings", &self.warnings);
79        }
80        out.push('}');
81        out
82    }
83
84    /// Write CSV (frequency, delta_n) to a writer.
85    pub fn write_csv<W: std::io::Write + ?Sized>(&self, w: &mut W) -> std::io::Result<()> {
86        let s = &self.snapshot;
87        writeln!(
88            w,
89            "# mu={:.6e} y={:.6e} delta_rho_over_rho={:.6e} z={:.1} steps={} newton_exhausted={}",
90            s.mu, s.y, s.delta_rho_over_rho, s.z, self.step_count, self.diag_newton_exhausted
91        )?;
92        for warning in &self.warnings {
93            writeln!(w, "# WARNING: {warning}")?;
94        }
95        writeln!(w, "x,delta_n")?;
96        for (i, &x) in self.x_grid.iter().enumerate() {
97            writeln!(w, "{:.8e},{:.8e}", x, s.delta_n[i])?;
98        }
99        Ok(())
100    }
101
102    /// Write a human-readable summary table to a writer.
103    pub fn write_table<W: std::io::Write + ?Sized>(&self, w: &mut W) -> std::io::Result<()> {
104        let s = &self.snapshot;
105        writeln!(w, "Solver Result")?;
106        writeln!(w, "=============")?;
107        writeln!(w, "  z_final       = {:.1}", s.z)?;
108        writeln!(w, "  mu            = {:.6e}", s.mu)?;
109        writeln!(w, "  y             = {:.6e}", s.y)?;
110        writeln!(w, "  delta_rho/rho = {:.6e}", s.delta_rho_over_rho)?;
111        writeln!(w, "  rho_e         = {:.8}", s.rho_e)?;
112        writeln!(w, "  accum_delta_T = {:.6e}", s.accumulated_delta_t)?;
113        writeln!(w, "  steps         = {}", self.step_count)?;
114        writeln!(w, "  grid points   = {}", self.x_grid.len())?;
115        writeln!(w, "  newton_exhausted = {}", self.diag_newton_exhausted)?;
116        if !self.warnings.is_empty() {
117            writeln!(w, "  warnings      = {} (see below)", self.warnings.len())?;
118            writeln!(w, "Warnings:")?;
119            for warning in &self.warnings {
120                writeln!(w, "  - {warning}")?;
121            }
122        }
123        Ok(())
124    }
125}
126
127/// One row of a sweep: PDE result + Green's function comparison at one z_h.
128#[derive(Debug, Clone)]
129pub struct SweepRow {
130    pub z_h: f64,
131    pub snapshot: SolverSnapshot,
132    pub gf_mu: f64,
133    pub gf_y: f64,
134    pub gf_delta_n: Vec<f64>,
135    pub x_grid: Vec<f64>,
136    pub step_count: usize,
137}
138
139/// Result of a sweep over multiple injection redshifts.
140#[derive(Debug, Clone)]
141#[must_use]
142pub struct SweepResult {
143    pub delta_rho: f64,
144    pub rows: Vec<SweepRow>,
145    /// Aggregated diagnostic warnings across all sweep workers.
146    pub warnings: Vec<String>,
147}
148
149impl SweepResult {
150    /// Serialize to a JSON string.
151    pub fn to_json(&self) -> String {
152        let per_row = self.rows.first().map_or(4096, |r| {
153            (r.x_grid.len() + r.snapshot.delta_n.len()) * 24 + 256
154        });
155        let mut out = String::with_capacity(self.rows.len() * per_row + 128);
156        out.push('{');
157        write_json_kv(&mut out, "delta_rho_inj", self.delta_rho);
158        out.push_str(",\"results\":[");
159        for (i, row) in self.rows.iter().enumerate() {
160            if i > 0 {
161                out.push(',');
162            }
163            out.push('{');
164            write_json_kv(&mut out, "z_h", row.z_h);
165            out.push(',');
166            write_json_kv(&mut out, "pde_mu", row.snapshot.mu);
167            out.push(',');
168            write_json_kv(&mut out, "gf_mu", row.gf_mu);
169            out.push(',');
170            write_json_kv(&mut out, "pde_y", row.snapshot.y);
171            out.push(',');
172            write_json_kv(&mut out, "gf_y", row.gf_y);
173            out.push(',');
174            write_json_kv(&mut out, "drho", row.snapshot.delta_rho_over_rho);
175            out.push(',');
176            write_json_kv(
177                &mut out,
178                "accumulated_delta_t",
179                row.snapshot.accumulated_delta_t,
180            );
181            out.push(',');
182            write_json_array(&mut out, "x", &row.x_grid);
183            out.push(',');
184            write_json_array(&mut out, "delta_n", &row.snapshot.delta_n);
185            out.push(',');
186            write_json_array(&mut out, "delta_n_gf", &row.gf_delta_n);
187            out.push('}');
188        }
189        out.push(']');
190        if !self.warnings.is_empty() {
191            out.push(',');
192            write_json_string_array(&mut out, "warnings", &self.warnings);
193        }
194        out.push('}');
195        out
196    }
197
198    /// Write CSV summary to a writer.
199    pub fn write_csv<W: std::io::Write + ?Sized>(&self, w: &mut W) -> std::io::Result<()> {
200        for warning in &self.warnings {
201            writeln!(w, "# WARNING: {warning}")?;
202        }
203        writeln!(w, "z_h,pde_mu,gf_mu,pde_y,gf_y,drho,steps")?;
204        for row in &self.rows {
205            writeln!(
206                w,
207                "{:.6e},{:.6e},{:.6e},{:.6e},{:.6e},{:.6e},{}",
208                row.z_h,
209                row.snapshot.mu,
210                row.gf_mu,
211                row.snapshot.y,
212                row.gf_y,
213                row.snapshot.delta_rho_over_rho,
214                row.step_count,
215            )?;
216        }
217        Ok(())
218    }
219
220    /// Write a human-readable summary table to stderr-style output.
221    pub fn write_table<W: std::io::Write + ?Sized>(&self, w: &mut W) -> std::io::Result<()> {
222        writeln!(
223            w,
224            "{:<10} {:>10} {:>10} {:>10} {:>10} {:>10} {:>10}",
225            "z_inj", "PDE_mu", "GF_mu", "PDE_y", "GF_y", "PDE_drho", "steps"
226        )?;
227        writeln!(w, "{}", "-".repeat(75))?;
228        for row in &self.rows {
229            writeln!(
230                w,
231                "{:<10.0e} {:>10.3e} {:>10.3e} {:>10.3e} {:>10.3e} {:>10.3e} {:>10}",
232                row.z_h,
233                row.snapshot.mu,
234                row.gf_mu,
235                row.snapshot.y,
236                row.gf_y,
237                row.snapshot.delta_rho_over_rho,
238                row.step_count,
239            )?;
240        }
241        if !self.warnings.is_empty() {
242            writeln!(w, "Warnings ({}):", self.warnings.len())?;
243            for warning in &self.warnings {
244                writeln!(w, "  - {warning}")?;
245            }
246        }
247        Ok(())
248    }
249}
250
251/// One row of a photon sweep: PDE result at one z_h for a fixed x_inj.
252#[derive(Debug, Clone)]
253pub struct PhotonSweepRow {
254    pub z_h: f64,
255    pub snapshot: SolverSnapshot,
256    pub x_grid: Vec<f64>,
257    pub step_count: usize,
258}
259
260/// Result of a photon injection sweep over multiple injection redshifts at fixed x_inj.
261#[derive(Debug, Clone)]
262#[must_use]
263pub struct PhotonSweepResult {
264    pub x_inj: f64,
265    pub delta_n_over_n: f64,
266    pub rows: Vec<PhotonSweepRow>,
267    /// Aggregated diagnostic warnings across all sweep workers.
268    pub warnings: Vec<String>,
269}
270
271impl PhotonSweepResult {
272    /// Serialize to a JSON string.
273    pub fn to_json(&self) -> String {
274        let per_row = self.rows.first().map_or(4096, |r| {
275            (r.x_grid.len() + r.snapshot.delta_n.len()) * 24 + 256
276        });
277        let mut out = String::with_capacity(self.rows.len() * per_row + 128);
278        out.push('{');
279        write_json_kv(&mut out, "x_inj", self.x_inj);
280        out.push(',');
281        write_json_kv(&mut out, "delta_n_over_n", self.delta_n_over_n);
282        out.push_str(",\"results\":[");
283        for (i, row) in self.rows.iter().enumerate() {
284            if i > 0 {
285                out.push(',');
286            }
287            out.push('{');
288            write_json_kv(&mut out, "z_h", row.z_h);
289            out.push(',');
290            write_json_kv(&mut out, "pde_mu", row.snapshot.mu);
291            out.push(',');
292            write_json_kv(&mut out, "pde_y", row.snapshot.y);
293            out.push(',');
294            write_json_kv(&mut out, "drho", row.snapshot.delta_rho_over_rho);
295            out.push(',');
296            write_json_kv(
297                &mut out,
298                "accumulated_delta_t",
299                row.snapshot.accumulated_delta_t,
300            );
301            out.push(',');
302            write_json_array(&mut out, "x", &row.x_grid);
303            out.push(',');
304            write_json_array(&mut out, "delta_n", &row.snapshot.delta_n);
305            out.push('}');
306        }
307        out.push(']');
308        if !self.warnings.is_empty() {
309            out.push(',');
310            write_json_string_array(&mut out, "warnings", &self.warnings);
311        }
312        out.push('}');
313        out
314    }
315
316    /// Write CSV summary to a writer.
317    pub fn write_csv<W: std::io::Write + ?Sized>(&self, w: &mut W) -> std::io::Result<()> {
318        for warning in &self.warnings {
319            writeln!(w, "# WARNING: {warning}")?;
320        }
321        writeln!(w, "z_h,pde_mu,pde_y,drho,steps")?;
322        for row in &self.rows {
323            writeln!(
324                w,
325                "{:.6e},{:.6e},{:.6e},{:.6e},{}",
326                row.z_h,
327                row.snapshot.mu,
328                row.snapshot.y,
329                row.snapshot.delta_rho_over_rho,
330                row.step_count,
331            )?;
332        }
333        Ok(())
334    }
335
336    /// Write a human-readable summary table.
337    pub fn write_table<W: std::io::Write + ?Sized>(&self, w: &mut W) -> std::io::Result<()> {
338        writeln!(
339            w,
340            "Photon sweep: x_inj={:.4e}, delta_n_over_n={:.4e}",
341            self.x_inj, self.delta_n_over_n
342        )?;
343        writeln!(
344            w,
345            "{:<10} {:>10} {:>10} {:>10} {:>10}",
346            "z_inj", "PDE_mu", "PDE_y", "PDE_drho", "steps"
347        )?;
348        writeln!(w, "{}", "-".repeat(55))?;
349        for row in &self.rows {
350            writeln!(
351                w,
352                "{:<10.0e} {:>10.3e} {:>10.3e} {:>10.3e} {:>10}",
353                row.z_h,
354                row.snapshot.mu,
355                row.snapshot.y,
356                row.snapshot.delta_rho_over_rho,
357                row.step_count,
358            )?;
359        }
360        if !self.warnings.is_empty() {
361            writeln!(w, "Warnings ({}):", self.warnings.len())?;
362            for warning in &self.warnings {
363                writeln!(w, "  - {warning}")?;
364            }
365        }
366        Ok(())
367    }
368}
369
370/// Result of a batch photon injection sweep over multiple x_inj values.
371///
372/// Contains one `PhotonSweepResult` per x_inj value.
373#[derive(Debug, Clone)]
374#[must_use]
375pub struct PhotonSweepBatchResult {
376    pub results: Vec<PhotonSweepResult>,
377    /// Aggregated diagnostic warnings across all batch workers.
378    pub warnings: Vec<String>,
379}
380
381impl PhotonSweepBatchResult {
382    /// Serialize to a JSON object containing per-x_inj results and aggregated warnings.
383    ///
384    /// Pre-warnings format was a bare JSON array. With warnings we wrap into
385    /// `{"results":[...], "warnings":[...]}`. Python wrappers tolerate both.
386    pub fn to_json(&self) -> String {
387        let mut out = String::with_capacity(self.results.len() * 4096);
388        out.push_str("{\"results\":[");
389        for (i, r) in self.results.iter().enumerate() {
390            if i > 0 {
391                out.push(',');
392            }
393            out.push_str(&r.to_json());
394        }
395        out.push(']');
396        if !self.warnings.is_empty() {
397            out.push(',');
398            write_json_string_array(&mut out, "warnings", &self.warnings);
399        }
400        out.push('}');
401        out
402    }
403
404    /// Write combined CSV summary to a writer.
405    pub fn write_csv<W: std::io::Write + ?Sized>(&self, w: &mut W) -> std::io::Result<()> {
406        for warning in &self.warnings {
407            writeln!(w, "# WARNING: {warning}")?;
408        }
409        writeln!(w, "x_inj,z_h,pde_mu,pde_y,drho,steps")?;
410        for r in &self.results {
411            for row in &r.rows {
412                writeln!(
413                    w,
414                    "{:.6e},{:.6e},{:.6e},{:.6e},{:.6e},{}",
415                    r.x_inj,
416                    row.z_h,
417                    row.snapshot.mu,
418                    row.snapshot.y,
419                    row.snapshot.delta_rho_over_rho,
420                    row.step_count,
421                )?;
422            }
423        }
424        Ok(())
425    }
426
427    /// Write a human-readable summary table.
428    pub fn write_table<W: std::io::Write + ?Sized>(&self, w: &mut W) -> std::io::Result<()> {
429        writeln!(w, "Photon sweep batch: {} x_inj values", self.results.len())?;
430        for r in &self.results {
431            r.write_table(w)?;
432            writeln!(w)?;
433        }
434        if !self.warnings.is_empty() {
435            writeln!(w, "Aggregated warnings ({}):", self.warnings.len())?;
436            for warning in &self.warnings {
437                writeln!(w, "  - {warning}")?;
438            }
439        }
440        Ok(())
441    }
442}
443
444/// Result of a Green's function calculation at a single redshift.
445#[derive(Debug, Clone)]
446#[must_use]
447pub struct GreensResult {
448    pub z_h: f64,
449    pub mu: f64,
450    pub y: f64,
451    pub x_grid: Vec<f64>,
452    pub delta_n: Vec<f64>,
453    /// Diagnostic warnings (validity-range, post-recombination caveats).
454    pub warnings: Vec<String>,
455}
456
457impl GreensResult {
458    /// Serialize to a JSON string.
459    pub fn to_json(&self) -> String {
460        let mut out = String::with_capacity(self.x_grid.len() * 30 + 256);
461        use std::fmt::Write;
462        write!(out, "{{\"results\":[{{").unwrap();
463        write_json_kv(&mut out, "z_h", self.z_h);
464        out.push(',');
465        write_json_kv(&mut out, "gf_mu", self.mu);
466        out.push(',');
467        write_json_kv(&mut out, "gf_y", self.y);
468        out.push(',');
469        write_json_array(&mut out, "x", &self.x_grid);
470        out.push(',');
471        write_json_array(&mut out, "delta_n_gf", &self.delta_n);
472        // Close: inner object `}`, array `]`, outer object `}`. The prior
473        // `}}]}` emitted three braces and failed `json.loads` (audit H7).
474        out.push_str("}]");
475        if !self.warnings.is_empty() {
476            out.push(',');
477            write_json_string_array(&mut out, "warnings", &self.warnings);
478        }
479        out.push('}');
480        out
481    }
482
483    /// Write CSV to a writer.
484    pub fn write_csv<W: std::io::Write + ?Sized>(&self, w: &mut W) -> std::io::Result<()> {
485        writeln!(
486            w,
487            "# mu={:.6e} y={:.6e} z_h={:.2e}",
488            self.mu, self.y, self.z_h
489        )?;
490        for warning in &self.warnings {
491            writeln!(w, "# WARNING: {warning}")?;
492        }
493        writeln!(w, "x,delta_n")?;
494        for (i, &x) in self.x_grid.iter().enumerate() {
495            writeln!(w, "{:.8e},{:.8e}", x, self.delta_n[i])?;
496        }
497        Ok(())
498    }
499
500    /// Write a human-readable summary.
501    pub fn write_table<W: std::io::Write + ?Sized>(&self, w: &mut W) -> std::io::Result<()> {
502        writeln!(w, "Green's function result at z_h = {:.2e}:", self.z_h)?;
503        writeln!(w, "  mu = {:.6e}", self.mu)?;
504        writeln!(w, "  y  = {:.6e}", self.y)?;
505        if !self.warnings.is_empty() {
506            writeln!(w, "Warnings ({}):", self.warnings.len())?;
507            for warning in &self.warnings {
508                writeln!(w, "  - {warning}")?;
509            }
510        }
511        Ok(())
512    }
513}
514
515// --- Serializable trait implementations ---
516
517impl Serializable for SolverResult {
518    fn to_json(&self) -> String {
519        self.to_json()
520    }
521    fn write_csv_dyn(&self, w: &mut dyn std::io::Write) -> std::io::Result<()> {
522        self.write_csv(w)
523    }
524    fn write_table_dyn(&self, w: &mut dyn std::io::Write) -> std::io::Result<()> {
525        self.write_table(w)
526    }
527}
528
529impl Serializable for SweepResult {
530    fn to_json(&self) -> String {
531        self.to_json()
532    }
533    fn write_csv_dyn(&self, w: &mut dyn std::io::Write) -> std::io::Result<()> {
534        self.write_csv(w)
535    }
536    fn write_table_dyn(&self, w: &mut dyn std::io::Write) -> std::io::Result<()> {
537        self.write_table(w)
538    }
539}
540
541impl Serializable for PhotonSweepResult {
542    fn to_json(&self) -> String {
543        self.to_json()
544    }
545    fn write_csv_dyn(&self, w: &mut dyn std::io::Write) -> std::io::Result<()> {
546        self.write_csv(w)
547    }
548    fn write_table_dyn(&self, w: &mut dyn std::io::Write) -> std::io::Result<()> {
549        self.write_table(w)
550    }
551}
552
553impl Serializable for PhotonSweepBatchResult {
554    fn to_json(&self) -> String {
555        self.to_json()
556    }
557    fn write_csv_dyn(&self, w: &mut dyn std::io::Write) -> std::io::Result<()> {
558        self.write_csv(w)
559    }
560    fn write_table_dyn(&self, w: &mut dyn std::io::Write) -> std::io::Result<()> {
561        self.write_table(w)
562    }
563}
564
565impl Serializable for GreensResult {
566    fn to_json(&self) -> String {
567        self.to_json()
568    }
569    fn write_csv_dyn(&self, w: &mut dyn std::io::Write) -> std::io::Result<()> {
570        self.write_csv(w)
571    }
572    fn write_table_dyn(&self, w: &mut dyn std::io::Write) -> std::io::Result<()> {
573        self.write_table(w)
574    }
575}
576
577/// Write a JSON-safe float: NaN and Inf become null (valid JSON).
578fn write_json_float(out: &mut String, val: f64, precision: usize) {
579    use std::fmt::Write;
580    if val.is_finite() {
581        write!(out, "{val:.prec$e}", prec = precision).unwrap();
582    } else {
583        out.push_str("null");
584    }
585}
586
587fn write_json_kv(out: &mut String, key: &str, val: f64) {
588    use std::fmt::Write;
589    write!(out, "\"{key}\":").unwrap();
590    write_json_float(out, val, 15);
591}
592
593fn write_json_array(out: &mut String, key: &str, arr: &[f64]) {
594    use std::fmt::Write;
595    write!(out, "\"{key}\":[").unwrap();
596    for (i, &v) in arr.iter().enumerate() {
597        if i > 0 {
598            out.push(',');
599        }
600        write_json_float(out, v, 15);
601    }
602    out.push(']');
603}
604
605/// Write a JSON array of strings, escaping `"`, `\`, and control characters.
606fn write_json_string_array(out: &mut String, key: &str, arr: &[String]) {
607    use std::fmt::Write;
608    write!(out, "\"{key}\":[").unwrap();
609    for (i, s) in arr.iter().enumerate() {
610        if i > 0 {
611            out.push(',');
612        }
613        out.push('"');
614        for c in s.chars() {
615            match c {
616                '"' => out.push_str("\\\""),
617                '\\' => out.push_str("\\\\"),
618                '\n' => out.push_str("\\n"),
619                '\r' => out.push_str("\\r"),
620                '\t' => out.push_str("\\t"),
621                c if (c as u32) < 0x20 => {
622                    write!(out, "\\u{:04x}", c as u32).unwrap();
623                }
624                c => out.push(c),
625            }
626        }
627        out.push('"');
628    }
629    out.push(']');
630}
631
632/// Parse an output format from a string.
633#[derive(Debug, Clone, Copy, PartialEq, Eq)]
634pub enum OutputFormat {
635    Json,
636    Csv,
637    Table,
638}
639
640impl OutputFormat {
641    pub fn from_str(s: &str) -> Result<Self, String> {
642        match s {
643            "json" => Ok(OutputFormat::Json),
644            "csv" => Ok(OutputFormat::Csv),
645            "table" => Ok(OutputFormat::Table),
646            _ => Err(format!(
647                "Unknown output format: '{s}'. Valid: json, csv, table"
648            )),
649        }
650    }
651}
652
653#[cfg(test)]
654mod tests {
655    use super::*;
656
657    fn sample_result() -> SolverResult {
658        SolverResult {
659            snapshot: SolverSnapshot {
660                z: 100.0,
661                delta_n: vec![1e-6, 2e-6, -3e-6],
662                rho_e: 1.000_01,
663                mu: 5.0e-7,
664                y: 1.0e-7,
665                delta_rho_over_rho: 1.0e-5,
666                accumulated_delta_t: 2.0e-10,
667            },
668            x_grid: vec![0.1, 1.0, 10.0],
669            step_count: 42,
670            diag_newton_exhausted: 0,
671            warnings: Vec::new(),
672        }
673    }
674
675    #[test]
676    fn test_output_format_from_str() {
677        assert_eq!(OutputFormat::from_str("json").unwrap(), OutputFormat::Json);
678        assert_eq!(OutputFormat::from_str("csv").unwrap(), OutputFormat::Csv);
679        assert_eq!(
680            OutputFormat::from_str("table").unwrap(),
681            OutputFormat::Table
682        );
683        assert!(OutputFormat::from_str("xml").is_err());
684        assert!(OutputFormat::from_str("").is_err());
685    }
686
687    fn sample_sweep_result() -> SweepResult {
688        SweepResult {
689            delta_rho: 1e-5,
690            warnings: Vec::new(),
691            rows: vec![
692                SweepRow {
693                    z_h: 1e4,
694                    snapshot: SolverSnapshot {
695                        z: 500.0,
696                        delta_n: vec![1e-8, 2e-8],
697                        rho_e: 1.0,
698                        mu: 1e-10,
699                        y: 2.5e-6,
700                        delta_rho_over_rho: 1e-5,
701                        accumulated_delta_t: 0.0,
702                    },
703                    gf_mu: 1.1e-10,
704                    gf_y: 2.4e-6,
705                    gf_delta_n: vec![1.1e-8, 1.9e-8],
706                    x_grid: vec![1.0, 10.0],
707                    step_count: 100,
708                },
709                SweepRow {
710                    z_h: 2e5,
711                    snapshot: SolverSnapshot {
712                        z: 500.0,
713                        delta_n: vec![3e-6, -1e-6],
714                        rho_e: 1.000_01,
715                        mu: 1.4e-5,
716                        y: 1e-8,
717                        delta_rho_over_rho: 1e-5,
718                        accumulated_delta_t: 1e-11,
719                    },
720                    gf_mu: 1.38e-5,
721                    gf_y: 1.1e-8,
722                    gf_delta_n: vec![2.9e-6, -0.9e-6],
723                    x_grid: vec![1.0, 10.0],
724                    step_count: 500,
725                },
726            ],
727        }
728    }
729
730    /// Count opening vs. closing braces/brackets in a JSON string, ignoring
731    /// contents inside double-quoted strings. Used to catch the audit H7
732    /// class of bug (`}}]}` emitting one too many closing braces) without
733    /// taking a serde_json dev-dependency.
734    fn json_is_balanced(s: &str) -> bool {
735        let mut in_str = false;
736        let mut escape = false;
737        let mut curly: i32 = 0;
738        let mut square: i32 = 0;
739        for c in s.chars() {
740            if escape {
741                escape = false;
742                continue;
743            }
744            if in_str {
745                match c {
746                    '\\' => escape = true,
747                    '"' => in_str = false,
748                    _ => {}
749                }
750                continue;
751            }
752            match c {
753                '"' => in_str = true,
754                '{' => curly += 1,
755                '}' => curly -= 1,
756                '[' => square += 1,
757                ']' => square -= 1,
758                _ => {}
759            }
760            if curly < 0 || square < 0 {
761                return false;
762            }
763        }
764        curly == 0 && square == 0 && !in_str
765    }
766
767    #[test]
768    fn test_json_bracket_balance_all_variants() {
769        // SolverResult
770        assert!(
771            json_is_balanced(&sample_result().to_json()),
772            "SolverResult JSON has unbalanced brackets"
773        );
774        // SweepResult
775        assert!(
776            json_is_balanced(&sample_sweep_result().to_json()),
777            "SweepResult JSON has unbalanced brackets"
778        );
779        // GreensResult
780        let g = GreensResult {
781            z_h: 2e5,
782            mu: 1.4e-5,
783            y: 1e-8,
784            x_grid: vec![1.0, 10.0],
785            delta_n: vec![3e-6, -1e-6],
786            warnings: Vec::new(),
787        };
788        assert!(
789            json_is_balanced(&g.to_json()),
790            "GreensResult JSON has unbalanced brackets"
791        );
792    }
793}