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 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
51pub struct CriterionCertificateRow {
57 pub analytic_directional: f64,
58 pub fd_directional: f64, pub fd_error: f64, 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 pub axes: Vec<(usize, f64, Option<f64>, Option<f64>)>,
90}
91
92pub 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 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
142pub 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 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 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 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 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 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 "√|Standardized Residual|"
287 ),
288 cfg = plot_cfg,
289 ));
290
291 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 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 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 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 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 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 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, cert.fd_error, cert.agreement_z )
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 ));
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 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 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 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>λ<sub>0</sub></th><th>λ<sub>1</sub></th>\
537 <th>λ<sub>2</sub></th><th>R</th><th>ν</th><th>κ²</th><th>Status</th></tr></thead>\n\
538 <tbody>{rows}</tbody>\n</table></div>\n</section>"
539 )
540 };
541
542 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 (η) are reported; \u{2113} and κ 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>η</th><th>\u{2113}</th><th>κ</th><th></th></tr></thead>\n\
588 <tbody>{rows}</tbody>\n</table></div>\n</section>"
589 )
590 };
591
592 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 "λ<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 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 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 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 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('&', "&")
945 .replace('<', "<")
946 .replace('>', ">")
947 .replace('"', """)
948 .replace('\'', "'")
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 #[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&b");
997 }
998
999 #[test]
1000 fn esc_angle_brackets() {
1001 assert_eq!(esc("<script>"), "<script>");
1002 }
1003
1004 #[test]
1005 fn esc_double_quote() {
1006 assert_eq!(esc("\"x\""), ""x"");
1007 }
1008
1009 #[test]
1010 fn esc_single_quote() {
1011 assert_eq!(esc("it's"), "it's");
1012 }
1013
1014 #[test]
1015 fn esc_all_entities() {
1016 assert_eq!(
1017 esc("a&b<c>d\"e'f"),
1018 "a&b<c>d"e'f"
1019 );
1020 }
1021
1022 #[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 #[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 #[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 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("<bad>"),
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("<script>"),
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}