kshana 0.27.2

Open, reproducible PNT-resilience simulator with quantum-sensor performance models
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
// SPDX-License-Identifier: AGPL-3.0-only
//! G13 reference — the operational-style Earth-orientation predictor is **additive**, and
//! its errors are genuine predictions.
//!
//! Three guarantees, each with its own oracle:
//!
//! * **Additivity (R1).** `tests/golden/realtime-frame-eop.pre-g13.json` is a frozen
//!   capture of the `realtime-frame-eop` report as it stood *before* the operational
//!   predictor existed, taken on the five-row final-only `finals2000A_2022001` excerpt
//!   that was then the runtime default. Every field it contains must still be present,
//!   with the identical value, in today's report **on that same input**: no released
//!   figure changed, no field removed. The file is a historical record — it must **never**
//!   be regenerated.
//!
//!   G12 later moved the runtime *default* onto the real 2026 product (20 Bulletin B
//!   finals + 12 Bulletin A prediction rows), so the capture is reached by naming the old
//!   input rather than by a bare run. That is the one authorised R1 exception, enumerated
//!   cell by cell in `docs/revisions/G12-default-eop-cell-changes.md`; the three
//!   source-identity strings it moved are pinned individually below, old → new, so the
//!   allowance cannot absorb a numeric change. The committed golden CSV moved with the
//!   default and was reissued under the same revision; G13 itself still adds no row to it.
//! * **No look-ahead.** A prediction fitted on data that includes the epoch it predicts is
//!   not a prediction. Every real row later than the issue epoch — the target row's own
//!   rapid value included — is wrecked, while the Bulletin B finals the forecast is scored
//!   against are left alone, and every emitted figure must come back identical.
//! * **A real, published predictor.** The three new inputs reach the report, the emitted
//!   per-row lead proves the fit window closed before the target, and the operational and
//!   persistence columns are different numbers measured over one identical epoch set.
//!
//! PIN-SCOPE:    `golden/realtime-frame-eop.pre-g13.json` pins every field the pre-G13
//!               default report CONTAINED, by value — a subset check, not an equality:
//!               today's report may add fields. `golden/realtime-frame-eop.csv` is pinned
//!               byte for byte.
//! PIN-EXCLUDES: anything ADDED to the JSON report since the capture. That is the whole
//!               point of the frozen file: it proves nothing was removed or changed, and
//!               deliberately says nothing about what was added — so a cross-cutting
//!               change that appends a block to every scenario document is OUT of scope
//!               here and must not cause this file to be regenerated. The CSV pin has no
//!               such escape: the reproducibility table is covered whole.

use kshana::api::run_toml;
use kshana::frame_eop::{
    operational_vs_persistence_vs_horizon, Horizon, OperationalPredictorConfig,
};
use kshana::realtime_frame_eop::RealtimeFrameEopScenario;
use serde_json::Value;

/// The frozen pre-G13 default report. Never regenerate this file.
const PRE_G13: &str = include_str!("golden/realtime-frame-eop.pre-g13.json");
/// The committed byte-stable reproducibility table.
const GOLDEN_CSV: &str = include_str!("golden/realtime-frame-eop.csv");
/// The real 45-row verbatim IERS extract — the longest series committed here.
const LONGSPAN: &str = "tests/fixtures/agency/eop/finals2000A_2022001_longspan.txt";

/// Every field of `base` must be present in `now` with the identical value. Reports the
/// full list of violations rather than only the first, so an additivity regression is
/// diagnosed in one run.
fn assert_superset(base: &Value, now: &Value, path: &str, bad: &mut Vec<String>) {
    match base {
        Value::Object(o) => {
            let Some(n) = now.as_object() else {
                bad.push(format!("{path}: was an object, is not"));
                return;
            };
            for (k, v) in o {
                match n.get(k) {
                    None => bad.push(format!("{path}.{k}: REMOVED")),
                    Some(x) => assert_superset(v, x, &format!("{path}.{k}"), bad),
                }
            }
        }
        Value::Array(a) => {
            let Some(n) = now.as_array() else {
                bad.push(format!("{path}: was an array, is not"));
                return;
            };
            if a.len() != n.len() {
                bad.push(format!(
                    "{path}: length {} -> {} (a released array may not change length)",
                    a.len(),
                    n.len()
                ));
                return;
            }
            for (i, v) in a.iter().enumerate() {
                assert_superset(v, &n[i], &format!("{path}[{i}]"), bad);
            }
        }
        _ => {
            if base != now {
                bad.push(format!("{path}: {base} -> {now}"));
            }
        }
    }
}

