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