From a3be8f0977d9b2457c7772fc7045db756ad0b360 Mon Sep 17 00:00:00 2001 From: "Claude (sandbox)" Date: Mon, 13 Jul 2026 12:07:19 +0000 Subject: [PATCH] =?UTF-8?q?feat(causal):=20ledger=20audit=20=E2=80=94=20de?= =?UTF-8?q?compose=20the=20@50=20injection=20deficit=20(RFC=20007)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measure-only counters for the deficit hunt (~23ms short per 700ms window at 50% on the bottleneck site; superlinear vs 25%). Nothing here changes injection or absorption; the sweep decides the fix. Buckets, windowed per cell into new ExperimentResult fields (audit snapshot taken at end() — spin/attribution freeze there, forgiveness does not): - spin_absorbed / park_forgiven: where owed delay actually went. Spin during a 0% cell is the baseline-contamination signature — leftover debt from a prior window being paid in a later one (checks gate on the experiment word, so cooldowns pay nothing and debt carries over). - drop_park / drop_yield (+counts): the deschedule path flushes no sample tail — an in-target-site park or yield silently loses [last sample -> now]; on_resume re-arms before the actor runs again. New on_deschedule hook in all three intent arms (real park; explicit/ slice-expiry yield; requeued park counts as yield — it never blocked). Slice-expiry yields sample at the descheduling checkpoint, so a fat yield bucket points at explicit yield_now or requeued parks. - discard_overmax (+count, in would-be delta terms so columns compare against injected_cycles) / discard_unarmed: the attribute() clamps, previously silent. LedgerCounters + ledger_counters() expose cumulative totals (tests, run-level prints); render_ledger_audit() is the per-cell companion to render_summary, which stays byte-identical (pinned). ExperimentResult now derives Default so literals survive future audit-field growth. Tests: +7 (spin counted, forgiveness counted, in-site park drop, in-site yield drop, overmax discard, 0%-window leftover absorption — synthesized deterministically via inject_delay_cycles_for_test with no experiment active — and the audit render). 22/22 causal. --- src/causal.rs | 218 ++++++++++++++++++++++++++++++++++++++-- src/runtime.rs | 15 ++- tests/causal.rs | 257 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 482 insertions(+), 8 deletions(-) diff --git a/src/causal.rs b/src/causal.rs index 751ed9b..30d074d 100644 --- a/src/causal.rs +++ b/src/causal.rs @@ -79,6 +79,31 @@ mod inner { /// ~3ms at 3 GHz. const MAX_SPIN_PER_VISIT: u64 = 10_000_000; + // ----------------------------------------------------------------------- + // Ledger audit (RFC 007 deficit hunt): where injected delay is born, + // paid, and forgiven — and where would-be attribution is silently lost + // (deschedule tails, clamp discards). Monotone Relaxed totals, read via + // `ledger_counters()`; `run_experiments` windows them into + // `ExperimentResult`. Measure-only: nothing here changes injection or + // absorption behaviour. + // ----------------------------------------------------------------------- + + /// Cycles bystanders actually spun to pay down the global ledger. + static SPIN_ABSORBED_CYCLES: AtomicU64 = AtomicU64::new(0); + /// Cycles waived at wake after a real park (the blocked-thread rule). + static PARK_FORGIVEN_CYCLES: AtomicU64 = AtomicU64::new(0); + /// Would-be attribution lost when the target-site actor parks mid-site. + static DROP_PARK_CYCLES: AtomicU64 = AtomicU64::new(0); + static DROP_PARK_N: AtomicU64 = AtomicU64::new(0); + /// Same loss at yields (explicit, slice-expiry, or a park that requeued). + static DROP_YIELD_CYCLES: AtomicU64 = AtomicU64::new(0); + static DROP_YIELD_N: AtomicU64 = AtomicU64::new(0); + /// Samples discarded by the TSC-weirdness clamp, in would-be delta terms. + static DISCARD_OVERMAX_CYCLES: AtomicU64 = AtomicU64::new(0); + static DISCARD_OVERMAX_N: AtomicU64 = AtomicU64::new(0); + /// In-site samples dropped because the thread's clock was unarmed. + static DISCARD_UNARMED_N: AtomicU64 = AtomicU64::new(0); + fn sites() -> &'static Mutex> { SITES.get_or_init(|| Mutex::new(Vec::new())) } @@ -256,7 +281,9 @@ mod inner { // and credit ourselves the same amount — the credited gap *is* // the virtual speedup. if last == 0 { - return; // unarmed clock: no interval to attribute + // Unarmed clock: no interval to attribute — count the loss. + DISCARD_UNARMED_N.fetch_add(1, Ordering::Relaxed); + return; } // SAFETY: `slot` is the on-CPU actor's slot (checked non-null // above); see `check_cancelled` for the lifetime argument. @@ -276,6 +303,7 @@ mod inner { while preempt::rdtsc().saturating_sub(start) < spin { core::hint::spin_loop(); } + SPIN_ABSORBED_CYCLES.fetch_add(spin, Ordering::Relaxed); unsafe { (*slot).set_causal_delay(mine.wrapping_add(spin)) }; preempt::extend_timeslice(spin); // The spin is not part of the next sample interval either. @@ -293,7 +321,16 @@ mod inner { /// SAFETY: `slot` must point at the on-CPU actor's slot (the /// `check_cancelled` lifetime argument). unsafe fn attribute(slot: *const crate::runtime::Slot, interval: u64, pct: u64) { - if interval == 0 || interval > MAX_SAMPLE_CYCLES { + if interval == 0 { + return; // now == last: nothing to attribute, nothing lost + } + if interval > MAX_SAMPLE_CYCLES { + // TSC-weirdness clamp: the sample is discarded, not the run. + // Count the loss in would-be delta terms so the audit's columns + // compare directly against `injected_cycles`. + DISCARD_OVERMAX_N.fetch_add(1, Ordering::Relaxed); + DISCARD_OVERMAX_CYCLES + .fetch_add(interval.saturating_mul(pct) / 100, Ordering::Relaxed); return; } let delta = interval.saturating_mul(pct) / 100; @@ -327,10 +364,14 @@ mod inner { if old == target && new != target { let now = preempt::rdtsc(); let last = LAST_SAMPLE_TSC.with(|c| c.replace(now)); - if last != 0 && pct > 0 { - // SAFETY: forwarded from the guard, which holds the on-CPU - // actor's slot for its whole life (see `SiteGuard::slot`). - unsafe { attribute(slot, now.saturating_sub(last), pct) }; + if pct > 0 { + if last != 0 { + // SAFETY: forwarded from the guard, which holds the on-CPU + // actor's slot for its whole life (see `SiteGuard::slot`). + unsafe { attribute(slot, now.saturating_sub(last), pct) }; + } else { + DISCARD_UNARMED_N.fetch_add(1, Ordering::Relaxed); + } } } else if new == target && old != target { LAST_SAMPLE_TSC.with(|c| c.set(preempt::rdtsc())); @@ -352,13 +393,55 @@ mod inner { pub(crate) fn on_resume(slot: &crate::runtime::Slot) { if slot.take_causal_parked() { let global = GLOBAL_DELAY.load(Ordering::Relaxed); - if slot.causal_delay() < global { + let mine = slot.causal_delay(); + if mine < global { + PARK_FORGIVEN_CYCLES.fetch_add(global - mine, Ordering::Relaxed); slot.set_causal_delay(global); } } LAST_SAMPLE_TSC.with(|c| c.set(preempt::rdtsc())); } + /// Deschedule-path hook (scheduler side, same OS thread the actor just + /// ran on). If an experiment is live and the departing actor sits in the + /// target site, the sample tail `[last sample -> now]` is about to be + /// lost: nothing flushes it here, and `on_resume` re-arms the clock + /// before the actor runs again. Measure-only (RFC 007 deficit hunt) — + /// tally the would-be attribution into the park/yield drop buckets and + /// leave behaviour untouched. The interval is capped at + /// MAX_SAMPLE_CYCLES: past that the flush would have discarded it anyway + /// (counted separately). `now` includes the few hundred ns of scheduler + /// bookkeeping since the actor actually stopped — an acceptable + /// overcount for a diagnostic. + /// + /// Slice-expiry yields sample at the same checkpoint that deschedules + /// them, so their tails are ~zero by construction; a fat yield bucket + /// therefore points at explicit `yield_now` calls or requeued parks. + pub(crate) fn on_deschedule(slot: &crate::runtime::Slot, real_park: bool) { + let exp = EXPERIMENT.load(Ordering::Relaxed); + if exp == 0 { + return; + } + let target = (exp >> 32) as u32; + let pct = exp & 0xffff_ffff; + if pct == 0 || slot.causal_site() != target { + return; + } + let last = LAST_SAMPLE_TSC.with(|c| c.get()); + if last == 0 { + return; + } + let interval = preempt::rdtsc().saturating_sub(last).min(MAX_SAMPLE_CYCLES); + let would_be = interval.saturating_mul(pct) / 100; + if real_park { + DROP_PARK_N.fetch_add(1, Ordering::Relaxed); + DROP_PARK_CYCLES.fetch_add(would_be, Ordering::Relaxed); + } else { + DROP_YIELD_N.fetch_add(1, Ordering::Relaxed); + DROP_YIELD_CYCLES.fetch_add(would_be, Ordering::Relaxed); + } + } + // ----------------------------------------------------------------------- // Experiments // ----------------------------------------------------------------------- @@ -376,6 +459,63 @@ mod inner { GLOBAL_DELAY.load(Ordering::Relaxed) } + /// Cumulative ledger-audit totals since startup (RFC 007 deficit hunt). + /// All monotone; window a span by snapshotting before/after and taking + /// `delta_since`. Cycle fields are in would-be-injected delta terms so + /// they compare directly against `injected_cycles`. + #[derive(Clone, Copy, Debug, Default)] + pub struct LedgerCounters { + pub spin_absorbed_cycles: u64, + pub park_forgiven_cycles: u64, + pub drop_park_cycles: u64, + pub drop_park_n: u64, + pub drop_yield_cycles: u64, + pub drop_yield_n: u64, + pub discard_overmax_cycles: u64, + pub discard_overmax_n: u64, + pub discard_unarmed_n: u64, + } + + impl LedgerCounters { + /// Field-wise difference against an earlier snapshot. + pub fn delta_since(&self, before: &LedgerCounters) -> LedgerCounters { + LedgerCounters { + spin_absorbed_cycles: self + .spin_absorbed_cycles + .saturating_sub(before.spin_absorbed_cycles), + park_forgiven_cycles: self + .park_forgiven_cycles + .saturating_sub(before.park_forgiven_cycles), + drop_park_cycles: self.drop_park_cycles.saturating_sub(before.drop_park_cycles), + drop_park_n: self.drop_park_n.saturating_sub(before.drop_park_n), + drop_yield_cycles: self + .drop_yield_cycles + .saturating_sub(before.drop_yield_cycles), + drop_yield_n: self.drop_yield_n.saturating_sub(before.drop_yield_n), + discard_overmax_cycles: self + .discard_overmax_cycles + .saturating_sub(before.discard_overmax_cycles), + discard_overmax_n: self.discard_overmax_n.saturating_sub(before.discard_overmax_n), + discard_unarmed_n: self.discard_unarmed_n.saturating_sub(before.discard_unarmed_n), + } + } + } + + /// Snapshot the cumulative audit counters. + pub fn ledger_counters() -> LedgerCounters { + LedgerCounters { + spin_absorbed_cycles: SPIN_ABSORBED_CYCLES.load(Ordering::Relaxed), + park_forgiven_cycles: PARK_FORGIVEN_CYCLES.load(Ordering::Relaxed), + drop_park_cycles: DROP_PARK_CYCLES.load(Ordering::Relaxed), + drop_park_n: DROP_PARK_N.load(Ordering::Relaxed), + drop_yield_cycles: DROP_YIELD_CYCLES.load(Ordering::Relaxed), + drop_yield_n: DROP_YIELD_N.load(Ordering::Relaxed), + discard_overmax_cycles: DISCARD_OVERMAX_CYCLES.load(Ordering::Relaxed), + discard_overmax_n: DISCARD_OVERMAX_N.load(Ordering::Relaxed), + discard_unarmed_n: DISCARD_UNARMED_N.load(Ordering::Relaxed), + } + } + /// Absorbed-delay ledger of the on-CPU actor (testing/introspection). pub fn my_absorbed_delay_cycles() -> u64 { let slot = preempt::current_slot_ptr(); @@ -430,6 +570,7 @@ mod inner { } /// One completed experiment cell. + #[derive(Default)] pub struct ExperimentResult { pub site: String, pub speedup_pct: u32, @@ -438,6 +579,19 @@ mod inner { pub deltas: Vec<(String, u64)>, /// Virtual delay injected during the window (cycles). pub injected_cycles: u64, + // Ledger-audit deltas over the window (RFC 007 deficit hunt); see + // `LedgerCounters` for field semantics. `spin_absorbed_cycles > 0` + // in a 0% cell means the window paid debt left over from an earlier + // one — the baseline-contamination signature. + pub spin_absorbed_cycles: u64, + pub park_forgiven_cycles: u64, + pub drop_park_cycles: u64, + pub drop_park_n: u64, + pub drop_yield_cycles: u64, + pub drop_yield_n: u64, + pub discard_overmax_cycles: u64, + pub discard_overmax_n: u64, + pub discard_unarmed_n: u64, } /// Run the plan synchronously on the calling (OS) thread: for every @@ -479,10 +633,16 @@ mod inner { for &pct in &plan.speedups_pct { let before = progress_snapshot(); let injected_before = global_delay_cycles(); + let audit_before = ledger_counters(); let t0 = Instant::now(); begin(*sid, pct); controller_sleep(plan.experiment); end(); + // Snapshot immediately: injection and spin freeze at `end()` + // (checks gate on the experiment word), but forgiveness does + // not — a later snapshot would leak cooldown wakes into the + // window. + let audit = ledger_counters().delta_since(&audit_before); let elapsed = t0.elapsed(); let after = progress_snapshot(); let deltas = after @@ -502,6 +662,15 @@ mod inner { duration: elapsed, deltas, injected_cycles: global_delay_cycles() - injected_before, + spin_absorbed_cycles: audit.spin_absorbed_cycles, + park_forgiven_cycles: audit.park_forgiven_cycles, + drop_park_cycles: audit.drop_park_cycles, + drop_park_n: audit.drop_park_n, + drop_yield_cycles: audit.drop_yield_cycles, + drop_yield_n: audit.drop_yield_n, + discard_overmax_cycles: audit.discard_overmax_cycles, + discard_overmax_n: audit.discard_overmax_n, + discard_unarmed_n: audit.discard_unarmed_n, }); controller_sleep(plan.cooldown); } @@ -626,6 +795,41 @@ mod inner { s } + /// Ledger-audit companion to `render_summary` (RFC 007 deficit hunt): + /// per cell, where the window's virtual delay went — born (injected), + /// paid (absorbed), waived (forgiven at wake) — and the attribution the + /// sampler lost: tails dropped at parks/yields inside the target site, + /// plus clamp discards. Cycle columns in ms at the calibrated TSC rate. + /// `absorbed` above `injected` in a cell (0% especially) means it paid + /// debt left over from earlier windows. + pub fn render_ledger_audit(results: &[ExperimentResult]) -> String { + use std::fmt::Write; + let hz = tsc_hz(); + let ms = |c: u64| c as f64 / hz * 1e3; + let mut s = String::new(); + let _ = writeln!(s, "== smarm causal ledger audit =="); + for r in results { + let _ = writeln!( + s, + "site {:<22} @{:>2}% injected {:>7.1}ms absorbed {:>7.1}ms forgiven {:>7.1}ms \ + drop park {:>6.2}ms/{:<4} yield {:>6.2}ms/{:<4} discard >max {:>6.2}ms/{:<3} unarmed {}", + r.site, + r.speedup_pct, + ms(r.injected_cycles), + ms(r.spin_absorbed_cycles), + ms(r.park_forgiven_cycles), + ms(r.drop_park_cycles), + r.drop_park_n, + ms(r.drop_yield_cycles), + r.drop_yield_n, + ms(r.discard_overmax_cycles), + r.discard_overmax_n, + r.discard_unarmed_n + ); + } + s + } + /// Coz-compatible profile text (`profile.coz`), so Coz's existing plot /// tooling renders our experiments — the RFC's "don't build a UI" call. pub fn render_coz(results: &[ExperimentResult]) -> String { diff --git a/src/runtime.rs b/src/runtime.rs index db1162c..d1cf24f 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -1757,6 +1757,11 @@ fn schedule_loop(inner: &Arc, slot_idx: usize) { let gen = pid.generation(); match intent { YieldIntent::Yield => { + // RFC 007 audit: measure the sample tail this yield drops + // (near-zero for slice-expiry yields, which sample at the + // same checkpoint; fat for explicit yield_now in-site). + #[cfg(feature = "smarm-causal")] + crate::causal::on_deschedule(slot, false); // Running OR RunningNotified → Queued; a notification // arriving mid-run coalesces into the re-queue. crate::te!(crate::trace::Event::Yield(pid)); @@ -1765,6 +1770,10 @@ fn schedule_loop(inner: &Arc, slot_idx: usize) { } YieldIntent::Park => { if slot.word.park_return(gen) { + // RFC 007 audit: an in-site park drops its sample + // tail (nothing flushes it; on_resume re-arms). + #[cfg(feature = "smarm-causal")] + crate::causal::on_deschedule(slot, true); // RFC 007: a real park — the eventual wake forgives // delay accrued while blocked (the instant-wake // re-queue below does NOT: that actor never blocked). @@ -1774,7 +1783,11 @@ fn schedule_loop(inner: &Arc, slot_idx: usize) { } else { // An unpark landed in the prep-to-park window; the // word is back to Queued — re-queue instead of - // parking. The lost-wakeup window, closed. + // parking. The lost-wakeup window, closed: this actor + // never blocked, so its dropped tail counts as a + // yield in the RFC 007 audit. + #[cfg(feature = "smarm-causal")] + crate::causal::on_deschedule(slot, false); crate::te!(crate::trace::Event::UnparkFlagConsumed(pid)); inner.enqueue(pid); } diff --git a/tests/causal.rs b/tests/causal.rs index e857337..fd8044a 100644 --- a/tests/causal.rs +++ b/tests/causal.rs @@ -261,6 +261,7 @@ fn impact_from_synthetic_results() { duration: secs(1.0), deltas: vec![("units".into(), 1000)], injected_cycles: 0, + ..Default::default() }, // Bottleneck at 50%: raw count unchanged, 0.5s injected // -> rate 1000/0.5 = 2x baseline -> +100%. @@ -270,6 +271,7 @@ fn impact_from_synthetic_results() { duration: secs(1.0), deltas: vec![("units".into(), 1000)], injected_cycles: cycles(0.5), + ..Default::default() }, ExperimentResult { site: "overlapped".into(), @@ -277,6 +279,7 @@ fn impact_from_synthetic_results() { duration: secs(1.0), deltas: vec![("units".into(), 1000)], injected_cycles: 0, + ..Default::default() }, // Overlapped at 50%: everyone else (incl. the real bottleneck) // slowed, count drops with the divisor -> ~0% impact. @@ -286,6 +289,7 @@ fn impact_from_synthetic_results() { duration: secs(1.0), deltas: vec![("units".into(), 500)], injected_cycles: cycles(0.5), + ..Default::default() }, ]; let bn = impact_pct(&results, "bottleneck", 50, "units").unwrap(); @@ -652,3 +656,256 @@ fn wall_send_after_cancels() { "cancelled wall send_after delivered" ); } + +// --------------------------------------------------------------------------- +// Ledger audit (RFC 007 deficit hunt) — measure-only counters that decompose +// where injected delay is born, paid, forgiven, and where would-be +// attribution is silently lost. Behaviour under test is accounting only: +// none of these tests assert a change in injection or absorption itself. +// --------------------------------------------------------------------------- + +/// A never-parking bystander's spin absorption lands in the audit's +/// `spin_absorbed_cycles` — the "paid for real" bucket. +#[test] +fn audit_counts_spin_absorption() { + let _s = serial(); + smarm::init(smarm::Config::exact(1)).run(|| { + let stop = Arc::new(AtomicBool::new(false)); + let stop_t = stop.clone(); + let target = smarm::spawn(move || { + let _g = smarm::causal_site!("audit-spin-site"); + while !stop_t.load(Ordering::Relaxed) { + smarm::check!(); + } + }); + let stop_b = stop.clone(); + let bystander = smarm::spawn(move || { + while !stop_b.load(Ordering::Relaxed) { + smarm::check!(); + } + }); + let c0 = smarm::causal::ledger_counters(); + let g0 = smarm::causal::global_delay_cycles(); + smarm::causal::begin_experiment_for_test("audit-spin-site", 50); + smarm::sleep(Duration::from_millis(150)); + smarm::causal::end_experiment_for_test(); + let injected = smarm::causal::global_delay_cycles() - g0; + let c1 = smarm::causal::ledger_counters(); + stop.store(true, Ordering::Relaxed); + target.join().unwrap(); + bystander.join().unwrap(); + + let spun = c1.spin_absorbed_cycles - c0.spin_absorbed_cycles; + assert!(injected > 0, "experiment injected nothing"); + assert!( + spun >= injected / 2, + "audit missed spin absorption: {spun} of {injected} injected" + ); + // Payers are bounded by the debt that exists; 2x headroom covers the + // main actor's incidental checkpoint payments. + assert!( + spun <= injected.saturating_mul(2), + "audit over-counted spin: {spun} of {injected} injected" + ); + }); +} + +/// A wake after a real park waives the sleeper's accrued debt; the waiver is +/// tallied in `park_forgiven_cycles` (Coz's blocked-thread rule, now visible). +#[test] +fn audit_counts_park_forgiveness() { + let _s = serial(); + smarm::init(smarm::Config::exact(1)).run(|| { + let stop = Arc::new(AtomicBool::new(false)); + let stop_t = stop.clone(); + let target = smarm::spawn(move || { + let _g = smarm::causal_site!("audit-forgive-site"); + while !stop_t.load(Ordering::Relaxed) { + smarm::check!(); + } + }); + // Parks across the whole injection window; owes everything at wake. + let sleeper = smarm::spawn(|| smarm::sleep(Duration::from_millis(120))); + + let c0 = smarm::causal::ledger_counters(); + let g0 = smarm::causal::global_delay_cycles(); + smarm::causal::begin_experiment_for_test("audit-forgive-site", 50); + smarm::sleep(Duration::from_millis(60)); + smarm::causal::end_experiment_for_test(); + let injected = smarm::causal::global_delay_cycles() - g0; + sleeper.join().unwrap(); + let c1 = smarm::causal::ledger_counters(); + stop.store(true, Ordering::Relaxed); + target.join().unwrap(); + + let forgiven = c1.park_forgiven_cycles - c0.park_forgiven_cycles; + assert!(injected > 0, "experiment injected nothing"); + assert!( + forgiven >= injected / 2, + "audit missed park forgiveness: {forgiven} of {injected} injected" + ); + }); +} + +/// Parking while *inside* the target site drops the sample tail +/// `[last sample -> park]` — nothing flushes it, and `on_resume` re-arms the +/// clock. The audit counts that loss without changing it. +#[test] +fn audit_counts_park_drop_inside_target_site() { + let _s = serial(); + smarm::init(smarm::Config::exact(1)).run(|| { + smarm::causal::begin_experiment_for_test("audit-park-drop-site", 50); + let c0 = smarm::causal::ledger_counters(); + let h = smarm::spawn(|| { + let _g = smarm::causal_site!("audit-park-drop-site"); + smarm::check!(); // arm an in-site sample interval + smarm::sleep(Duration::from_millis(20)); // real park, in-guard + }); + h.join().unwrap(); + let c1 = smarm::causal::ledger_counters(); + smarm::causal::end_experiment_for_test(); + + assert!( + c1.drop_park_n > c0.drop_park_n, + "in-site park recorded no drop event" + ); + assert!( + c1.drop_park_cycles > c0.drop_park_cycles, + "in-site park drop carried no cycles" + ); + }); +} + +/// An explicit yield inside the target site loses its tail the same way; it +/// lands in the separate yield bucket so park- and yield-losses stay apart. +#[test] +fn audit_counts_yield_drop_inside_target_site() { + let _s = serial(); + smarm::init(smarm::Config::exact(1)).run(|| { + smarm::causal::begin_experiment_for_test("audit-yield-drop-site", 50); + let c0 = smarm::causal::ledger_counters(); + let h = smarm::spawn(|| { + let _g = smarm::causal_site!("audit-yield-drop-site"); + smarm::check!(); // arm an in-site sample interval + smarm::yield_now(); + smarm::check!(); + }); + h.join().unwrap(); + let c1 = smarm::causal::ledger_counters(); + smarm::causal::end_experiment_for_test(); + + assert!( + c1.drop_yield_n > c0.drop_yield_n, + "in-site yield recorded no drop event" + ); + assert!( + c1.drop_yield_cycles > c0.drop_yield_cycles, + "in-site yield drop carried no cycles" + ); + }); +} + +/// A single in-site interval past MAX_SAMPLE_CYCLES is discarded by the +/// TSC-weirdness clamp; the audit counts the discard instead of losing it +/// silently. +#[test] +fn audit_counts_overmax_discard() { + let _s = serial(); + smarm::init(smarm::Config::exact(1)).run(|| { + smarm::causal::begin_experiment_for_test("audit-overmax-site", 50); + let c0 = smarm::causal::ledger_counters(); + let h = smarm::spawn(|| { + let _g = smarm::causal_site!("audit-overmax-site"); + // Stay in-site past MAX_SAMPLE_CYCLES (~33ms at 3GHz) without a + // single checkpoint: no allocs, no check!. The guard-drop flush + // then sees one huge interval and must discard it. + let t0 = std::time::Instant::now(); + while t0.elapsed() < Duration::from_millis(60) { + std::hint::spin_loop(); + } + }); + h.join().unwrap(); + let c1 = smarm::causal::ledger_counters(); + smarm::causal::end_experiment_for_test(); + + assert!( + c1.discard_overmax_n > c0.discard_overmax_n, + "overmax interval recorded no discard event" + ); + assert!( + c1.discard_overmax_cycles > c0.discard_overmax_cycles, + "overmax discard carried no cycles" + ); + }); +} + +/// Debt left outstanding when a window ends is paid during the NEXT active +/// experiment — including a 0% baseline cell, whose bystander-chase branch is +/// live at pct=0. Synthesized deterministically: grow the ledger with no +/// experiment active (nobody pays), then watch a 0% window absorb it. +#[test] +fn audit_zero_pct_window_absorbs_leftover_debt() { + let _s = serial(); + smarm::init(smarm::Config::exact(1)).run(|| { + let hz = smarm::causal::tsc_hz(); + let stop = Arc::new(AtomicBool::new(false)); + let stop_b = stop.clone(); + let bystander = smarm::spawn(move || { + while !stop_b.load(Ordering::Relaxed) { + smarm::check!(); + } + }); + + // No experiment active: checks early-return, debt sits unpaid. + let debt = (hz * 0.020) as u64; // ~20ms + smarm::causal::inject_delay_cycles_for_test(debt); + smarm::sleep(Duration::from_millis(30)); + + let c0 = smarm::causal::ledger_counters(); + let g0 = smarm::causal::global_delay_cycles(); + smarm::causal::begin_experiment_for_test("audit-zero-pct-site", 0); + smarm::sleep(Duration::from_millis(60)); + smarm::causal::end_experiment_for_test(); + let c1 = smarm::causal::ledger_counters(); + stop.store(true, Ordering::Relaxed); + bystander.join().unwrap(); + + assert_eq!( + smarm::causal::global_delay_cycles(), + g0, + "a 0% window must inject nothing" + ); + let spun = c1.spin_absorbed_cycles - c0.spin_absorbed_cycles; + assert!( + spun >= debt / 2, + "leftover debt not absorbed in the 0% window: {spun} of {debt}" + ); + }); +} + +/// The audit render lists one line per cell with the ledger columns. +#[test] +fn ledger_audit_renders_per_cell() { + use smarm::causal::{render_ledger_audit, ExperimentResult}; + let results = vec![ExperimentResult { + site: "audit-render-site".into(), + speedup_pct: 50, + duration: Duration::from_secs(1), + deltas: vec![("units".into(), 10)], + injected_cycles: 1_000_000, + spin_absorbed_cycles: 900_000, + park_forgiven_cycles: 50_000, + drop_park_cycles: 30_000, + drop_park_n: 2, + drop_yield_cycles: 10_000, + drop_yield_n: 1, + discard_overmax_cycles: 5_000, + discard_overmax_n: 1, + discard_unarmed_n: 0, + }]; + let s = render_ledger_audit(&results); + assert!(s.contains("ledger audit"), "missing header: {s}"); + assert!(s.contains("audit-render-site"), "missing site line: {s}"); + assert!(s.contains("absorbed"), "missing absorbed column: {s}"); + assert!(s.contains("forgiven"), "missing forgiven column: {s}"); +}