/// The final-only five-row excerpt the pre-G13 capture was taken on. G12 moved the RUNTIME
/// default onto the real 2026 product (20 finals + 12 Bulletin A prediction rows), so the
/// capture is no longer reproduced by a bare run — it is reproduced by naming that input.
const FINAL_ONLY: &str = "tests/fixtures/agency/eop/finals2000A_2022001.txt";

/// The G12 default switch renamed the input, and only the input. These are the ONLY fields
/// of the frozen capture whose value is allowed to differ, each pinned old → new so the
/// allowance cannot absorb anything else: a numeric cell that moved would still fail.
/// `eop_input.note` is the census prose, rewritten because the default is no longer a
/// final-only excerpt; it is compared by the substring that carries the claim.
const SOURCE_IDENTITY: &[(&str, &str, &str)] = &[
    (
        "eop_source",
        "bundled fixture finals2000A_2022001",
        FINAL_ONLY,
    ),
    (
        "eop_input.source",
        "bundled fixture finals2000A_2022001",
        FINAL_ONLY,
    ),
    (
        "eop_input.kind",
        "bundled-offline-fixture",
        "supplied-finals2000a-file",
    ),
];

/// Rewrite the frozen capture's source-identity strings to the values the same input now
/// reports, asserting each old value was the one recorded. Everything else is untouched.
fn retarget_source_identity(base: &mut Value) {
    for (path, was, now) in SOURCE_IDENTITY {
        let mut cur = &mut *base;
        for seg in path.split('.') {
            cur = cur.get_mut(seg).unwrap_or_else(|| {
                panic!("the frozen capture has no `{path}` — it is not the pre-G13 document")
            });
        }
        assert_eq!(
            cur.as_str(),
            Some(*was),
            "`{path}` of the frozen capture is not the value G12 renamed"
        );
        *cur = Value::String((*now).to_string());
    }
    // The predicted-vs-final table embeds the source name in two places.
    let t4 = &mut base["table4_predicted_vs_final_horizon"];
    t4["as_issued_source"] = Value::String(FINAL_ONLY.to_string());
    let stated = t4["statement"].as_str().expect("a statement").to_string();
    t4["statement"] =
        Value::String(stated.replace("bundled fixture finals2000A_2022001", FINAL_ONLY));
    // The census note is prose, rewritten by G12 because the default is no longer a
    // final-only excerpt. It is lifted out of the byte-for-byte comparison and checked
    // for its CLAIM instead (see the caller); nothing else is.
    base["eop_input"]
        .as_object_mut()
        .expect("eop_input is an object")
        .remove("note")
        .expect("the frozen capture carries a census note");
}

#[test]
fn the_defaults_still_emit_every_pre_g13_field_with_its_pre_g13_value() {
    let mut base: Value = serde_json::from_str(PRE_G13).expect("the frozen capture parses");
    // G12 (default EOP switch) is the one enumerated exception; see
    // docs/revisions/G12-default-eop-cell-changes.md. The capture is reproduced on the
    // input it was taken on, which is now named rather than defaulted to.
    retarget_source_identity(&mut base);
    let now: Value = serde_json::from_str(
        &run_toml(&format!(
            "kind=\"realtime-frame-eop\"\neop_finals2000a=\"{FINAL_ONLY}\"\n"
        ))
        .unwrap()
        .json,
    )
    .unwrap();
    // The rewritten census note must still say the thing the old one said.
    let note = now["eop_input"]["note"].as_str().expect("a census note");
    assert!(
        note.contains("final-only"),
        "the census prose must still explain the zero-prediction-row case: {note}"
    );
    let mut bad = Vec::new();
    assert_superset(&base, &now, "$", &mut bad);
    assert!(
        bad.is_empty(),
        "the additive-only rule was broken — {} change(s):\n{}",
        bad.len(),
        bad.join("\n")
    );
    // And the capture really is the pre-G13 document: it must NOT already contain the
    // new blocks, or this test would be comparing the change against itself.
    for added in [
        "operational_predictor_model",
        "table5_operational_vs_persistence",
        "table6_archived_vintage_predicted_vs_final",
        "units",
    ] {
        assert!(
            base.get(added).is_none(),
            "the frozen pre-G13 capture already contains `{added}` — it has been \
             regenerated, and no longer proves anything"
        );
        assert!(now.get(added).is_some(), "`{added}` is not emitted");
    }
}

