mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-18 21:00:22 -05:00
Merge PR #2140 (aepod) into release integration
This commit is contained in:
commit
27eefb6a7f
2 files changed
+71
-1
No files matched your search
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user