kshana 0.8.0

Open hybrid quantum/classical PNT performance simulator
Documentation
// SPDX-License-Identifier: Apache-2.0
//! Scenario dispatch shared by the CLI and the language bindings.
//!
//! [`run_toml`] parses a scenario from a TOML string, dispatches on its `kind`,
//! runs the matching pack, and returns the result as pretty JSON together with an
//! SVG chart and a one-line summary. The CLI, the Python binding, and the
//! WebAssembly binding all go through this one entry point so they never drift.

use crate::scenario::GnssState;
use serde::Deserialize;

/// The outputs of a scenario run: the result document, an SVG chart, and a
/// human-readable one-line summary.
pub struct RunOutput {
    pub json: String,
    pub svg: String,
    pub summary: String,
}

/// Escape the five characters that matter in HTML text/attribute context.
fn html_escape(s: &str) -> String {
    s.replace('&', "&")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
        .replace('\'', "&#39;")
}

/// Percent-encode an SVG for an inert `data:` URI: the chart renders as an image
/// (so no embedded markup or script can execute) and the report stays a single
/// self-contained file.
fn svg_data_uri(svg: &str) -> String {
    let mut out = String::from("data:image/svg+xml,");
    for b in svg.bytes() {
        match b {
            b'%' | b'#' | b'<' | b'>' | b'"' | b'&' | b'\n' | b'\r' | b'\t' => {
                out.push_str(&format!("%{b:02X}"));
            }
            _ => out.push(b as char),
        }
    }
    out
}

