Skip to main content

hotl_engine/
ledger.rs

1//! LoopLedger — per-sample phase stamps and a flushable summary (§S1).
2//!
3//! Owned solely by the turn task: nothing else ever touches a `Turn`'s
4//! ledger, so plain `&mut self` methods are enough — no locking, no atomics.
5//! Fixed capacity, no heap allocation per stamp: the instrument must not
6//! perturb the loop it measures.
7
8use std::time::Instant;
9
10/// The ten boundary events a turn's sample passes through. Declaration order
11/// is the critical-path order the design doc's per-phase deltas price
12/// (`SnapshotReady−BoundaryStart` prices the snapshot transport, etc.) — see
13/// [`DELTA_PAIRS`]. A doom-loop abort or a failed sample can legitimately
14/// skip phases past the point of failure; see [`LoopLedger::stamp`].
15///
16/// `BatchProposed`/`WatermarkDurable` are the one place the critical path
17/// forks: a sample proposes once (the model's own assistant+usage entry) or
18/// twice (that entry, then — only when a tool phase runs — the tool-results
19/// entry). These two phases use [`LoopLedger::restamp`] (last-wins, not
20/// `stamp`'s first-wins), so their recorded position always tracks the
21/// sample's REAL final commit — after `ToolsJoined` when a tool phase ran,
22/// otherwise right after `LastBlockEnd`. Every other phase is written
23/// exactly once by construction, so `stamp`'s first-wins rule is what
24/// applies to them.
25///
26/// **Declaration order is stamp order, not wall order** — and since S2c's
27/// optimistic dispatch those two genuinely diverge. A sample whose request
28/// was dispatched at the *previous* boundary already had bytes on the wire
29/// before this sample's `BoundaryStart`: its true first byte can precede the
30/// previous sample's `WatermarkDurable`, which is the overlap the mechanism
31/// exists for, not a measurement error. Every stamp is taken when the turn
32/// **observes** the event, so the recorded sequence stays monotone and the
33/// summary needs no special case; what a reader must not do is treat
34/// `RequestBuilt→FirstByte` on an adopted sample as a round-trip
35/// measurement. It is the time to *notice* a byte that was already there,
36/// and it is legitimately near zero. ([`width`] saturates either way, so an
37/// out-of-order pair reports `0` rather than wrapping.)
38#[repr(u8)]
39#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
40pub enum Phase {
41    BoundaryStart,
42    SnapshotReady,
43    RequestBuilt,
44    FirstByte,
45    LastBlockEnd,
46    ToolsSpawned,
47    ToolsJoined,
48    BatchProposed,
49    WatermarkDurable,
50    BoundaryEnd,
51}
52
53/// Width of the raw-counter array a sample occupies — one slot per [`Phase`].
54pub const PHASE_COUNT: usize = 10;
55
56/// Fixed sample capacity: past this, `start_sample` keeps dropping new
57/// samples instead of growing (never reallocates, never panics).
58const CAPACITY: usize = 64;
59
60/// Consecutive phase pairs along the critical path, in declaration order —
61/// each pairing is one attributable stage of the loop. `(ToolsJoined,
62/// BatchProposed)` is zero-width for a sample with no tool phase (both
63/// `ToolsSpawned`/`ToolsJoined` are legitimately absent) — that is the
64/// zero-width-when-absent rule in [`width`], not a measurement gap. For a
65/// sample that DID run a tool phase, `restamp`'s last-wins semantics on
66/// `BatchProposed` (see [`Phase`]) guarantee this pairing prices real time:
67/// the gap between the tools finishing and the tool-results propose call.
68const DELTA_PAIRS: [(Phase, Phase); PHASE_COUNT - 1] = [
69    (Phase::BoundaryStart, Phase::SnapshotReady),
70    (Phase::SnapshotReady, Phase::RequestBuilt),
71    (Phase::RequestBuilt, Phase::FirstByte),
72    (Phase::FirstByte, Phase::LastBlockEnd),
73    (Phase::LastBlockEnd, Phase::ToolsSpawned),
74    (Phase::ToolsSpawned, Phase::ToolsJoined),
75    (Phase::ToolsJoined, Phase::BatchProposed),
76    (Phase::BatchProposed, Phase::WatermarkDurable),
77    (Phase::WatermarkDurable, Phase::BoundaryEnd),
78];
79
80/// Raw monotonic nanoseconds since this process's first call — comparable
81/// only to other readings from the same process run, never persisted or
82/// compared across processes. Isolated behind one fn so a TSC source
83/// (quanta) can replace `Instant` later without touching any call site.
84fn now_nanos() -> u64 {
85    static START: std::sync::OnceLock<Instant> = std::sync::OnceLock::new();
86    let start = START.get_or_init(Instant::now);
87    start.elapsed().as_nanos() as u64
88}
89
90/// The span between two stamps, or zero-width if either endpoint is absent
91/// (`0` — a phase legitimately never stamped, e.g. no tool phase this
92/// sample). Never underflows: an out-of-order pair of stamps reports `0`
93/// rather than panicking or wrapping.
94fn width(from: u64, to: u64) -> u64 {
95    if from == 0 || to == 0 {
96        0
97    } else {
98        to.saturating_sub(from)
99    }
100}
101
102/// `overhead = (BoundaryEnd−BoundaryStart) − stream − tools`, per the design
103/// doc's metric — `stream = LastBlockEnd−FirstByte`, `tools =
104/// ToolsJoined−ToolsSpawned`, both zero-width when absent.
105fn sample_overhead(raw: &[u64; PHASE_COUNT]) -> u64 {
106    let total = width(
107        raw[Phase::BoundaryStart as usize],
108        raw[Phase::BoundaryEnd as usize],
109    );
110    let stream = width(
111        raw[Phase::FirstByte as usize],
112        raw[Phase::LastBlockEnd as usize],
113    );
114    let tools = width(
115        raw[Phase::ToolsSpawned as usize],
116        raw[Phase::ToolsJoined as usize],
117    );
118    total.saturating_sub(stream).saturating_sub(tools)
119}
120
121/// Nearest-rank percentile (`p` in `0..=100`) over an ascending-sorted slice;
122/// `0` on an empty slice. Advisory statistics only (design doc §S1) — no
123/// interpolation, deterministic on ties.
124fn percentile(sorted: &[u64], p: u64) -> u64 {
125    if sorted.is_empty() {
126        return 0;
127    }
128    let n = sorted.len() as u64;
129    // Textbook nearest-rank: rank = ceil(p/100 * n), clamped to [1, n],
130    // 1-indexed.
131    let rank = (p * n).div_ceil(100);
132    let idx = rank.clamp(1, n) - 1;
133    sorted[idx as usize]
134}
135
136/// One attributable stage's timing across every sample in a flush.
137#[derive(Debug, Clone, serde::Serialize)]
138pub struct PhaseDeltaSummary {
139    pub from: Phase,
140    pub to: Phase,
141    pub p50_ns: u64,
142    pub p99_ns: u64,
143}
144
145/// What gets flushed at `TurnFinished` (as an [`crate::EngineEvent::LedgerReport`],
146/// never the canon log) and what a CI regression gate consumes.
147#[derive(Debug, Clone, serde::Serialize)]
148pub struct LedgerSummary {
149    /// Samples actually taken this turn, capped at 64 — past the cap this is
150    /// `64`, not the true (dropped) count.
151    pub sample_count: usize,
152    pub overhead_p50_ns: u64,
153    pub overhead_p99_ns: u64,
154    pub phase_deltas: Vec<PhaseDeltaSummary>,
155    pub max_rss_bytes: u64,
156    /// Raw per-sample phase stamps (nanoseconds, process-relative; `0` =
157    /// absent), in [`Phase`] declaration order — the material the p50/p99
158    /// fields above are computed from, exposed directly so a consumer isn't
159    /// limited to this module's own aggregation choices.
160    pub samples: Vec<[u64; PHASE_COUNT]>,
161}
162
163/// Fixed-capacity, single-writer store of raw per-sample phase stamps —
164/// owned by one `Turn` for its whole lifetime.
165pub struct LoopLedger {
166    samples: [[u64; PHASE_COUNT]; CAPACITY],
167    /// Samples started so far, capped at `CAPACITY`.
168    len: usize,
169    /// Slot `stamp` writes into; `CAPACITY` is the overflow sentinel (every
170    /// `stamp` becomes a no-op) once the cap is hit.
171    current: usize,
172}
173
174impl Default for LoopLedger {
175    fn default() -> Self {
176        Self::new()
177    }
178}
179
180impl LoopLedger {
181    pub fn new() -> Self {
182        Self {
183            samples: [[0; PHASE_COUNT]; CAPACITY],
184            len: 0,
185            current: CAPACITY,
186        }
187    }
188
189    /// Advance to the next sample slot. Silently drops past `CAPACITY` —
190    /// never reallocates, never panics: a pathological turn with hundreds of
191    /// samples must not turn the instrument itself into a cost.
192    pub fn start_sample(&mut self) {
193        if self.len < CAPACITY {
194            self.current = self.len;
195            self.len += 1;
196        } else {
197            self.current = CAPACITY;
198        }
199    }
200
201    /// Record `phase` into the current slot. First stamp wins — a phase
202    /// stamped twice keeps its earliest value. A no-op before the first
203    /// `start_sample()` call or past the capacity cap.
204    pub fn stamp(&mut self, phase: Phase) {
205        if self.current >= CAPACITY {
206            return;
207        }
208        let slot = &mut self.samples[self.current][phase as usize];
209        if *slot == 0 {
210            *slot = now_nanos();
211        }
212    }
213
214    /// Record `phase` into the current slot, always overwriting — the "last
215    /// wins" counterpart to [`stamp`](Self::stamp). `BatchProposed`/
216    /// `WatermarkDurable` are the only phases that use this: a sample can
217    /// legitimately propose twice (the model's own assistant+usage entry,
218    /// then — only when a tool phase runs — the tool-results entry), and the
219    /// *last* commit is the one actually on the sample's critical path, not
220    /// the first. Every other phase is stamped once by construction, so
221    /// `stamp`'s first-wins rule is a no-op difference for them; this method
222    /// exists so the two propose-adjacent phases don't have to lie about
223    /// which commit they priced. A no-op before the first `start_sample()`
224    /// call or past the capacity cap.
225    pub fn restamp(&mut self, phase: Phase) {
226        if self.current >= CAPACITY {
227            return;
228        }
229        self.samples[self.current][phase as usize] = now_nanos();
230    }
231
232    /// Compute the flushable summary. `max_rss_bytes` is threaded in rather
233    /// than read here so the pure aggregation stays independent of the
234    /// platform syscall (see [`max_rss_bytes`] below).
235    pub fn summary(&self, max_rss_bytes: u64) -> LedgerSummary {
236        let used = &self.samples[..self.len];
237        let mut overheads: Vec<u64> = used.iter().map(sample_overhead).collect();
238        overheads.sort_unstable();
239        let phase_deltas = DELTA_PAIRS
240            .iter()
241            .map(|&(from, to)| {
242                let mut deltas: Vec<u64> = used
243                    .iter()
244                    .map(|s| width(s[from as usize], s[to as usize]))
245                    .collect();
246                deltas.sort_unstable();
247                PhaseDeltaSummary {
248                    from,
249                    to,
250                    p50_ns: percentile(&deltas, 50),
251                    p99_ns: percentile(&deltas, 99),
252                }
253            })
254            .collect();
255        LedgerSummary {
256            sample_count: self.len,
257            overhead_p50_ns: percentile(&overheads, 50),
258            overhead_p99_ns: percentile(&overheads, 99),
259            phase_deltas,
260            max_rss_bytes,
261            samples: used.to_vec(),
262        }
263    }
264}
265
266/// Max resident set size in bytes (`getrusage(RUSAGE_SELF).ru_maxrss`,
267/// normalized — macOS reports bytes, Linux/BSD report kilobytes). `0` if the
268/// syscall fails; a broken RSS reading is never a reason to fail the flush.
269pub fn max_rss_bytes() -> u64 {
270    let mut usage: libc::rusage = unsafe { std::mem::zeroed() };
271    if unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut usage) } != 0 {
272        return 0;
273    }
274    normalize_rss(usage.ru_maxrss.max(0) as u64)
275}
276
277#[cfg(target_os = "macos")]
278fn normalize_rss(raw: u64) -> u64 {
279    raw
280}
281
282#[cfg(not(target_os = "macos"))]
283fn normalize_rss(raw: u64) -> u64 {
284    raw * 1024
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    fn raw_with(pairs: &[(Phase, u64)]) -> [u64; PHASE_COUNT] {
292        let mut raw = [0u64; PHASE_COUNT];
293        for &(phase, ns) in pairs {
294            raw[phase as usize] = ns;
295        }
296        raw
297    }
298
299    #[test]
300    fn sample_overhead_subtracts_stream_and_tools() {
301        let raw = raw_with(&[
302            (Phase::BoundaryStart, 1_000),
303            (Phase::SnapshotReady, 1_100),
304            (Phase::RequestBuilt, 1_200),
305            (Phase::FirstByte, 1_300),
306            (Phase::LastBlockEnd, 1_800), // stream = 500
307            (Phase::ToolsSpawned, 1_850),
308            (Phase::ToolsJoined, 2_050), // tools = 200
309            (Phase::BatchProposed, 2_100),
310            (Phase::WatermarkDurable, 2_150),
311            (Phase::BoundaryEnd, 2_200), // total = 1200
312        ]);
313        // overhead = total(1200) - stream(500) - tools(200)
314        assert_eq!(sample_overhead(&raw), 500);
315    }
316
317    #[test]
318    fn sample_overhead_treats_absent_phases_as_zero_width() {
319        // No tool phase this sample, and (contrived) no stream phase either —
320        // both must contribute zero, not an underflow or a bogus value
321        // computed against whatever happens to sit at index 0.
322        let raw = raw_with(&[(Phase::BoundaryStart, 1_000), (Phase::BoundaryEnd, 1_500)]);
323        assert_eq!(sample_overhead(&raw), 500);
324    }
325
326    #[test]
327    fn width_never_underflows_on_an_out_of_order_pair() {
328        assert_eq!(width(500, 100), 0);
329    }
330
331    #[test]
332    fn percentile_is_nearest_rank_on_sorted_input() {
333        let sorted = [10u64, 20, 30, 40, 50];
334        assert_eq!(percentile(&sorted, 50), 30);
335        assert_eq!(percentile(&sorted, 99), 50);
336        assert_eq!(percentile(&sorted, 0), 10);
337    }
338
339    #[test]
340    fn percentile_of_empty_input_is_zero() {
341        assert_eq!(percentile(&[], 50), 0);
342    }
343
344    #[test]
345    fn start_sample_then_stamp_targets_a_fresh_slot_each_time() {
346        let mut ledger = LoopLedger::new();
347        ledger.start_sample();
348        ledger.stamp(Phase::BoundaryStart);
349        ledger.start_sample();
350        ledger.stamp(Phase::BoundaryStart);
351        let report = ledger.summary(0);
352        assert_eq!(report.sample_count, 2);
353        assert_ne!(
354            report.samples[0][Phase::BoundaryStart as usize],
355            0,
356            "sample 0 must have its own stamp"
357        );
358        assert_ne!(
359            report.samples[1][Phase::BoundaryStart as usize],
360            0,
361            "sample 1 must have its own stamp"
362        );
363    }
364
365    #[test]
366    fn stamp_before_any_start_sample_is_a_noop() {
367        let mut ledger = LoopLedger::new();
368        ledger.stamp(Phase::BoundaryStart); // no start_sample() yet
369        let report = ledger.summary(0);
370        assert_eq!(report.sample_count, 0, "no sample was ever started");
371    }
372
373    #[test]
374    fn first_stamp_wins() {
375        let mut ledger = LoopLedger::new();
376        ledger.start_sample();
377        ledger.stamp(Phase::BoundaryStart);
378        let first = ledger.summary(0).samples[0][Phase::BoundaryStart as usize];
379        // A real (non-zero) wall-clock gap so a bug that overwrites would be
380        // visible even on a very fast machine.
381        std::thread::sleep(std::time::Duration::from_micros(50));
382        ledger.stamp(Phase::BoundaryStart);
383        let second = ledger.summary(0).samples[0][Phase::BoundaryStart as usize];
384        assert_eq!(
385            first, second,
386            "the second stamp must not overwrite the first"
387        );
388    }
389
390    #[test]
391    fn restamp_overwrites_with_the_latest_value() {
392        // `BatchProposed`/`WatermarkDurable` (turn.rs) can legitimately be
393        // proposed twice in one sample — the model's own assistant+usage
394        // entry, then (when a tool phase runs) the tool-results entry. The
395        // *last* commit is the one on the sample's real critical path, so
396        // `restamp` — unlike `stamp` — always overwrites.
397        let mut ledger = LoopLedger::new();
398        ledger.start_sample();
399        ledger.stamp(Phase::BatchProposed);
400        let first = ledger.summary(0).samples[0][Phase::BatchProposed as usize];
401        std::thread::sleep(std::time::Duration::from_micros(50));
402        ledger.restamp(Phase::BatchProposed);
403        let second = ledger.summary(0).samples[0][Phase::BatchProposed as usize];
404        assert!(
405            second > first,
406            "restamp must overwrite with a later value, got first={first} second={second}"
407        );
408    }
409
410    #[test]
411    fn restamp_before_any_start_sample_is_a_noop() {
412        let mut ledger = LoopLedger::new();
413        ledger.restamp(Phase::BatchProposed); // no start_sample() yet
414        let report = ledger.summary(0);
415        assert_eq!(report.sample_count, 0, "no sample was ever started");
416    }
417
418    #[test]
419    fn a_phase_may_be_legitimately_absent() {
420        let mut ledger = LoopLedger::new();
421        ledger.start_sample();
422        ledger.stamp(Phase::BoundaryStart);
423        ledger.stamp(Phase::BoundaryEnd);
424        // No tool phase this sample (e.g. a Done reply with no tool calls).
425        let report = ledger.summary(0);
426        assert_eq!(report.samples[0][Phase::ToolsSpawned as usize], 0);
427        assert_eq!(report.samples[0][Phase::ToolsJoined as usize], 0);
428    }
429
430    #[test]
431    fn overflow_past_capacity_is_safe_and_drops() {
432        let mut ledger = LoopLedger::new();
433        for _ in 0..(CAPACITY + 36) {
434            ledger.start_sample();
435            ledger.stamp(Phase::BoundaryStart);
436            ledger.stamp(Phase::BoundaryEnd);
437        }
438        let report = ledger.summary(0);
439        assert_eq!(
440            report.sample_count, CAPACITY,
441            "overflow must cap, not grow, the stored sample count"
442        );
443        assert_eq!(report.samples.len(), CAPACITY);
444    }
445
446    #[test]
447    fn summary_reports_sample_count_and_passes_through_max_rss() {
448        let mut ledger = LoopLedger::new();
449        ledger.start_sample();
450        ledger.stamp(Phase::BoundaryStart);
451        ledger.stamp(Phase::BoundaryEnd);
452        let report = ledger.summary(123_456);
453        assert_eq!(report.sample_count, 1);
454        assert_eq!(report.max_rss_bytes, 123_456);
455    }
456
457    #[test]
458    fn summary_overhead_percentiles_span_every_sample() {
459        // Two samples with distinct overhead (500ns and 100ns via
460        // hand-built raw counters), asserting the p50/p99 read off the
461        // smaller/larger value respectively — nearest-rank over n=2 puts p50
462        // at index 0 (sorted ascending) and p99 at index 1.
463        // `BoundaryStart` must be non-zero — `0` is the "absent" sentinel
464        // `width` treats as zero-width, which would defeat this test.
465        let mut samples = [[0u64; PHASE_COUNT]; CAPACITY];
466        samples[0] = raw_with(&[(Phase::BoundaryStart, 1_000), (Phase::BoundaryEnd, 1_100)]);
467        samples[1] = raw_with(&[(Phase::BoundaryStart, 1_000), (Phase::BoundaryEnd, 1_500)]);
468        let ledger = LoopLedger {
469            samples,
470            len: 2,
471            current: 1,
472        };
473        let report = ledger.summary(0);
474        assert_eq!(report.overhead_p50_ns, 100);
475        assert_eq!(report.overhead_p99_ns, 500);
476    }
477
478    #[test]
479    fn phase_deltas_cover_every_consecutive_pair_in_declaration_order() {
480        let ledger = LoopLedger::new();
481        let report = ledger.summary(0);
482        assert_eq!(report.phase_deltas.len(), PHASE_COUNT - 1);
483        assert_eq!(report.phase_deltas[0].from, Phase::BoundaryStart);
484        assert_eq!(report.phase_deltas[0].to, Phase::SnapshotReady);
485        assert_eq!(report.phase_deltas[PHASE_COUNT - 2].to, Phase::BoundaryEnd);
486    }
487
488    #[test]
489    fn now_nanos_is_monotonically_nondecreasing() {
490        let a = now_nanos();
491        let b = now_nanos();
492        assert!(b >= a);
493    }
494
495    #[test]
496    fn max_rss_bytes_returns_a_plausible_reading() {
497        // A real syscall on a real running process: some resident memory has
498        // always been touched by the time a test binary is running.
499        assert!(max_rss_bytes() > 0);
500    }
501}