use crate::suite::Suite;
use serde::Serialize;
use std::path::Path;
#[derive(Clone, Debug)]
pub struct StudyOutput {
pub json: String,
pub html: String,
pub summary: String,
}
#[derive(Clone, Debug, Serialize)]
struct StudyScenario {
label: String,
scenario_hash: String,
foms: serde_json::Value,
}
#[derive(Clone, Debug, Serialize)]
struct StudyArtifact {
schema_version: String,
engine_version: String,
title: String,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
generated_utc: Option<String>,
scenarios: Vec<StudyScenario>,
}
struct Resolved {
label: String,
scenario_hash: String,
foms: serde_json::Value,
}
fn label_for(path: &str, label: &Option<String>) -> String {
if let Some(l) = label {
return l.clone();
}
Path::new(path)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(path)
.to_string()
}
fn extract_foms(root: &serde_json::Value) -> serde_json::Value {
let mut map = serde_json::Map::new();
for clock in ["quantum", "classical"] {
if let Some(fom) = root.get(clock).and_then(|c| c.get("fom")) {
map.insert(clock.to_string(), fom.clone());
}
}
if map.is_empty() {
if let Some(fom) = root.get("fom") {
map.insert("fom".to_string(), fom.clone());
}
}
serde_json::Value::Object(map)
}
pub fn run_suite(suite: &Suite, base_dir: &Path) -> Result<StudyOutput, String> {
if suite.scenarios.is_empty() {
return Err("study has no scenarios to run".to_string());
}
let mut resolved: Vec<Resolved> = Vec::with_capacity(suite.scenarios.len());
for entry in &suite.scenarios {
let path = base_dir.join(&entry.path);
let src = std::fs::read_to_string(&path)
.map_err(|e| format!("cannot read scenario {}: {e}", path.display()))?;
let out = crate::api::run_toml(&src)
.map_err(|e| format!("scenario {} failed: {e}", path.display()))?;
let root: serde_json::Value = serde_json::from_str(&out.json)
.map_err(|e| format!("scenario {} produced unparseable JSON: {e}", path.display()))?;
let scenario_hash = root
.get("scenario_hash")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let foms = extract_foms(&root);
resolved.push(Resolved {
label: label_for(&entry.path, &entry.label),
scenario_hash,
foms,
});
}
let artifact = StudyArtifact {
schema_version: crate::interchange::SCHEMA_VERSION.to_string(),
engine_version: env!("CARGO_PKG_VERSION").to_string(),
title: suite.title.clone(),
description: suite.description.clone(),
generated_utc: None,
scenarios: resolved
.iter()
.map(|r| StudyScenario {
label: r.label.clone(),
scenario_hash: r.scenario_hash.clone(),
foms: r.foms.clone(),
})
.collect(),
};
let json = serde_json::to_string_pretty(&artifact).expect("study artifact serialises");
let html = render_html(&suite.title, suite.description.as_deref(), &resolved);
let summary = format!(
"study \"{}\": {} scenarios",
suite.title,
suite.scenarios.len()
);
Ok(StudyOutput {
json,
html,
summary,
})
}
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
const FOM_LABELS: &[(&str, &str, &str)] = &[
("holdover_s", "Holdover", "s"),
("timing_rms_ns", "Timing RMS", "ns"),
("timing_p95_ns", "Timing p95", "ns"),
("resilience_slope_ns_per_s", "Resilience slope", "ns/s"),
("availability", "Availability", ""),
("integrity", "Integrity", ""),
("security", "Security", ""),
];
fn fom_value(foms: &serde_json::Value, key: &str) -> Option<f64> {
for clock in ["quantum", "classical", "fom"] {
if let Some(v) = foms
.get(clock)
.and_then(|b| b.get(key))
.and_then(|v| v.as_f64())
{
return Some(v);
}
}
None
}
fn render_html(title: &str, description: Option<&str>, scenarios: &[Resolved]) -> String {
let title_e = html_escape(title);
let mut head_cols = String::from("<th>Metric</th><th>Validation</th>");
for r in scenarios {
head_cols.push_str(&format!("<th class=\"num\">{}</th>", html_escape(&r.label)));
}
let mut body = String::new();
for (key, label, unit) in FOM_LABELS {
let any = scenarios.iter().any(|r| fom_value(&r.foms, key).is_some());
if !any {
continue;
}
let Some(tier) = crate::fom_label::tier_for(key) else {
continue;
};
let metric = if unit.is_empty() {
(*label).to_string()
} else {
format!("{label} ({unit})")
};
let mut row = format!(
"<tr><td>{}</td><td><span class=\"tier\">{}</span></td>",
html_escape(&metric),
tier.tag(),
);
for r in scenarios {
match fom_value(&r.foms, key) {
Some(v) => row.push_str(&format!("<td class=\"num\">{v:.3}</td>")),
None => row.push_str("<td class=\"num\">—</td>"),
}
}
row.push_str("</tr>");
body.push_str(&row);
}
let desc_block = match description {
Some(d) if !d.trim().is_empty() => {
format!("<p class=\"desc\">{}</p>\n", html_escape(d))
}
_ => String::new(),
};
format!(
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\"/>\n\
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/>\n\
<title>{title_e} \u{2014} Kshana study</title>\n<style>\n\
:root{{color-scheme:light dark}}\
body{{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;line-height:1.55;\
max-width:1000px;margin:0 auto;padding:2rem 1.25rem 3rem}}\
.eyebrow{{letter-spacing:.18em;text-transform:uppercase;font-size:.72rem;opacity:.6;text-align:center}}\
h1{{text-align:center;font-size:2.1rem;margin:.1rem 0;\
background:linear-gradient(135deg,#2dd4bf,#6366f1,#a855f7);-webkit-background-clip:text;\
background-clip:text;color:transparent}}\
.desc{{text-align:center;opacity:.78;margin:.2rem 0 1.2rem}}\
h2{{font-size:1.15rem;margin:1.6rem 0 .6rem;border-bottom:1px solid #8884;padding-bottom:.2rem}}\
table{{border-collapse:collapse;width:100%;font-size:.9rem}}\
th,td{{border:1px solid #8884;padding:.35rem .6rem;text-align:left}}\
td.num,th.num{{text-align:right;font-variant-numeric:tabular-nums}}\
.tier{{font-size:.72rem;letter-spacing:.06em;font-weight:600;padding:.05rem .4rem;\
border:1px solid #8886;border-radius:4px;white-space:nowrap}}\
.tier-note{{font-size:.8rem;opacity:.75;margin:.5rem 0 1rem}}\
footer{{margin-top:2rem;padding-top:1rem;border-top:1px solid #8884;font-size:.85rem;opacity:.75}}\
</style>\n</head>\n<body>\n\
<p class=\"eyebrow\">\u{915}\u{94d}\u{937}\u{923} \u{b7} the precise instant</p>\n\
<h1>{title_e}</h1>\n\
{desc_block}\
<h2>Side-by-side comparison</h2>\n\
<table><thead><tr>{head_cols}</tr></thead><tbody>{body}</tbody></table>\n\
<p class=\"tier-note\">Each figure of merit is tagged VALIDATED (checked against an \
external oracle) or MODELLED (first-principles, internally tested), derived from the \
verification matrix. Columns are independent scenarios; values are not normalised \
across scenarios.</p>\n\
<footer>Generated by Kshana {version} as an aggregated study of {n} scenarios. \
Each scenario is reproducible from its scenario + seed + engine version. \
Free and open source (AGPL-3.0) \u{2014} \
<a href=\"https://github.com/AshfordeOU/kshana\">source & docs</a>.</footer>\n\
</body>\n</html>\n",
version = env!("CARGO_PKG_VERSION"),
n = scenarios.len(),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn label_falls_back_to_path_stem() {
assert_eq!(
label_for("a/b/clock-holdover.toml", &None),
"clock-holdover"
);
assert_eq!(
label_for("a.toml", &Some("Explicit".to_string())),
"Explicit"
);
}
#[test]
fn extract_foms_pulls_clock_blocks() {
let root = serde_json::json!({
"quantum": {"fom": {"holdover_s": 1.0}},
"classical": {"fom": {"holdover_s": 2.0}},
});
let foms = extract_foms(&root);
assert_eq!(foms["quantum"]["holdover_s"], 1.0);
assert_eq!(foms["classical"]["holdover_s"], 2.0);
}
#[test]
fn extract_foms_handles_top_level_fom() {
let root = serde_json::json!({ "fom": {"availability": 0.9} });
let foms = extract_foms(&root);
assert_eq!(foms["fom"]["availability"], 0.9);
assert!(foms.get("quantum").is_none());
}
#[test]
fn fom_value_prefers_quantum_then_classical() {
let foms = serde_json::json!({
"quantum": {"holdover_s": 10.0},
"classical": {"holdover_s": 20.0},
});
assert_eq!(fom_value(&foms, "holdover_s"), Some(10.0));
let only_classical = serde_json::json!({ "classical": {"availability": 0.5} });
assert_eq!(fom_value(&only_classical, "availability"), Some(0.5));
assert_eq!(fom_value(&foms, "not_a_fom"), None);
}
#[test]
fn render_html_has_table_title_and_tier() {
let scenarios = vec![Resolved {
label: "Alpha".to_string(),
scenario_hash: "abc".to_string(),
foms: serde_json::json!({ "quantum": {"holdover_s": 3.0, "availability": 1.0} }),
}];
let html = render_html("My Study", Some("desc"), &scenarios);
assert!(html.contains("My Study"));
assert!(html.contains("desc"));
assert!(html.contains("<table"));
assert!(html.contains("Alpha"));
assert!(html.contains("Holdover"));
assert!(html.contains("MODELLED"));
}
}