impl RunOutput {
    /// Render a self-contained, branded HTML scorecard: the one-line summary, the
    /// chart (as an inert image), and the full JSON result.
    pub fn html_report(&self) -> String {
        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>Kshana — scenario result</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:900px;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.4rem;margin:.1rem 0;\
             background:linear-gradient(135deg,#2dd4bf,#6366f1,#a855f7);-webkit-background-clip:text;\
             background-clip:text;color:transparent}}\
             .tag{{text-align:center;opacity:.7;margin-top:0}}\
             .summary{{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:.86rem;\
             border-left:3px solid #6366f1;padding:.7rem .9rem;background:rgba(99,102,241,.08);\
             border-radius:8px;overflow-x:auto;white-space:pre-wrap;word-break:break-word}}\
             .chart{{text-align:center;margin:1.2rem 0}}\
             .chart img{{max-width:100%;height:auto;border:1px solid #8884;border-radius:8px;background:#fff}}\
             details{{border:1px solid #8884;border-radius:8px;padding:.5rem .9rem}}\
             summary{{cursor:pointer;font-weight:600}}\
             pre{{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:.78rem;overflow:auto;max-height:520px}}\
             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\">क्षण · the precise instant</p>\n\
             <h1>Kshana</h1>\n\
             <p class=\"tag\">Hybrid quantum / classical PNT performance scorecard</p>\n\
             <p class=\"summary\">{summary}</p>\n\
             <div class=\"chart\"><img alt=\"Result chart\" src=\"{chart}\"/></div>\n\
             <details><summary>Full result (JSON)</summary><pre>{json}</pre></details>\n\
             <footer>Generated by Kshana {version}. Reproducible from scenario + seed + engine version. \
             Free and open source (Apache-2.0) — <a href=\"https://github.com/AshfordeOU/kshana\">source &amp; docs</a>.</footer>\n\
             </body>\n</html>\n",
            summary = html_escape(&self.summary),
            chart = svg_data_uri(&self.svg),
            json = html_escape(&self.json),
            version = env!("CARGO_PKG_VERSION"),
        )
    }
}

#[derive(Deserialize)]
struct Kind {
    #[serde(default)]
    kind: String,
}

fn json_of<T: serde::Serialize>(v: &T) -> String {
    serde_json::to_string_pretty(v).expect("result serialises")
}

fn integ(i: Option<f64>) -> String {
    i.map_or_else(|| "n/a".to_string(), |v| format!("{v:.3}"))
}

fn fnum(v: Option<f64>) -> String {
    v.map_or_else(|| "n/a".to_string(), |v| format!("{v:.2}"))
}

fn posm(v: Option<f64>) -> String {
    v.map_or_else(|| "n/a".to_string(), |v| format!("{v:.2}m"))
}

/// Parse, dispatch, and run a scenario given as a TOML string.
pub fn run_toml(src: &str) -> Result<RunOutput, String> {
    let kind: Kind = toml::from_str(src).unwrap_or(Kind {
        kind: String::new(),
    });
    match kind.kind.as_str() {
        "inertial" => {
            let scn: crate::inertial::InertialScenario =
                toml::from_str(src).map_err(|e| format!("invalid inertial scenario: {e}"))?;
            scn.time.validate()?;
            let r = crate::inertial::run_inertial(&scn);
            let summary = format!(
                "scenario {} | quantum holdover {:.0}s p95 {:.2}m | classical holdover {:.0}s p95 {:.1}m",
                &r.scenario_hash[..12],
                r.quantum.fom.holdover_s, r.quantum.fom.pos_p95_m,
                r.classical.fom.holdover_s, r.classical.fom.pos_p95_m,
            );
            Ok(RunOutput {
                json: json_of(&r),
                svg: crate::inertial::to_svg(&r),
                summary,
            })
        }
        "timetransfer" => {
            let scn: crate::timetransfer::TimeTransferScenario =
                toml::from_str(src).map_err(|e| format!("invalid time-transfer scenario: {e}"))?;
            let r = crate::timetransfer::run_timetransfer(&scn);
            let summary = format!(
                "scenario {} | optical sync_rms {:.2}ps range_rms {:.3}mm | RF sync_rms {:.1}ps range_rms {:.1}mm",
                &r.scenario_hash[..12],
                r.quantum.fom.sync_rms_ps, r.quantum.fom.range_rms_mm,
                r.classical.fom.sync_rms_ps, r.classical.fom.range_rms_mm,
            );
            Ok(RunOutput {
                json: json_of(&r),
                svg: crate::timetransfer::to_svg(&r),
                summary,
            })
        }
        "hybrid" => {
            let scn: crate::hybrid::HybridScenario =
                toml::from_str(src).map_err(|e| format!("invalid hybrid scenario: {e}"))?;
            scn.time.validate()?;
            let r = crate::hybrid::run_hybrid(&scn);
            let summary = format!(
                "scenario {} | quantum PNT-holdover {:.0}s (t {:.0}s/p {:.0}s) integrity {} security {} | classical PNT-holdover {:.0}s (t {:.0}s/p {:.0}s) integrity {} security {}",
                &r.scenario_hash[..12],
                r.quantum.fom.pnt_holdover_s, r.quantum.fom.timing_holdover_s, r.quantum.fom.position_holdover_s, integ(r.quantum.fom.integrity), integ(r.quantum.fom.security),
                r.classical.fom.pnt_holdover_s, r.classical.fom.timing_holdover_s, r.classical.fom.position_holdover_s, integ(r.classical.fom.integrity), integ(r.classical.fom.security),
            );
            Ok(RunOutput {
                json: json_of(&r),
                svg: crate::hybrid::to_svg(&r),
                summary,
            })
        }
        "fusion" => {
            let scn: crate::hybrid::HybridScenario =
                toml::from_str(src).map_err(|e| format!("invalid fusion scenario: {e}"))?;
            scn.time.validate()?;
            let r = crate::fusion::run_fusion(&scn);
            let summary = format!(
                "scenario {} | fused | quantum PNT-holdover {:.0}s (t {:.0}s/p {:.0}s) integrity {} security {} | classical PNT-holdover {:.0}s (t {:.0}s/p {:.0}s) integrity {} security {}",
                &r.scenario_hash[..12],
                r.quantum.fom.pnt_holdover_s, r.quantum.fom.timing_holdover_s, r.quantum.fom.position_holdover_s, integ(r.quantum.fom.integrity), integ(r.quantum.fom.security),
                r.classical.fom.pnt_holdover_s, r.classical.fom.timing_holdover_s, r.classical.fom.position_holdover_s, integ(r.classical.fom.integrity), integ(r.classical.fom.security),
            );
            Ok(RunOutput {
                json: json_of(&r),
                svg: crate::hybrid::to_svg(&r),
                summary,
            })
        }
        "spoof" => {
            let scn: crate::spoof::SpoofScenario =
                toml::from_str(src).map_err(|e| format!("invalid spoof scenario: {e}"))?;
            scn.time.validate()?;
            let r = crate::spoof::run_spoof(&scn);
            let det = |c: &crate::spoof::SpoofClock| {
                c.detect_time_s
                    .map_or_else(|| "undetected".to_string(), |t| format!("detected {t:.0}s"))
            };
            let summary = format!(
                "scenario {} | spoof {:.2} ns/s vs {:.0} ns spec | quantum bound {:.3}ns {} ({}) | classical bound {:.2}ns {} ({})",
                &r.scenario_hash[..12], scn.attack.rate_ns_per_s, r.threshold_ns,
                r.quantum.min_detectable_ns, det(&r.quantum),
                if r.quantum.breaches_spec_undetected { "spoof succeeds" } else { "caught before spec" },
                r.classical.min_detectable_ns, det(&r.classical),
                if r.classical.breaches_spec_undetected { "spoof succeeds" } else { "caught before spec" },
            );
            Ok(RunOutput {
                json: json_of(&r),
                svg: crate::spoof::to_svg(&r),
                summary,
            })
        }
        "sweep" => {
            let scn: crate::sweep::SweepScenario =
                toml::from_str(src).map_err(|e| format!("invalid sweep scenario: {e}"))?;
            scn.base.time.validate()?;
            let r = crate::sweep::run_sweep(&scn)?;
            let (first, last) = (r.points.first(), r.points.last());
            let summary = format!(
                "sweep {} over {} ({:.2e}..{:.2e}, {} pts, {} scale) | quantum {:.3}->{:.3} | classical {:.3}->{:.3}",
                r.metric, r.parameter,
                first.map_or(0.0, |p| p.value), last.map_or(0.0, |p| p.value), r.points.len(), r.scale,
                first.map_or(0.0, |p| p.quantum), last.map_or(0.0, |p| p.quantum),
                first.map_or(0.0, |p| p.classical), last.map_or(0.0, |p| p.classical),
            );
            Ok(RunOutput {
                json: json_of(&r),
                svg: crate::sweep::to_svg(&r),
                summary,
            })
        }
        "orbit" => {
            let scn: crate::orbit::OrbitClockScenario =
                toml::from_str(src).map_err(|e| format!("invalid orbit scenario: {e}"))?;
            scn.time.validate()?;
            let r = crate::run::run_orbit_clock(&scn)?;
            let geometry = crate::orbit::summarize_dop(
                &scn.user.to_orbit(),
                &scn.all_satellites()?,
                scn.time.step_s,
                scn.time.duration_s,
                scn.mask_deg,
                scn.sigma_uere_m,
            );
            let nominal = r
                .quantum
                .series
                .iter()
                .filter(|s| s.gnss == GnssState::Nominal)
                .count();
            let summary = format!(
                "scenario {} | {}/{} samples GNSS-nominal | best PDOP {} pos {} | quantum holdover {:.0}s p95 {:.1}ns integrity {} security {} | classical holdover {:.0}s p95 {:.1}ns integrity {} security {}",
                &r.scenario_hash[..12],
                nominal, r.quantum.series.len(),
                fnum(geometry.best_pdop), posm(geometry.best_position_sigma_m),
                r.quantum.fom.holdover_s, r.quantum.fom.timing_p95_ns, integ(r.quantum.fom.integrity), integ(r.quantum.fom.security),
                r.classical.fom.holdover_s, r.classical.fom.timing_p95_ns, integ(r.classical.fom.integrity), integ(r.classical.fom.security),
            );
            #[derive(serde::Serialize)]
            struct OrbitOutput<'a> {
                #[serde(flatten)]
                run: &'a crate::report::RunResult,
                geometry: crate::orbit::DopSummary,
            }
            Ok(RunOutput {
                json: json_of(&OrbitOutput { run: &r, geometry }),
                svg: crate::report::to_svg(&r),
                summary,
            })
        }
        _ => {
            let scn: crate::scenario::Scenario =
                toml::from_str(src).map_err(|e| format!("invalid scenario: {e}"))?;
            scn.time.validate()?;
            if scn.runs > 1 {
                // Monte Carlo ensemble: report confidence bands instead of one run.
                let r = crate::ensemble::run_ensemble(&scn);
                let q = &r.quantum;
                let c = &r.classical;
                let summary = format!(
                    "scenario {} | {} runs | quantum holdover {:.0}s [{:.0}-{:.0}] p95 {:.1}ns security {} | classical holdover {:.0}s [{:.0}-{:.0}] p95 {:.1}ns security {}",
                    &r.scenario_hash[..12], r.runs,
                    q.holdover_s.mean, q.holdover_s.p05, q.holdover_s.p95, q.timing_p95_ns.mean, integ(q.security),
                    c.holdover_s.mean, c.holdover_s.p05, c.holdover_s.p95, c.timing_p95_ns.mean, integ(c.security),
                );
                return Ok(RunOutput {
                    json: json_of(&r),
                    svg: crate::ensemble::to_svg(&r),
                    summary,
                });
            }
            let r = crate::run::run(&scn);
            let summary = format!(
                "scenario {} | quantum holdover {:.0}s p95 {:.1}ns integrity {} security {} | classical holdover {:.0}s p95 {:.1}ns integrity {} security {}",
                &r.scenario_hash[..12],
                r.quantum.fom.holdover_s, r.quantum.fom.timing_p95_ns, integ(r.quantum.fom.integrity), integ(r.quantum.fom.security),
                r.classical.fom.holdover_s, r.classical.fom.timing_p95_ns, integ(r.classical.fom.integrity), integ(r.classical.fom.security),
            );
            Ok(RunOutput {
                json: json_of(&r),
                svg: crate::report::to_svg(&r),
                summary,
            })
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn dispatches_each_kind_and_emits_json_and_svg() {
        for src in [
            include_str!("../scenarios/clock-holdover.toml"),
            include_str!("../scenarios/clock-ensemble.toml"),
            include_str!("../scenarios/imu-deadreckoning.toml"),
            include_str!("../scenarios/timetransfer.toml"),
            include_str!("../scenarios/hybrid-pnt.toml"),
            include_str!("../scenarios/fusion-pnt.toml"),
            include_str!("../scenarios/orbit-gnss-challenged.toml"),
            include_str!("../scenarios/orbit-molniya.toml"),
            include_str!("../scenarios/orbit-multignss.toml"),
            include_str!("../scenarios/orbit-real-tle.toml"),
            include_str!("../scenarios/sweep-clock-stability.toml"),
            include_str!("../scenarios/spoof-attack.toml"),
        ] {
            let out = run_toml(src).expect("scenario runs");
            assert!(out.json.starts_with('{'));
            assert!(out.svg.starts_with("<svg"));
            assert!(!out.summary.is_empty());
        }
    }

    #[test]
    fn invalid_scenario_is_an_error() {
        assert!(run_toml("kind = \"orbit\"\nnot_valid = true").is_err());
    }

    #[test]
    fn html_report_is_self_contained_and_escaped() {
        let out = run_toml(include_str!("../scenarios/clock-holdover.toml")).unwrap();
        let html = out.html_report();
        assert!(html.starts_with("<!doctype html>"));
        assert!(html.contains("<img alt=\"Result chart\" src=\"data:image/svg+xml,"));
        assert!(html.contains("Kshana"));
        assert!(html.trim_end().ends_with("</html>"));
        // The embedded JSON must be HTML-escaped (no raw quotes from the document).
        assert!(html.contains("&quot;"));
        // The chart is an inert data-URI image, not inline markup that could execute.
        assert!(!html.contains("<svg"));
    }

    #[test]
    fn html_escape_handles_the_five_characters() {
        assert_eq!(
            html_escape("<a href=\"x\">&'</a>"),
            "&lt;a href=&quot;x&quot;&gt;&amp;&#39;&lt;/a&gt;"
        );
    }
}