#[test]
fn the_persistence_reproducibility_table_is_byte_identical() {
    // The CSV carries P4 Tables 1 and 2 — the persistence curve among them. G13 adds no
    // row to it. G12 DID move its Table 2 rows, by moving the default EOP input onto a
    // real product carrying prediction rows; the committed golden was reissued with that
    // revision (docs/revisions/G12-default-eop-cell-changes.md) and must be reproduced
    // byte-for-byte from here on.
    let out = run_toml("kind=\"realtime-frame-eop\"\n").unwrap();
    let csv = out.csv.as_ref().expect("a CSV artifact");
    assert_eq!(
        csv, GOLDEN_CSV,
        "the golden reproducibility CSV moved; G13 must not touch it"
    );
    assert_eq!(
        csv,
        &RealtimeFrameEopScenario::default().to_csv().unwrap(),
        "the runtime CSV and the scenario's own CSV diverged"
    );
}

/// The G13 configuration every reported number comes from: the real 45-row series at five
/// horizons, everything else default.
fn longspan_toml(extra: &str) -> String {
    format!("kind=\"realtime-frame-eop\"\nhorizons_days=[1,2,3,5,10]\neop_finals2000a=\"{LONGSPAN}\"\n{extra}")
}

#[test]
fn the_operational_columns_are_measured_and_differ_from_persistence() {
    let v: Value = serde_json::from_str(&run_toml(&longspan_toml("")).unwrap().json).unwrap();
    let t5 = &v["table5_operational_vs_persistence"];
    assert_eq!(t5["status"], "measured", "{}", t5["statement"]);
    let rows = t5["rows"].as_array().unwrap();
    assert_eq!(rows.len(), 5);
    for r in rows {
        // One epoch set, both predictors — and two genuinely different numbers.
        let o = r["combined"]["operational"]["rms_position_m"]
            .as_f64()
            .unwrap();
        let p = r["combined"]["persistence"]["rms_position_m"]
            .as_f64()
            .unwrap();
        assert!(o > 0.0 && p > 0.0);
        assert!(
            (o - p).abs() / p > 1e-3,
            "horizon {}: the two predictors produced the same error ({o} vs {p}) — the \
             comparison has stopped comparing anything",
            r["horizon"]
        );
        assert_eq!(
            r["combined"]["operational"]["n"],
            r["combined"]["persistence"]["n"]
        );
        // Emitted proof that the fit closed before the target.
        assert!(r["min_fit_lead_days"].as_f64().unwrap() >= 1.0);
    }
    // The equivalent horizon of the ~14.4 m real-time frame error is read off the measured
    // curve for both predictors, and the operational one sits further out.
    let eh = &t5["equivalent_horizon_days"];
    let op = eh["ut1"]["operational"].as_f64().unwrap();
    let pers = eh["ut1"]["persistence"].as_f64().unwrap();
    assert!(
        op > pers,
        "operational {op} d must exceed persistence {pers} d"
    );
}

/// Rewrite every row later than `after_mjd` so its **rapid Bulletin A** UT1 and pole are
/// wrecked, leaving the Bulletin B final block — the truth a forecast is scored against —
/// untouched. A predictor that reads even one row past its issue epoch moves; an honest
/// one cannot notice.
fn poison_rapid_after(body: &str, after_mjd: f64) -> (String, usize) {
    let mut out = String::new();
    let mut touched = 0;
    for line in body.lines() {
        match kshana::eop::parse_line(line) {
            Some(rec) if rec.mjd > after_mjd + 0.5 && line.len() > 68 => {
                let mut s = line.to_string();
                let ut1 = format!("{:>10.7}", rec.ut1_utc_s - 5.0);
                s.replace_range(58..68, &ut1[ut1.len() - 10..]);
                let xp = format!("{:>9.6}", rec.xp_arcsec + 0.5);
                s.replace_range(18..27, &xp[xp.len() - 9..]);
                let yp = format!("{:>9.6}", rec.yp_arcsec - 0.5);
                s.replace_range(37..46, &yp[yp.len() - 9..]);
                out.push_str(&s);
                touched += 1;
            }
            _ => out.push_str(line),
        }
        out.push('\n');
    }
    (out, touched)
}

/// Drop every row later than `last_mjd`, keeping comments and unparsed lines.
fn trim_after(body: &str, last_mjd: f64) -> String {
    body.lines()
        .filter(|l| match kshana::eop::parse_line(l) {
            Some(r) => r.mjd <= last_mjd + 1e-9,
            None => true,
        })
        .collect::<Vec<_>>()
        .join("\n")
}

