1pub mod sparkline;
2
3use std::fs;
4use std::path::{Path, PathBuf};
5
6pub 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 pub convergence_status: String,
22 pub converged: bool,
25 pub outer_gradient_norm: Option<f64>,
28 pub criterion_certificate: Option<CriterionCertificateRow>,
32 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
55pub 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
73pub struct CriterionCertificateRow {
79 pub analytic_directional: f64,
80 pub fd_directional: f64, pub fd_error: f64, 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 pub axes: Vec<(usize, f64, Option<f64>, Option<f64>)>,
112}
113
114pub 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 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
164pub 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 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 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 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 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 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 "√|Standardized Residual|"
309 ),
310 cfg = plot_cfg,
311 ));
312
313 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 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 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 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 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 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 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, cert.fd_error, cert.agreement_z )
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 ));
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 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 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 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>λ<sub>0</sub></th><th>λ<sub>1</sub></th>\
559 <th>λ<sub>2</sub></th><th>R</th><th>ν</th><th>κ²</th><th>Status</th></tr></thead>\n\
560 <tbody>{rows}</tbody>\n</table></div>\n</section>"
561 )
562 };
563
564 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 (η) are reported; \u{2113} and κ 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>η</th><th>\u{2113}</th><th>κ</th><th></th></tr></thead>\n\
610 <tbody>{rows}</tbody>\n</table></div>\n</section>"
611 )
612 };
613
614 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 "λ<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 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 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 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 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 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('&', "&")
1027 .replace('<', "<")
1028 .replace('>', ">")
1029 .replace('"', """)
1030 .replace('\'', "'")
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 #[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&b");
1079 }
1080
1081 #[test]
1082 fn esc_angle_brackets() {
1083 assert_eq!(esc("<script>"), "<script>");
1084 }
1085
1086 #[test]
1087 fn esc_double_quote() {
1088 assert_eq!(esc("\"x\""), ""x"");
1089 }
1090
1091 #[test]
1092 fn esc_single_quote() {
1093 assert_eq!(esc("it's"), "it's");
1094 }
1095
1096 #[test]
1097 fn esc_all_entities() {
1098 assert_eq!(esc("a&b<c>d\"e'f"), "a&b<c>d"e'f");
1099 }
1100
1101 #[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 #[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 #[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 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("<bad>"),
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("<script>"),
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}