From cbd84acf6c1d62dfc7287477e9acada83e5bbb94 Mon Sep 17 00:00:00 2001 From: Mathew Beane Date: Sat, 3 Oct 2026 22:58:13 -0400 Subject: [PATCH] fix(unified): stop GaussianMap::decay compounding across calls decay(now) multiplied confidence by exp(-(now - timestamp_ns)/tau_eff) on every call, but timestamp_ns is the last-seen time and does not move when decay runs. Calling decay(t1) then decay(t2) applied exp(-(t1-ts)/tau) * exp(-(t2-ts)/tau) instead of exp(-(t2-ts)/tau), so confidence fell faster the more often the caller ticked. Track how far each Gaussian has already been decayed (decayed_to_ns) and only apply the interval since max(decayed_to_ns, timestamp_ns). A fresh observation still restarts the clock, and the lifetime-stretched tau_eff is unchanged. Fusion and the merge pass keep the later of the two decayed_to_ns values. The new field is #[serde(default)], so maps serialized before this change load with 0 and decay from timestamp_ns as before. Adds two tests: one-shot vs. per-second decay must agree to 1e-12, and the wall-vs-transient persistence case must hold when decay runs every 10 s. Co-Authored-By: claude-flow --- v2/crates/ruview-unified/src/gaussian/map.rs | 65 ++++++++++++++++++- .../ruview-unified/src/gaussian/primitive.rs | 7 ++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/v2/crates/ruview-unified/src/gaussian/map.rs b/v2/crates/ruview-unified/src/gaussian/map.rs index c1389c55e..235853e24 100644 --- a/v2/crates/ruview-unified/src/gaussian/map.rs +++ b/v2/crates/ruview-unified/src/gaussian/map.rs @@ -137,6 +137,7 @@ impl GaussianMap { e.doppler_variance = (wa * e.doppler_variance + wb * g.doppler_variance) / wsum; e.confidence = (wa + wb - wa * wb).clamp(0.0, 1.0); e.first_seen_ns = e.first_seen_ns.min(g.first_seen_ns); + e.decayed_to_ns = e.decayed_to_ns.max(g.decayed_to_ns); if g.timestamp_ns >= e.timestamp_ns { e.timestamp_ns = g.timestamp_ns; e.provenance = g.provenance; @@ -181,9 +182,15 @@ impl GaussianMap { /// repeatedly observed — `τ_eff = τ · (1 + ln(1 + lifetime/τ))` with /// `lifetime = last_seen − first_seen`. A wall confirmed for hours /// outlives a transient echo seen once, even at equal nominal τ. + /// + /// Only the interval since `max(decayed_to_ns, timestamp_ns)` is applied, + /// so `decay(t1); decay(t2)` equals `decay(t2)`: the result does not + /// depend on how often the caller ticks. pub fn decay(&mut self, now_ns: u64) { for g in &mut self.gaussians { - let dt_s = (now_ns.saturating_sub(g.timestamp_ns)) as f64 / 1e9; + let from_ns = g.decayed_to_ns.max(g.timestamp_ns); + let dt_s = (now_ns.saturating_sub(from_ns)) as f64 / 1e9; + g.decayed_to_ns = from_ns.max(now_ns); let lifetime_s = (g.timestamp_ns.saturating_sub(g.first_seen_ns)) as f64 / 1e9; // `RfGaussian::new` validates `decay_tau_s > 0`, but the field is // mutable after construction (`gaussians_mut`); re-clamp here so a @@ -269,6 +276,7 @@ impl GaussianMap { e.confidence = (wa + wb - wa * wb).clamp(0.0, 1.0); e.first_seen_ns = e.first_seen_ns.min(partner.first_seen_ns); e.timestamp_ns = e.timestamp_ns.max(partner.timestamp_ns); + e.decayed_to_ns = e.decayed_to_ns.max(partner.decayed_to_ns); for r in partner.source_receipts { if !e.source_receipts.contains(&r) && e.source_receipts.len() < super::primitive::MAX_SOURCE_RECEIPTS @@ -532,6 +540,61 @@ mod tests { assert!((map.gaussians()[0].position[0]).abs() < 1e-9, "survivor is the wall"); } + #[test] + fn decay_is_independent_of_call_frequency() { + // A transient (lifetime 0 ⇒ τ_eff = τ) and a wall seen for 30 min + // (stretched τ_eff), both last observed at t = 1800 s. + let build = || { + let mut map = GaussianMap::new(1.0); + let mut wall = g_at([0.0, 0.0, 1.0], 0.9, 1_800_000_000_000); + wall.first_seen_ns = 0; + map.insert(wall); + map.insert(g_at([6.0, 0.0, 1.0], 0.9, 1_800_000_000_000)); + map + }; + let end_ns = 1_890_000_000_000; // 90 s after the last observation + + let mut once = build(); + once.decay(end_ns); + + let mut stepped = build(); + for k in 1..=90u64 { + stepped.decay(1_800_000_000_000 + k * 1_000_000_000); + } + // Repeating the final timestamp must be a no-op. + stepped.decay(end_ns); + + assert_eq!(once.len(), 2, "both survive 90 s of single-call decay"); + assert_eq!(stepped.len(), once.len(), "no extra pruning"); + for (a, b) in once.gaussians().iter().zip(stepped.gaussians()) { + assert!( + (a.confidence - b.confidence).abs() < 1e-12, + "decay(t) once gave {} but stepped decay up to t gave {}", + a.confidence, + b.confidence + ); + } + // Transient: exactly 0.9·e^{-90/60}. + let transient = &once.gaussians()[1]; + assert!((transient.confidence - 0.9 * (-1.5f64).exp()).abs() < 1e-12); + } + + #[test] + fn static_persistence_holds_under_frequent_decay() { + // Same scenario as `long_lived_structure_outlives_transients_at_equal_tau`, + // but decay runs every 10 s instead of once at the end. + let mut map = GaussianMap::new(1.0); + let mut wall = g_at([0.0, 0.0, 1.0], 0.9, 1_800_000_000_000); + wall.first_seen_ns = 0; + map.insert(wall); + map.insert(g_at([6.0, 0.0, 1.0], 0.9, 1_800_000_000_000)); + for k in 1..=30u64 { + map.decay(1_800_000_000_000 + k * 10_000_000_000); + } + assert_eq!(map.len(), 1, "only the long-lived structure survives"); + assert!(map.gaussians()[0].position[0].abs() < 1e-9, "wall survives"); + } + #[test] fn merge_pass_collapses_mutual_overlaps_but_respects_semantics() { // Two overlapping unlabeled Gaussians that insert-fusion misses diff --git a/v2/crates/ruview-unified/src/gaussian/primitive.rs b/v2/crates/ruview-unified/src/gaussian/primitive.rs index 2fa448d5e..a9d4a317f 100644 --- a/v2/crates/ruview-unified/src/gaussian/primitive.rs +++ b/v2/crates/ruview-unified/src/gaussian/primitive.rs @@ -118,6 +118,12 @@ pub struct RfGaussian { pub first_seen_ns: u64, /// Last-updated timestamp, ns since epoch. pub timestamp_ns: u64, + /// Time up to which `confidence` has already been decayed, ns since + /// epoch. [`super::map::GaussianMap::decay`] only applies the interval + /// after `max(decayed_to_ns, timestamp_ns)`, so repeated calls compose + /// instead of compounding. Defaults to 0 for older serialized maps. + #[serde(default)] + pub decayed_to_ns: u64, /// Confidence e-folding time in seconds (decay clock). pub decay_tau_s: f64, /// Evidence provenance. @@ -204,6 +210,7 @@ impl RfGaussian { confidence, first_seen_ns: timestamp_ns, timestamp_ns, + decayed_to_ns: timestamp_ns, decay_tau_s, provenance, source_receipts: Vec::new(),