// THE LOOK-AHEAD DETECTOR, at the level of the shipped report.
//
// The series is trimmed so the 1-day table has exactly ONE issue epoch, and then every
// rapid row after that issue epoch is wrecked — including the target row's own rapid
// value, whose Bulletin B final (the truth) is left alone. A predictor fitted on data
// that includes the epoch it predicts would move; the emitted rows must be identical.
#[test]
fn wrecking_every_row_after_the_issue_epoch_does_not_move_a_single_emitted_figure() {
    let clean_full = std::fs::read_to_string(LONGSPAN).expect("fixture");
    let cfg = OperationalPredictorConfig::default();
    let hs = [Horizon::Days(1)];
    let first_row = operational_vs_persistence_vs_horizon(&clean_full, &hs, &cfg);
    let issue = first_row[0].epochs_mjd[0];
    let target = first_row[0].target_mjds[0];

    let clean = trim_after(&clean_full, target);
    let (poisoned, touched) = poison_rapid_after(&clean, issue);
    assert_eq!(
        touched, 1,
        "exactly the target row should be wrecked, not {touched}"
    );
    assert_ne!(clean, poisoned, "the poison must actually change the bytes");

    let dir = std::env::temp_dir();
    let pid = std::process::id();
    // The sequence, not the pid, is what separates two calls: every test in a binary runs
    // as a thread of ONE process and shares the pid. Enforced by tests/source_guards.rs.
    static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
    let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
    let a = dir.join(format!("kshana_g13_clean_{pid}_{seq}.txt"));
    let b = dir.join(format!("kshana_g13_wrecked_{pid}_{seq}.txt"));
    std::fs::write(&a, &clean).unwrap();
    std::fs::write(&b, &poisoned).unwrap();
    let go = |p: &std::path::Path| -> Value {
        let toml = format!(
            "kind=\"realtime-frame-eop\"\nhorizons_days=[1]\neop_finals2000a=\"{}\"\n",
            p.to_string_lossy()
        );
        serde_json::from_str(&run_toml(&toml).unwrap().json).unwrap()
    };
    let va = go(&a);
    let vb = go(&b);
    let _ = std::fs::remove_file(&a);
    let _ = std::fs::remove_file(&b);

    let rows = |v: &Value| v["table5_operational_vs_persistence"]["rows"].clone();
    assert_eq!(
        rows(&va)[0]["n"],
        1,
        "the trim should leave one issue epoch"
    );
    assert_eq!(
        rows(&va),
        rows(&vb),
        "an emitted figure moved when only rows AFTER the issue epoch were wrecked — the \
         fit is reading ahead of the epoch it predicts"
    );
    // The same at the library level, where the whole row is compared field for field.
    let one_a = operational_vs_persistence_vs_horizon(&clean, &hs, &cfg);
    let one_b = operational_vs_persistence_vs_horizon(&poisoned, &hs, &cfg);
    assert_eq!(one_a.len(), 1);
    assert_eq!(one_a[0].n, 1);
    assert_eq!(one_a[0], one_b[0]);
}

// The same detector over the whole real series rather than a trimmed one: every fit at
// every issue epoch must be unaffected by rows later than that epoch. The comparison is
// per issue epoch, because a later issue epoch legitimately uses rows that a poison
// boundary further back would have wrecked.
#[test]
fn every_issue_epoch_is_unaffected_by_rows_later_than_itself() {
    let clean = std::fs::read_to_string(LONGSPAN).expect("fixture");
    let cfg = OperationalPredictorConfig::default();
    let hs = [Horizon::Days(1), Horizon::Days(2), Horizon::Days(3)];
    let base = operational_vs_persistence_vs_horizon(&clean, &hs, &cfg);
    assert!(base.iter().all(|r| r.n >= 3));
    for row in &base {
        let Horizon::Days(h) = row.horizon else {
            unreachable!()
        };
        // Score each issue epoch on its own, over a series wrecked past that epoch.
        for &issue in row.epochs_mjd.iter().take(4) {
            let target = issue + h as f64;
            let trimmed = trim_after(&clean, target);
            let (poisoned, touched) = poison_rapid_after(&trimmed, issue);
            assert_eq!(touched, h as usize, "issue {issue}, horizon {h}");
            let a = operational_vs_persistence_vs_horizon(&trimmed, &[row.horizon], &cfg);
            let b = operational_vs_persistence_vs_horizon(&poisoned, &[row.horizon], &cfg);
            assert_eq!(a.len(), 1);
            assert_eq!(b.len(), 1);
            let last = a[0].n - 1;
            assert!((a[0].epochs_mjd[last] - issue).abs() < 1e-9);
            assert_eq!(
                a[0], b[0],
                "issue {issue} at horizon {h}: a figure moved under a purely future \
                 corruption"
            );
        }
    }
}

#[test]
fn the_report_is_deterministic_with_the_predictor_in_it() {
    let t = longspan_toml("");
    assert_eq!(run_toml(&t).unwrap().json, run_toml(&t).unwrap().json);
}