Skip to main content

gam_report/
lib.rs

1pub mod sparkline;
2
3use std::fs;
4use std::path::{Path, PathBuf};
5
6// Data structs: plain data only, no gam library types.
7// main.rs is responsible for all model/fit computation and populating these.
8
9pub struct ReportInput {
10    pub model_path: String,
11    pub family_name: String,
12    pub model_class: String,
13    pub formula: String,
14    pub n_obs: Option<usize>,
15    pub deviance: f64,
16    pub reml_score: f64,
17    pub iterations: usize,
18    /// Human-readable P-IRLS / outer convergence status (e.g. "Converged",
19    /// "Max iterations reached"). Plain text so report.rs stays free of gam
20    /// library types; main.rs supplies `PirlsStatus::label()`.
21    pub convergence_status: String,
22    /// Whether the fit cleanly converged. Drives the visual flag on the
23    /// convergence line — any non-converged state is highlighted.
24    pub converged: bool,
25    /// Final outer-objective gradient norm at the recorded solution, when the
26    /// outer loop measured it (`None` for cache-hit / gradient-free exits).
27    pub outer_gradient_norm: Option<f64>,
28    /// First-order optimality certificate (#934), when the fit recorded one.
29    /// Plain data so report.rs stays free of gam library types; main.rs maps
30    /// the fit's `CriterionCertificate` into this row.
31    pub criterion_certificate: Option<CriterionCertificateRow>,
32    pub edf_total: f64,
33    pub r_squared: Option<f64>,
34    pub coefficients: Vec<CoefficientRow>,
35    pub edf_blocks: Vec<EdfBlockRow>,
36    pub continuous_order: Vec<ContinuousOrderRow>,
37    pub anisotropic_scales: Vec<AnisotropicScalesRow>,
38    pub measure_jet_spectra: Vec<MeasureJetSpectrumRow>,
39    pub diagnostics: Option<DiagnosticsInput>,
40    pub smooth_plots: Vec<SmoothPlotData>,
41    pub alo: Option<AloData>,
42    pub notes: Vec<String>,
43}
44
45pub struct EdfBlockRow {
46    pub index: usize,
47    pub edf: f64,
48    pub role: Option<String>,
49}
50
51/// First-order optimality certificate row (#934): the fit's self-audit of its
52/// analytic gradient against a finite difference of the actual criterion
53/// value at the returned optimum, plus curvature-definiteness and λ-rail
54/// facts. `consistent` / `clean` are precomputed verdicts so the renderer
55/// never re-derives policy.
56pub struct CriterionCertificateRow {
57    pub analytic_directional: f64,
58    pub fd_directional: f64, // fd-ok: FD-audit certificate, not in math path
59    pub fd_error: f64,       // fd-ok: FD-audit certificate, not in math path
60    pub agreement_z: f64,
61    pub grad_norm: f64,
62    pub hessian_pd: Option<bool>,
63    pub lambdas_railed: Vec<usize>,
64    pub consistent: bool,
65    pub clean: bool,
66}
67
68pub struct CoefficientRow {
69    pub index: usize,
70    pub estimate: f64,
71    pub std_error: Option<f64>,
72}
73
74pub struct ContinuousOrderRow {
75    pub name: String,
76    pub lambda0: f64,
77    pub lambda1: f64,
78    pub lambda2: f64,
79    pub r_ratio: Option<f64>,
80    pub nu: Option<f64>,
81    pub kappa2: Option<f64>,
82    pub status: String,
83}
84
85pub struct AnisotropicScalesRow {
86    pub term_name: String,
87    pub global_length_scale: Option<f64>,
88    /// Per-axis: (axis_index, eta, per_axis_length_scale, per_axis_kappa)
89    pub axes: Vec<(usize, f64, Option<f64>, Option<f64>)>,
90}
91
92/// Measure-jet scale spectrum row: the realized multiscale band of one
93/// measure-jet term, plus — in per-scale-candidate mode — the fitted
94/// physical λ̂_ℓ per scale and the implied continuous order
95/// ŝ = −½ · (least-squares slope of ln λ̂_ℓ on ln ε_ℓ). `per_scale` empty
96/// means the term carries a single fused jet-energy penalty, so only the
97/// band and the spec's order are shown. main.rs computes everything
98/// (`measure_jet_implied_order` derives ŝ); this row stays plain data like
99/// the rest of the file.
100pub struct MeasureJetSpectrumRow {
101    pub term_name: String,
102    pub eps_min: f64,
103    pub eps_max: f64,
104    pub n_scales: usize,
105    pub length_scale: f64,
106    pub spec_order_s: f64,
107    /// Per-scale (ε_ℓ, physical λ̂_ℓ) pairs, ascending in ε; empty = fused.
108    pub per_scale: Vec<(f64, f64)>,
109    pub implied_order: Option<f64>,
110}
111
112pub struct DiagnosticsInput {
113    pub residuals_sorted: Vec<f64>,
114    pub theoretical_quantiles: Vec<f64>,
115    pub y_observed: Vec<f64>,
116    pub y_predicted: Vec<f64>,
117    pub calibration: Option<CalibrationData>,
118}
119
120pub struct CalibrationData {
121    pub mean_predicted: Vec<f64>,
122    pub observed_rate: Vec<f64>,
123}
124
125pub struct AloData {
126    pub rows: Vec<AloRow>,
127}
128
129pub struct AloRow {
130    pub index: usize,
131    pub leverage: f64,
132    pub eta_tilde: f64,
133    pub se_sandwich: f64,
134}
135
136pub struct SmoothPlotData {
137    pub name: String,
138    pub x: Vec<f64>,
139    pub y: Vec<f64>,
140}
141
142/// Write the report HTML to the output path, returning the path written.
143pub fn write_report(
144    input: &ReportInput,
145    out: Option<&Path>,
146    model_path: &Path,
147) -> Result<PathBuf, String> {
148    let out = out.map(PathBuf::from).unwrap_or_else(|| {
149        let stem = model_path
150            .file_stem()
151            .and_then(|s| s.to_str())
152            .unwrap_or("model");
153        PathBuf::from(format!("{stem}.report.html"))
154    });
155    let html = render_html(input)?;
156    fs::write(&out, html)
157        .map_err(|e| format!("failed to write report '{}': {e}", out.display()))?;
158    Ok(out)
159}
160
161pub fn render_html(input: &ReportInput) -> Result<String, String> {
162    let json = |v: &[f64]| serde_json::to_string(v).map_err(|e| e.to_string());
163
164    let mut scripts = Vec::new();
165    let plot_cfg =
166        "responsive:true,displaylogo:false,modeBarButtonsToRemove:['lasso2d','select2d']";
167    let plot_style = |title: &str, xtitle: &str, ytitle: &str| {
168        format!(
169            "{{margin:{{t:44,b:48,l:56,r:24}},\
170             font:{{family:'Inter,system-ui,sans-serif'}},\
171             title:{{text:'{title}',font:{{size:14,color:'#1e293b'}}}},\
172             xaxis:{{title:'{xtitle}',gridcolor:'#f0f0f0',zeroline:false}},\
173             yaxis:{{title:'{ytitle}',gridcolor:'#f0f0f0',zeroline:false}},\
174             plot_bgcolor:'#fafafa',paper_bgcolor:'white',hoverlabel:{{font:{{size:12}}}}}}"
175        )
176    };
177    let marker = "marker:{color:'#6366f1',size:4,opacity:0.6}";
178
179    if let Some(diag) = &input.diagnostics {
180        let residuals: Vec<f64> = diag
181            .y_observed
182            .iter()
183            .zip(diag.y_predicted.iter())
184            .map(|(o, p)| o - p)
185            .collect();
186
187        // QQ plot
188        scripts.push(format!(
189            "Plotly.newPlot('qq_plot',\
190             [{{x:{theo},y:{res},mode:'markers',type:'scattergl',{marker}}}],\
191             {layout},{{{cfg}}});",
192            theo = json(&diag.theoretical_quantiles)?,
193            res = json(&diag.residuals_sorted)?,
194            marker = marker,
195            layout = plot_style("Normal Q-Q", "Theoretical Quantile", "Sample Quantile"),
196            cfg = plot_cfg,
197        ));
198
199        // Residuals vs fitted
200        let fit_min = diag
201            .y_predicted
202            .iter()
203            .copied()
204            .fold(f64::INFINITY, f64::min);
205        let fit_max = diag
206            .y_predicted
207            .iter()
208            .copied()
209            .fold(f64::NEG_INFINITY, f64::max);
210        scripts.push(format!(
211            "Plotly.newPlot('resid_fitted',\
212             [{{x:{fitted},y:{resid},mode:'markers',type:'scattergl',{marker}}}],\
213             Object.assign({layout},{{shapes:[{{type:'line',x0:{fit_min},x1:{fit_max},\
214             y0:0,y1:0,line:{{color:'#cbd5e1',width:1,dash:'dash'}}}}]}}),\
215             {{{cfg}}});",
216            fitted = json(&diag.y_predicted)?,
217            resid = json(&residuals)?,
218            marker = marker,
219            layout = plot_style("Residuals vs Fitted", "Fitted Value", "Residual"),
220            fit_min = fit_min,
221            fit_max = fit_max,
222            cfg = plot_cfg,
223        ));
224
225        // Observed vs Predicted
226        let range_min = diag
227            .y_observed
228            .iter()
229            .chain(diag.y_predicted.iter())
230            .copied()
231            .fold(f64::INFINITY, f64::min);
232        let range_max = diag
233            .y_observed
234            .iter()
235            .chain(diag.y_predicted.iter())
236            .copied()
237            .fold(f64::NEG_INFINITY, f64::max);
238        scripts.push(format!(
239            "Plotly.newPlot('obs_pred',\
240             [{{x:{pred},y:{obs},mode:'markers',type:'scattergl',{marker}}},\
241             {{x:[{lo},{hi}],y:[{lo},{hi}],mode:'lines',\
242             line:{{color:'#cbd5e1',width:1,dash:'dash'}},showlegend:false}}],\
243             {layout},{{{cfg}}});",
244            pred = json(&diag.y_predicted)?,
245            obs = json(&diag.y_observed)?,
246            marker = marker,
247            lo = range_min,
248            hi = range_max,
249            layout = plot_style("Observed vs Predicted", "Predicted", "Observed"),
250            cfg = plot_cfg,
251        ));
252
253        // Residual histogram
254        scripts.push(format!(
255            "Plotly.newPlot('resid_hist',\
256             [{{x:{resid},type:'histogram',\
257             marker:{{color:'#6366f1',line:{{color:'#4f46e5',width:0.5}}}},opacity:0.85}}],\
258             {layout},{{{cfg}}});",
259            resid = json(&residuals)?,
260            layout = plot_style("Residual Distribution", "Residual", "Frequency"),
261            cfg = plot_cfg,
262        ));
263
264        // Scale-location plot: sqrt(|standardized residuals|) vs fitted
265        let resid_std = {
266            let n = residuals.len() as f64;
267            let mean = residuals.iter().sum::<f64>() / n.max(1.0);
268            let var =
269                residuals.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / (n - 1.0).max(1.0);
270            let sd = var.sqrt().max(1e-15);
271            residuals
272                .iter()
273                .map(|r| (r / sd).abs().sqrt())
274                .collect::<Vec<_>>()
275        };
276        scripts.push(format!(
277            "Plotly.newPlot('scale_loc',\
278             [{{x:{fitted},y:{sqrt_abs},mode:'markers',type:'scattergl',{marker}}}],\
279             {layout},{{{cfg}}});",
280            fitted = json(&diag.y_predicted)?,
281            sqrt_abs = json(&resid_std)?,
282            marker = marker,
283            layout = plot_style(
284                "Scale-Location",
285                "Fitted Value",
286                "&radic;|Standardized Residual|"
287            ),
288            cfg = plot_cfg,
289        ));
290
291        // Calibration (binary only)
292        if let Some(cal) = &diag.calibration {
293            scripts.push(format!(
294                "Plotly.newPlot('cal_plot',\
295                 [{{x:{x},y:{y},mode:'markers+lines',type:'scatter',\
296                 marker:{{color:'#6366f1',size:7}},line:{{color:'#6366f1',width:2}}}},\
297                 {{x:[0,1],y:[0,1],mode:'lines',showlegend:false,\
298                 line:{{color:'#cbd5e1',width:1,dash:'dash'}}}}],\
299                 Object.assign({layout},\
300                 {{xaxis:{{title:'Mean Predicted',range:[-0.02,1.02],gridcolor:'#f0f0f0'}},\
301                 yaxis:{{title:'Observed Rate',range:[-0.02,1.02],gridcolor:'#f0f0f0'}}}}),\
302                 {{{cfg}}});",
303                x = json(&cal.mean_predicted)?,
304                y = json(&cal.observed_rate)?,
305                layout = plot_style("Calibration (Deciles)", "Mean Predicted", "Observed Rate"),
306                cfg = plot_cfg,
307            ));
308        }
309    }
310
311    // Smooth term partial-effect plots
312    for sp in &input.smooth_plots {
313        let div_id = format!("smooth_{}", to_html_id(&sp.name));
314        scripts.push(format!(
315            "Plotly.newPlot('{div_id}',\
316             [{{x:{x},y:{y},mode:'lines',type:'scatter',\
317             line:{{color:'#6366f1',width:2.5}}}}],\
318             {layout},{{{cfg}}});",
319            div_id = div_id,
320            x = json(&sp.x)?,
321            y = json(&sp.y)?,
322            layout = plot_style(
323                &format!("s({})", js_escape(&sp.name)),
324                &js_escape(&sp.name),
325                "Partial Effect",
326            ),
327            cfg = plot_cfg,
328        ));
329    }
330
331    // HTML sections
332    let notes_html = if input.notes.is_empty() {
333        String::new()
334    } else {
335        format!(
336            "<div class=\"alert\">{}</div>",
337            input
338                .notes
339                .iter()
340                .map(|n| esc(n))
341                .collect::<Vec<_>>()
342                .join("<br/>")
343        )
344    };
345
346    // Summary card: key-value pairs, 2-column grid
347    let mut summary_pairs: Vec<(&str, String)> = vec![
348        ("Family", esc(&input.family_name)),
349        ("Model Class", esc(&input.model_class)),
350    ];
351    if let Some(n) = input.n_obs {
352        summary_pairs.push(("Observations", format!("{}", n)));
353    }
354    summary_pairs.push(("Deviance", fmt_num(input.deviance)));
355    summary_pairs.push(("REML / LAML", fmt_num(input.reml_score)));
356    if let Some(r2) = input.r_squared {
357        summary_pairs.push(("R-squared", format!("{:.6}", r2)));
358    }
359    summary_pairs.push(("EDF (total)", format!("{:.4}", input.edf_total)));
360    // Outer iterations, annotated with the cap when the solver did not
361    // converge cleanly so "47" cannot be misread as "converged at 47".
362    let iter_value = if input.converged {
363        format!("{}", input.iterations)
364    } else {
365        format!(
366            "{} <span class=\"conv-warn\">(did not converge)</span>",
367            input.iterations
368        )
369    };
370    summary_pairs.push(("Outer Iterations", iter_value));
371    // Convergence status: always shown, visually flagged when not `Converged`,
372    // so a reader can immediately tell a healthy fit from one that hit the
373    // iteration cap, exhausted the LM step search, or went unstable.
374    let conv_value = if input.converged {
375        format!(
376            "<span class=\"conv-ok\">{}</span>",
377            esc(&input.convergence_status)
378        )
379    } else {
380        format!(
381            "<span class=\"conv-warn\">\u{26A0} {}</span>",
382            esc(&input.convergence_status)
383        )
384    };
385    summary_pairs.push(("Convergence", conv_value));
386    if let Some(g) = input.outer_gradient_norm {
387        summary_pairs.push(("Outer Gradient Norm", format!("{g:.3e}")));
388    }
389    // Optimality certificate (#934): the fit's own gradient-vs-objective
390    // audit at the optimum. A desync flag here names the broken criterion
391    // the moment it is introduced — surface it as loudly as non-convergence.
392    if let Some(cert) = &input.criterion_certificate {
393        let cert_value = if cert.clean {
394            format!(
395                "<span class=\"conv-ok\">consistent</span> \
396                 (grad\u{00B7}v={:.3e}, fd\u{00B7}v={:.3e}\u{00B1}{:.1e}, z={:.2})",
397                cert.analytic_directional,
398                cert.fd_directional, // fd-ok: FD-audit certificate, not in math path
399                cert.fd_error,       // fd-ok: FD-audit certificate, not in math path
400                cert.agreement_z     // fd-ok: FD-audit certificate, not in math path
401            )
402        } else {
403            let mut flags = Vec::new();
404            if !cert.consistent {
405                flags.push(format!(
406                    "gradient\u{2194}objective desync (grad\u{00B7}v={:.3e} vs fd\u{00B7}v={:.3e}\u{00B1}{:.1e}, z={:.2})",
407                    cert.analytic_directional, cert.fd_directional, cert.fd_error, cert.agreement_z // fd-ok: FD-audit certificate, not in math path
408                ));
409            }
410            if cert.hessian_pd == Some(false) {
411                flags.push("outer Hessian not positive definite".to_string());
412            }
413            if !cert.lambdas_railed.is_empty() {
414                flags.push(format!(
415                    "\u{03BB} railed at bound: {:?}",
416                    cert.lambdas_railed
417                ));
418            }
419            format!(
420                "<span class=\"conv-warn\">\u{26A0} {}</span>",
421                esc(&flags.join("; "))
422            )
423        };
424        summary_pairs.push(("Optimality Certificate", cert_value));
425    }
426
427    let summary_items = summary_pairs
428        .iter()
429        .map(|(k, v)| format!("<div class=\"stat-item\"><span class=\"stat-label\">{k}</span><span class=\"stat-value\">{v}</span></div>"))
430        .collect::<Vec<_>>()
431        .join("\n");
432
433    let formula_html = format!("<code class=\"formula\">{}</code>", esc(&input.formula));
434
435    // Coefficients table — collapsible if > 20 rows
436    let n_coef = input.coefficients.len();
437    let coef_rows = input
438        .coefficients
439        .iter()
440        .map(|c| {
441            let se_str = c.std_error.map(|v| format!("{v:.6e}")).unwrap_or_else(|| "\u{2014}".to_string());
442            let z_str = c.std_error
443                .filter(|&se| se.abs() > 1e-15)
444                .map(|se| format!("{:.3}", c.estimate / se))
445                .unwrap_or_else(|| "\u{2014}".to_string());
446            format!(
447                "<tr><td class=\"mono\">{}</td><td class=\"num\">{:.6e}</td><td class=\"num\">{}</td><td class=\"num\">{}</td></tr>",
448                c.index, c.estimate, se_str, z_str
449            )
450        })
451        .collect::<Vec<_>>()
452        .join("\n");
453    let coef_table = format!(
454        "<div class=\"table-wrap\"><table>\n\
455         <thead><tr><th>#</th><th>Estimate</th><th>Std. Error</th><th>z</th></tr></thead>\n\
456         <tbody>\n{coef_rows}\n</tbody>\n</table></div>"
457    );
458    let coef_body = if n_coef > 20 {
459        format!(
460            "<details><summary class=\"toggle\">{n_coef} coefficients (click to expand)</summary>\n{coef_table}\n</details>"
461        )
462    } else {
463        format!("<p class=\"muted\">{n_coef} parameters</p>\n{coef_table}")
464    };
465
466    // EDF blocks
467    let has_roles = input.edf_blocks.iter().any(|b| b.role.is_some());
468    let edf_rows = input
469        .edf_blocks
470        .iter()
471        .map(|b| {
472            if has_roles {
473                let role_label = b.role.as_deref().unwrap_or("\u{2014}");
474                format!(
475                    "<tr><td class=\"mono\">{}</td><td>{}</td><td class=\"num\">{:.4}</td></tr>",
476                    b.index,
477                    esc(role_label),
478                    b.edf
479                )
480            } else {
481                format!(
482                    "<tr><td class=\"mono\">{}</td><td class=\"num\">{:.4}</td></tr>",
483                    b.index, b.edf
484                )
485            }
486        })
487        .collect::<Vec<_>>()
488        .join("\n");
489    let edf_header = if has_roles {
490        "<thead><tr><th>Block</th><th>Role</th><th>EDF</th></tr></thead>"
491    } else {
492        "<thead><tr><th>Block</th><th>EDF</th></tr></thead>"
493    };
494    let edf_section = format!(
495        "<section class=\"card\" id=\"sec-edf\">\n\
496         <h2>EDF by Penalty Block</h2>\n\
497         <div class=\"table-wrap\"><table>\n\
498         {edf_header}\n\
499         <tbody>{edf_rows}</tbody>\n\
500         </table></div>\n\
501         </section>"
502    );
503
504    // Continuous smoothness order (only if present)
505    let continuous_section = if input.continuous_order.is_empty() {
506        String::new()
507    } else {
508        let rows = input
509            .continuous_order
510            .iter()
511            .map(|c| {
512                let f = |v: Option<f64>| {
513                    v.map(|x| format!("{x:.4e}"))
514                        .unwrap_or_else(|| "\u{2014}".to_string())
515                };
516                format!(
517                    "<tr><td>{}</td><td class=\"num\">{:.4e}</td><td class=\"num\">{:.4e}</td>\
518                 <td class=\"num\">{:.4e}</td><td class=\"num\">{}</td><td class=\"num\">{}</td>\
519                 <td class=\"num\">{}</td><td class=\"status\">{}</td></tr>",
520                    esc(&c.name),
521                    c.lambda0,
522                    c.lambda1,
523                    c.lambda2,
524                    f(c.r_ratio),
525                    f(c.nu),
526                    f(c.kappa2),
527                    esc(&c.status),
528                )
529            })
530            .collect::<Vec<_>>()
531            .join("\n");
532        format!(
533            "<section class=\"card\" id=\"sec-cont-order\">\n\
534             <h2>Continuous Smoothness Order</h2>\n\
535             <div class=\"table-wrap\"><table>\n\
536             <thead><tr><th>Term</th><th>&lambda;<sub>0</sub></th><th>&lambda;<sub>1</sub></th>\
537             <th>&lambda;<sub>2</sub></th><th>R</th><th>&nu;</th><th>&kappa;&sup2;</th><th>Status</th></tr></thead>\n\
538             <tbody>{rows}</tbody>\n</table></div>\n</section>"
539        )
540    };
541
542    // Anisotropic spatial geometry section
543    let aniso_section = if input.anisotropic_scales.is_empty() {
544        String::new()
545    } else {
546        let rows = input
547            .anisotropic_scales
548            .iter()
549            .flat_map(|row| {
550                let header = match row.global_length_scale {
551                    Some(length_scale) => format!(
552                        "{} (global \u{2113}={length_scale:.4})",
553                        esc(&row.term_name),
554                    ),
555                    None => format!(
556                        "{} (pure Duchon shape-only anisotropy)",
557                        esc(&row.term_name)
558                    ),
559                };
560                let mut out = vec![format!(
561                    "<tr><td colspan=\"5\" style=\"font-weight:600\">{header}</td></tr>"
562                )];
563                for &(axis, eta, length, kappa) in &row.axes {
564                    let length = length
565                        .map(|value| format!("{value:.4}"))
566                        .unwrap_or_else(|| "\u{2014}".to_string());
567                    let kappa = kappa
568                        .map(|value| format!("{value:.4}"))
569                        .unwrap_or_else(|| "\u{2014}".to_string());
570                    out.push(format!(
571                        "<tr><td style=\"padding-left:2em\">axis {axis}</td>\
572                         <td class=\"num\">{eta:+.4}</td>\
573                         <td class=\"num\">{length}</td>\
574                         <td class=\"num\">{kappa}</td>\
575                         <td></td></tr>"
576                    ));
577                }
578                out
579            })
580            .collect::<Vec<_>>()
581            .join("\n");
582        format!(
583            "<section class=\"card\" id=\"sec-aniso-scales\">\n\
584             <h2>Anisotropic Spatial Geometry</h2>\n\
585             <p class=\"muted\">Pure Duchon terms are scale-free, so only the centered axis contrasts (&eta;) are reported; \u{2113} and &kappa; are shown only for terms with a global length scale.</p>\n\
586             <div class=\"table-wrap\"><table>\n\
587             <thead><tr><th>Term / Axis</th><th>&eta;</th><th>\u{2113}</th><th>&kappa;</th><th></th></tr></thead>\n\
588             <tbody>{rows}</tbody>\n</table></div>\n</section>"
589        )
590    };
591
592    // Measure-jet scale spectrum: one compact line per term (only if present).
593    // Two realized shapes, matching the single-scale/multiscale opt-in (#1116): a
594    // single fused penalty (single-scale mode, the default at any center count
595    // unless `multiscale=true` — see `measure_jet_multiscale_mode`) carries an
596    // empty `per_scale`, so we print just the band and the spec order and skip the
597    // slope readout; the per-scale spectrum (multiscale mode) prints the fitted
598    // lambda_l and the implied order.
599    let measure_jet_section = if input.measure_jet_spectra.is_empty() {
600        String::new()
601    } else {
602        let lines = input
603            .measure_jet_spectra
604            .iter()
605            .map(|r| {
606                let band = format!(
607                    "band {}..{} ({} scales, \u{2113}={})",
608                    fmt_num(r.eps_min),
609                    fmt_num(r.eps_max),
610                    r.n_scales,
611                    fmt_num(r.length_scale),
612                );
613                let tail = if r.per_scale.is_empty() {
614                    format!("fused penalty, spec order s={:.2}", r.spec_order_s)
615                } else {
616                    let lams = r
617                        .per_scale
618                        .iter()
619                        .map(|&(_, lam)| format!("{lam:.3e}"))
620                        .collect::<Vec<_>>()
621                        .join(", ");
622                    let implied = r
623                        .implied_order
624                        .map(|s| format!("implied order s\u{0302}\u{2248}{s:.2}"))
625                        .unwrap_or_else(|| "implied order \u{2014}".to_string());
626                    format!(
627                        "&lambda;<sub>\u{2113}</sub> = [{lams}], {implied} (spec s={:.2})",
628                        r.spec_order_s
629                    )
630                };
631                format!(
632                    "<p class=\"mono\">{}: measure-jet {band}, {tail}</p>",
633                    esc(&r.term_name)
634                )
635            })
636            .collect::<Vec<_>>()
637            .join("\n");
638        format!(
639            "<section class=\"card\" id=\"sec-mjet-spectrum\">\n\
640             <h2>Measure-Jet Scale Spectrum</h2>\n\
641             {lines}\n</section>"
642        )
643    };
644
645    // Diagnostics plots grid
646    let diagnostics_section = if input.diagnostics.is_some() {
647        let has_cal = input
648            .diagnostics
649            .as_ref()
650            .and_then(|d| d.calibration.as_ref())
651            .is_some();
652        let cal_div = if has_cal {
653            "<div id=\"cal_plot\" class=\"plot\"></div>"
654        } else {
655            ""
656        };
657        format!(
658            "<section class=\"card\" id=\"sec-diagnostics\">\n\
659             <h2>Diagnostics</h2>\n\
660             <div class=\"plot-grid\">\n\
661               <div id=\"qq_plot\" class=\"plot\"></div>\n\
662               <div id=\"resid_fitted\" class=\"plot\"></div>\n\
663               <div id=\"obs_pred\" class=\"plot\"></div>\n\
664               <div id=\"resid_hist\" class=\"plot\"></div>\n\
665               <div id=\"scale_loc\" class=\"plot\"></div>\n\
666               {cal_div}\n\
667             </div>\n</section>"
668        )
669    } else {
670        String::new()
671    };
672
673    // Smooth term plots
674    let smooth_section = if input.smooth_plots.is_empty() {
675        String::new()
676    } else {
677        let divs = input
678            .smooth_plots
679            .iter()
680            .map(|sp| {
681                format!(
682                    "<div id=\"smooth_{}\" class=\"plot\"></div>",
683                    to_html_id(&sp.name)
684                )
685            })
686            .collect::<Vec<_>>()
687            .join("\n");
688        format!(
689            "<section class=\"card\" id=\"sec-smooth\">\n\
690             <h2>Smooth Terms</h2>\n\
691             <div class=\"plot-grid\">{divs}</div>\n</section>"
692        )
693    };
694
695    // ALO diagnostics table (only if present)
696    let alo_section = if let Some(alo) = &input.alo {
697        let max_show = 100;
698        let n_show = alo.rows.len().min(max_show);
699        let rows = alo.rows[..n_show]
700            .iter()
701            .map(|r| {
702                format!(
703                    "<tr><td class=\"mono\">{}</td><td class=\"num\">{:.6e}</td>\
704                 <td class=\"num\">{:.6e}</td><td class=\"num\">{:.6e}</td></tr>",
705                    r.index, r.leverage, r.eta_tilde, r.se_sandwich
706                )
707            })
708            .collect::<Vec<_>>()
709            .join("\n");
710        let truncation_note = if alo.rows.len() > max_show {
711            format!(
712                "<p class=\"muted\">Showing first {n_show} of {} rows</p>",
713                alo.rows.len()
714            )
715        } else {
716            String::new()
717        };
718        format!(
719            "<section class=\"card\" id=\"sec-alo\">\n\
720             <h2>ALO Diagnostics</h2>\n\
721             {truncation_note}\n\
722             <div class=\"table-wrap\"><table>\n\
723             <thead><tr><th>Row</th><th>Leverage</th><th>\u{03B7}\u{0303}</th><th>SE (sandwich)</th></tr></thead>\n\
724             <tbody>{rows}</tbody>\n</table></div>\n</section>"
725        )
726    } else {
727        String::new()
728    };
729
730    // Build nav links for present sections
731    let mut nav_items = vec![
732        ("sec-summary", "Summary"),
733        ("sec-coef", "Coefficients"),
734        ("sec-edf", "EDF"),
735    ];
736    if !input.continuous_order.is_empty() {
737        nav_items.push(("sec-cont-order", "Smoothness Order"));
738    }
739    if !input.anisotropic_scales.is_empty() {
740        nav_items.push(("sec-aniso-scales", "Anisotropy"));
741    }
742    if !input.measure_jet_spectra.is_empty() {
743        nav_items.push(("sec-mjet-spectrum", "Measure-Jet"));
744    }
745    if input.diagnostics.is_some() {
746        nav_items.push(("sec-diagnostics", "Diagnostics"));
747    }
748    if !input.smooth_plots.is_empty() {
749        nav_items.push(("sec-smooth", "Smooth Terms"));
750    }
751    if input.alo.is_some() {
752        nav_items.push(("sec-alo", "ALO"));
753    }
754    let nav_links = nav_items
755        .iter()
756        .map(|(id, label)| format!("<a href=\"#{id}\">{label}</a>"))
757        .collect::<Vec<_>>()
758        .join("");
759
760    Ok(format!(
761        r##"<!doctype html>
762<html lang="en"><head>
763<meta charset="utf-8"/>
764<meta name="viewport" content="width=device-width,initial-scale=1"/>
765<title>GAM Report</title>
766<script src="https://cdn.plot.ly/plotly-2.35.2.min.js"></script>
767<style>
768:root {{
769  --bg: #f8fafc; --card: #fff; --border: #e2e8f0;
770  --accent: #6366f1; --accent-light: #eef2ff; --accent-dark: #4f46e5;
771  --text: #1e293b; --text2: #475569; --text3: #94a3b8;
772  --font: 'Inter',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
773  --mono: 'SF Mono','JetBrains Mono','Fira Code',ui-monospace,monospace;
774  --shadow: 0 1px 3px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04);
775  --radius: 10px;
776}}
777*,*::before,*::after {{ margin:0;padding:0;box-sizing:border-box; }}
778html {{ scroll-behavior:smooth; }}
779body {{ font-family:var(--font);background:var(--bg);color:var(--text);line-height:1.6;-webkit-font-smoothing:antialiased; }}
780
781.header {{
782  background:linear-gradient(135deg,#1e293b 0%,#334155 100%);
783  color:#f8fafc; padding:28px 32px 20px;
784}}
785.header h1 {{ font-size:24px;font-weight:700;letter-spacing:-.03em; }}
786.header .subtitle {{ color:var(--text3);font-size:13px;margin-top:2px; }}
787.nav {{
788  display:flex;gap:2px;margin-top:16px;flex-wrap:wrap;
789}}
790.nav a {{
791  color:#cbd5e1;text-decoration:none;font-size:12px;font-weight:500;
792  padding:5px 12px;border-radius:6px;transition:all .15s;
793}}
794.nav a:hover {{ background:rgba(255,255,255,.1);color:#f1f5f9; }}
795
796.container {{ max-width:1200px;margin:0 auto;padding:20px 24px 48px; }}
797
798.card {{
799  background:var(--card);border:1px solid var(--border);
800  border-radius:var(--radius);padding:24px;margin-bottom:16px;
801  box-shadow:var(--shadow);
802}}
803.card h2 {{
804  font-size:15px;font-weight:600;color:var(--text);
805  margin-bottom:16px;padding-bottom:10px;
806  border-bottom:2px solid var(--accent-light);
807  display:flex;align-items:center;gap:8px;
808}}
809
810.formula {{
811  font-family:var(--mono);font-size:13px;
812  background:var(--accent-light);color:var(--accent-dark);
813  padding:8px 14px;border-radius:6px;display:block;
814  margin-bottom:4px;word-break:break-word;line-height:1.8;
815}}
816
817.stat-grid {{
818  display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px;
819}}
820.stat-item {{
821  display:flex;flex-direction:column;padding:10px 14px;
822  border-radius:8px;background:var(--bg);border:1px solid var(--border);
823}}
824.stat-label {{ font-size:11px;font-weight:600;color:var(--text3);text-transform:uppercase;letter-spacing:.05em; }}
825.stat-value {{ font-size:15px;font-weight:600;color:var(--text);font-variant-numeric:tabular-nums;margin-top:2px; }}
826.conv-ok {{ color:#15803d; }}
827.conv-warn {{ color:#b45309;font-weight:700; }}
828
829.alert {{
830  background:#fffbeb;border:1px solid #fde68a;border-radius:var(--radius);
831  padding:12px 16px;margin-bottom:16px;font-size:13px;color:#92400e;line-height:1.5;
832}}
833
834.table-wrap {{ overflow-x:auto;border-radius:8px;border:1px solid var(--border); }}
835table {{ border-collapse:collapse;width:100%;font-size:13px; }}
836thead th {{
837  background:var(--accent-light);color:var(--accent-dark);
838  font-weight:600;text-align:left;padding:9px 12px;
839  border-bottom:2px solid var(--border);white-space:nowrap;
840  position:sticky;top:0;
841}}
842tbody td {{ padding:7px 12px;border-bottom:1px solid #f1f5f9; }}
843tbody tr:last-child td {{ border-bottom:none; }}
844tbody tr:hover {{ background:#fafbfc; }}
845td.num {{ text-align:right;font-variant-numeric:tabular-nums;font-family:var(--mono);font-size:12px; }}
846td.mono,.mono {{ font-family:var(--mono);font-size:12px; }}
847td.status {{
848  font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;
849  color:var(--accent);
850}}
851
852.plot-grid {{ display:grid;grid-template-columns:repeat(2,1fr);gap:12px; }}
853.plot {{
854  width:100%;height:340px;border-radius:8px;
855  border:1px solid var(--border);background:white;
856}}
857
858.toggle {{
859  cursor:pointer;font-size:13px;font-weight:500;color:var(--accent);
860  padding:8px 0;list-style:none;user-select:none;
861}}
862.toggle::-webkit-details-marker {{ display:none; }}
863.toggle::before {{ content:'\25B6\FE0E  ';font-size:10px;transition:transform .2s; }}
864details[open] .toggle::before {{ content:'\25BC\FE0E  '; }}
865
866.muted {{ color:var(--text3);font-size:12px;margin-bottom:8px; }}
867
868.footer {{
869  text-align:center;padding:32px 0 8px;color:var(--text3);font-size:11px;
870  border-top:1px solid var(--border);margin-top:24px;
871}}
872.footer strong {{ color:var(--text2);font-weight:600; }}
873
874@media (max-width:768px) {{
875  .plot-grid {{ grid-template-columns:1fr; }}
876  .stat-grid {{ grid-template-columns:1fr 1fr; }}
877  .container {{ padding:12px 12px 24px; }}
878  .nav {{ display:none; }}
879}}
880@media print {{
881  .header {{ background:#1e293b !important;-webkit-print-color-adjust:exact;print-color-adjust:exact; }}
882  .card {{ break-inside:avoid;box-shadow:none; }}
883  .plot {{ height:280px; }}
884  .nav {{ display:none; }}
885}}
886</style></head>
887<body>
888<div class="header">
889  <h1>GAM Report</h1>
890  <p class="subtitle">{model_path}</p>
891  <nav class="nav">{nav}</nav>
892</div>
893<div class="container">
894{notes}
895
896<section class="card" id="sec-summary">
897<h2>Model Summary</h2>
898{formula}
899<div class="stat-grid">
900{summary_items}
901</div>
902</section>
903
904<section class="card" id="sec-coef">
905<h2>Coefficients</h2>
906{coef_body}
907</section>
908
909{edf_section}
910
911{continuous_section}
912{aniso_section}
913{measure_jet_section}
914{diagnostics_section}
915{smooth_section}
916{alo_section}
917
918<div class="footer">
919  Generated by <strong>gam</strong>
920</div>
921</div>
922<script>
923{scripts}
924</script>
925</body></html>"##,
926        model_path = esc(&input.model_path),
927        nav = nav_links,
928        notes = notes_html,
929        formula = formula_html,
930        summary_items = summary_items,
931        coef_body = coef_body,
932        edf_section = edf_section,
933        continuous_section = continuous_section,
934        aniso_section = aniso_section,
935        measure_jet_section = measure_jet_section,
936        diagnostics_section = diagnostics_section,
937        smooth_section = smooth_section,
938        alo_section = alo_section,
939        scripts = scripts.join("\n"),
940    ))
941}
942
943fn esc(s: &str) -> String {
944    s.replace('&', "&amp;")
945        .replace('<', "&lt;")
946        .replace('>', "&gt;")
947        .replace('"', "&quot;")
948        .replace('\'', "&#39;")
949}
950
951fn js_escape(s: &str) -> String {
952    s.replace('\\', "\\\\")
953        .replace('\'', "\\'")
954        .replace('"', "\\\"")
955        .replace('\n', "\\n")
956}
957
958fn to_html_id(s: &str) -> String {
959    s.chars()
960        .map(|c| {
961            if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
962                c
963            } else {
964                '_'
965            }
966        })
967        .collect()
968}
969
970fn fmt_num(v: f64) -> String {
971    if v.abs() < 1e4 && v.abs() > 1e-2 {
972        format!("{:.4}", v)
973    } else {
974        format!("{:.6e}", v)
975    }
976}
977
978#[cfg(test)]
979mod tests {
980    use super::*;
981
982    // ── esc ──────────────────────────────────────────────────────────────────
983
984    #[test]
985    fn esc_passthrough_safe_text() {
986        assert_eq!(esc("hello world"), "hello world");
987    }
988
989    #[test]
990    fn esc_empty_string() {
991        assert_eq!(esc(""), "");
992    }
993
994    #[test]
995    fn esc_ampersand() {
996        assert_eq!(esc("a&b"), "a&amp;b");
997    }
998
999    #[test]
1000    fn esc_angle_brackets() {
1001        assert_eq!(esc("<script>"), "&lt;script&gt;");
1002    }
1003
1004    #[test]
1005    fn esc_double_quote() {
1006        assert_eq!(esc("\"x\""), "&quot;x&quot;");
1007    }
1008
1009    #[test]
1010    fn esc_single_quote() {
1011        assert_eq!(esc("it's"), "it&#39;s");
1012    }
1013
1014    #[test]
1015    fn esc_all_entities() {
1016        assert_eq!(
1017            esc("a&b<c>d\"e'f"),
1018            "a&amp;b&lt;c&gt;d&quot;e&#39;f"
1019        );
1020    }
1021
1022    // ── js_escape ─────────────────────────────────────────────────────────────
1023
1024    #[test]
1025    fn js_escape_passthrough_safe_text() {
1026        assert_eq!(js_escape("hello"), "hello");
1027    }
1028
1029    #[test]
1030    fn js_escape_backslash() {
1031        assert_eq!(js_escape("a\\b"), "a\\\\b");
1032    }
1033
1034    #[test]
1035    fn js_escape_single_quote() {
1036        assert_eq!(js_escape("it's"), "it\\'s");
1037    }
1038
1039    #[test]
1040    fn js_escape_double_quote() {
1041        assert_eq!(js_escape("say \"hi\""), "say \\\"hi\\\"");
1042    }
1043
1044    #[test]
1045    fn js_escape_newline() {
1046        assert_eq!(js_escape("line\nnext"), "line\\nnext");
1047    }
1048
1049    // ── to_html_id ───────────────────────────────────────────────────────────
1050
1051    #[test]
1052    fn to_html_id_passthrough_alphanumeric() {
1053        assert_eq!(to_html_id("hello123"), "hello123");
1054    }
1055
1056    #[test]
1057    fn to_html_id_hyphen_and_underscore_preserved() {
1058        assert_eq!(to_html_id("x-var_1"), "x-var_1");
1059    }
1060
1061    #[test]
1062    fn to_html_id_dot_becomes_underscore() {
1063        assert_eq!(to_html_id("x.1"), "x_1");
1064    }
1065
1066    #[test]
1067    fn to_html_id_parens_become_underscore() {
1068        assert_eq!(to_html_id("s(x)"), "s_x_");
1069    }
1070
1071    #[test]
1072    fn to_html_id_empty_string() {
1073        assert_eq!(to_html_id(""), "");
1074    }
1075
1076    // ── fmt_num ──────────────────────────────────────────────────────────────
1077
1078    #[test]
1079    fn fmt_num_normal_range_positive() {
1080        assert_eq!(fmt_num(1.5), "1.5000");
1081    }
1082
1083    #[test]
1084    fn fmt_num_normal_range_negative() {
1085        assert_eq!(fmt_num(-3.14), "-3.1400");
1086    }
1087
1088    #[test]
1089    fn fmt_num_just_below_1e4() {
1090        assert_eq!(fmt_num(9999.0), "9999.0000");
1091    }
1092
1093    #[test]
1094    fn fmt_num_at_1e4_uses_scientific() {
1095        let s = fmt_num(10000.0);
1096        assert!(s.contains('e'), "expected scientific for 1e4, got {s}");
1097    }
1098
1099    #[test]
1100    fn fmt_num_just_above_0_01_threshold() {
1101        assert_eq!(fmt_num(0.011), "0.0110");
1102    }
1103
1104    #[test]
1105    fn fmt_num_exactly_0_01_uses_scientific() {
1106        let s = fmt_num(0.01);
1107        assert!(s.contains('e'), "expected scientific for 0.01, got {s}");
1108    }
1109
1110    #[test]
1111    fn fmt_num_zero_uses_scientific() {
1112        let s = fmt_num(0.0);
1113        assert!(s.contains('e'), "expected scientific for 0.0, got {s}");
1114    }
1115
1116    // ── render_html smoke test ────────────────────────────────────────────────
1117
1118    fn minimal_input(formula: &str) -> ReportInput {
1119        ReportInput {
1120            model_path: "model.gam".to_string(),
1121            family_name: "Gaussian".to_string(),
1122            model_class: "GAM".to_string(),
1123            formula: formula.to_string(),
1124            n_obs: Some(100),
1125            deviance: 42.5,
1126            reml_score: -17.3,
1127            iterations: 5,
1128            convergence_status: "Converged".to_string(),
1129            converged: true,
1130            outer_gradient_norm: None,
1131            criterion_certificate: None,
1132            edf_total: 3.2,
1133            r_squared: Some(0.85),
1134            coefficients: vec![CoefficientRow {
1135                index: 0,
1136                estimate: 1.23,
1137                std_error: Some(0.05),
1138            }],
1139            edf_blocks: vec![EdfBlockRow {
1140                index: 0,
1141                edf: 3.2,
1142                role: None,
1143            }],
1144            continuous_order: vec![],
1145            anisotropic_scales: vec![],
1146            measure_jet_spectra: vec![],
1147            diagnostics: None,
1148            smooth_plots: vec![],
1149            alo: None,
1150            notes: vec![],
1151        }
1152    }
1153
1154    #[test]
1155    fn render_html_produces_doctype() {
1156        let html = render_html(&minimal_input("y ~ s(x)")).unwrap();
1157        assert!(
1158            html.starts_with("<!doctype html>"),
1159            "expected HTML doctype at start"
1160        );
1161    }
1162
1163    #[test]
1164    fn render_html_contains_formula() {
1165        let html = render_html(&minimal_input("y ~ s(x)")).unwrap();
1166        assert!(
1167            html.contains("y ~ s(x)"),
1168            "formula not found in rendered HTML"
1169        );
1170    }
1171
1172    #[test]
1173    fn render_html_escapes_formula_special_chars() {
1174        let html = render_html(&minimal_input("y ~ <bad>")).unwrap();
1175        assert!(
1176            html.contains("&lt;bad&gt;"),
1177            "HTML special chars in formula must be escaped"
1178        );
1179        assert!(!html.contains("<bad>"), "raw unescaped <bad> must not appear");
1180    }
1181
1182    #[test]
1183    fn render_html_notes_are_escaped() {
1184        let mut input = minimal_input("y ~ s(x)");
1185        input.notes = vec!["<script>alert('xss')</script>".to_string()];
1186        let html = render_html(&input).unwrap();
1187        assert!(
1188            !html.contains("<script>alert"),
1189            "raw <script> tag must not appear in output"
1190        );
1191        assert!(
1192            html.contains("&lt;script&gt;"),
1193            "script tag must be HTML-escaped"
1194        );
1195    }
1196
1197    #[test]
1198    fn render_html_non_converged_shows_warning() {
1199        let mut input = minimal_input("y ~ s(x)");
1200        input.converged = false;
1201        input.convergence_status = "Max iterations reached".to_string();
1202        let html = render_html(&input).unwrap();
1203        assert!(
1204            html.contains("conv-warn"),
1205            "non-converged fit must show conv-warn class"
1206        );
1207    }
1208}