Skip to main content

zenkey_fleet/report/
rate.rs

1//! The rate plane: per-key throughput and the latency populations behind
2//! it, kept apart because a stamped and an unstamped sample do not measure
3//! the same thing (#119).
4
5use super::asked::Asked;
6use serde::Serialize;
7
8/// One key's measured traffic over a `rate` window.
9#[derive(Debug, Clone, Serialize)]
10pub struct RateRow {
11    pub key: String,
12    pub count: u64,
13    pub bytes: u64,
14    /// Source-sequence gaps. `NotAsked` = `--loss` was not asked — the same
15    /// gate the report-level `sn_gaps` always had; the row used to serialize
16    /// an uncaveated `0` regardless (#238's twin, review finding R3). Even
17    /// when asked, zero also means "publishers attach no SourceInfo" — an
18    /// observation, not proof of losslessness.
19    #[serde(skip_serializing_if = "Asked::is_not_asked", default)]
20    pub sn_gaps: Asked<u64>,
21    /// Observed **skewed** latency over the window (#119). Gated on
22    /// `--latency` like `unstamped` (#238), but deliberately still `Option`
23    /// — the [`Asked`] split's other half: even when asked, it is absent
24    /// when **no sample was HLC-stamped**, which is asked-but-absent (not
25    /// zero latency, and not "not asked"). Whether the gate was on is what
26    /// `unstamped` being `Asked(_)` says. Split by who stamped it (#213):
27    /// the three populations measure from different clocks and are never
28    /// folded into one median.
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub latency: Option<crate::report::LatencyReport>,
31    /// Samples that carried no HLC — the other half of the latency
32    /// observation, so it rides the same gate: `NotAsked` = `--latency` was
33    /// not asked (R3, matching #238's fix for `latency` itself).
34    #[serde(skip_serializing_if = "Asked::is_not_asked", default)]
35    pub unstamped: Asked<u64>,
36}
37
38/// The `rate` report (issue #46) — measured counts plus the
39/// O6 bound honesty: a bounded [`StatsTable`](crate::model::stats::StatsTable) that retired
40/// keys must say so, or the totals silently claim more coverage than they
41/// have.
42#[derive(Debug, Clone, Serialize)]
43pub struct RateReport {
44    pub selector: String,
45    pub window_s: f64,
46    /// Rows are present only for a `--per-key` run, sorted by count
47    /// descending.
48    #[serde(skip_serializing_if = "Vec::is_empty")]
49    pub rows: Vec<RateRow>,
50    pub total_count: u64,
51    pub total_bytes: u64,
52    /// Concrete keys retained by the stats table over the window.
53    pub keys: usize,
54    /// Keys retired to stay within the table bound (RFC 09 §5.1 O6) — the
55    /// totals cover the retained set only.
56    pub evicted: u64,
57    /// The bound the table ran under.
58    pub max_keys: usize,
59    /// Total source-sequence gaps (`NotAsked` = `--loss` was not asked).
60    #[serde(skip_serializing_if = "Asked::is_not_asked", default)]
61    pub sn_gaps: Asked<u64>,
62}
63
64/// The observed **skewed** latency distribution of one key (#119):
65/// (arrival wall-clock − publisher HLC), µs, over the last `LAT_WINDOW`
66/// (a private bound) stamped samples.
67///
68/// The caveat is part of the measurement: this contains clock skew, and
69/// HLCs are only as good as the fleet's time discipline. Negative values
70/// are the skew *evidence* and are never clamped — render this as
71/// "observed skewed latency", an observation, not a verdict on the
72/// transport (RFC 09 §5.1 applied to a number).
73#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
74pub struct LatencySummary {
75    pub min_us: i64,
76    pub median_us: i64,
77    pub p95_us: i64,
78    pub max_us: i64,
79    /// Stamped samples in the window.
80    pub samples: usize,
81}
82
83/// One key's observed latency, kept apart by **who stamped it** (issue #213).
84///
85/// Three populations, never folded into one median. A publisher-stamped sample
86/// measures publisher → observer; a router-stamped one measures that router →
87/// observer, which is a different quantity on the same axis. Averaging them
88/// produces a number that describes neither, and a fleet where some producers
89/// timestamp and some do not would report it without a word.
90#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
91pub struct LatencyReport {
92    /// Samples the publishing session stamped itself.
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub self_stamped: Option<LatencySummary>,
95    /// Samples stamped by another node — commonly a router.
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub foreign: Option<LatencySummary>,
98    /// Stamped, but with no `SourceInfo` to compare against: unknown, not
99    /// foreign (RFC 09 §5.1 O4).
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub unattributable: Option<LatencySummary>,
102    /// The distinct stamping nodes seen on this key, rendered. Empty when
103    /// every sample was self-stamped — there is no third party to name.
104    #[serde(skip_serializing_if = "Vec::is_empty", default)]
105    pub stampers: Vec<String>,
106    /// Stampers beyond the retained bound that were dropped (O6: a bound
107    /// reports what it cost).
108    #[serde(skip_serializing_if = "is_zero", default)]
109    pub stampers_dropped: u64,
110}
111
112fn is_zero(n: &u64) -> bool {
113    *n == 0
114}
115
116impl LatencyReport {
117    /// Whether anything was observed at all.
118    pub fn is_empty(&self) -> bool {
119        self.self_stamped.is_none() && self.foreign.is_none() && self.unattributable.is_none()
120    }
121
122    /// The populations present, each with the label that says what it
123    /// measures. Ordered self → foreign → unattributable.
124    pub fn populations(&self) -> Vec<(&'static str, LatencySummary)> {
125        [
126            ("publisher-stamped", self.self_stamped),
127            ("router-stamped", self.foreign),
128            ("stamper unknown", self.unattributable),
129        ]
130        .into_iter()
131        .filter_map(|(label, s)| s.map(|s| (label, s)))
132        .collect()
133    }
134
135    /// The caveat that has to travel with every rendering of these numbers.
136    ///
137    /// One sentence, in the engine, so the CLI and the GUI cannot drift into
138    /// describing the same measurement differently (RFC 09 §5.1 O7).
139    pub fn caveat(&self) -> String {
140        let clock = match (self.self_stamped.is_some(), self.foreign.is_some()) {
141            (true, false) => "the publisher's own HLC",
142            (false, true) => "an HLC stamped in transit, not the publisher's",
143            (true, true) => "two different clocks, kept apart below",
144            (false, false) => "an HLC whose stamper did not identify itself",
145        };
146        let mut note = format!(
147            "arrival wall-clock − {clock}: observed *skewed* latency — it contains \
148             clock skew, and negative values are the skew evidence, not an error \
149             (RFC 09 §5.1)"
150        );
151        if !self.stampers.is_empty() {
152            note.push_str(&format!("\nstamped by: {}", self.stampers.join(", ")));
153            if self.stampers_dropped > 0 {
154                note.push_str(&format!(" (+{} more not retained)", self.stampers_dropped));
155            }
156        }
157        note
158    }
159}