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(),