Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,9 @@ members = [
# Calculus of emergent / relational time (Wheeler-DeWitt, Page-Wootters,
# entropic, thermal) + Structural Proper Time for agentic systems.
"crates/emergent-time",
# Structural-time agent memory decay: StructuralProperTime-based compaction
# retention scoring vs wall-clock recency (nightly 2026-08-24, ADR-340)
"crates/ruvector-structural-memory",
# PhotonLayer: learned optical-frontend computing simulator (ADR-260)
"crates/photonlayer-core",
"crates/photonlayer-bench",
Expand Down
26 changes: 26 additions & 0 deletions crates/ruvector-structural-memory/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
[package]
name = "ruvector-structural-memory"
version = "0.1.0"
edition = "2021"
description = "Structural-time agent memory decay: replaces wall-clock recency with emergent-time's StructuralProperTime (embedding-arc-length + entropy internal clock) for compaction retention scoring"
authors = ["ruvnet", "claude-flow"]
license = "MIT OR Apache-2.0"
repository = "https://github.com/ruvnet/ruvector"
keywords = ["agent-memory", "emergent-time", "vector-search", "compaction", "ruvector"]
categories = ["algorithms", "data-structures"]

[[bin]]
name = "benchmark"
path = "src/main.rs"

[dependencies]
# ADR-251's calculus of emergent time: reused here (not reimplemented) for its
# `Clock` trait / `StructuralProperTime` arc-length clock and Shannon entropy
# helper, applied to a new domain (agent-memory compaction retention scoring).
emergent-time = { version = "2.2.4", path = "../emergent-time" }

[dev-dependencies]

# Research-tier crate: keep correctness lints denied, relax style churn.
[lints.rust]
dead_code = "allow"
34 changes: 34 additions & 0 deletions crates/ruvector-structural-memory/src/clocks.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//! The three clocks under comparison. All are literal `emergent-time` types —
//! this module only fixes the [`StructuralMetric`] weights, it does not add
//! new clock math.

use emergent_time::{StructuralMetric, StructuralProperTime};

/// Candidate A: internal time = accumulated embedding movement only (`Δv`).
/// Cheapest structural clock: one L2 distance per step, no other signal
/// required.
pub fn structural_embedding_clock() -> StructuralProperTime {
StructuralProperTime::new(StructuralMetric {
w_embedding: 1.0,
w_entropy: 0.0,
w_graph: 0.0,
w_coherence: 0.0,
w_pred_error: 0.0,
gate: 0.0,
})
}

/// Candidate B: internal time = embedding movement (`Δv`) plus genuine
/// topic-uncertainty entropy (`ΔS`, see [`crate::scenario::build_snapshots`]).
/// `ΔG` and `ΔE` stay at zero weight: this harness has no honest graph or
/// prediction-error signal to feed them.
pub fn structural_full_clock() -> StructuralProperTime {
StructuralProperTime::new(StructuralMetric {
w_embedding: 1.0,
w_entropy: 1.0,
w_graph: 0.0,
w_coherence: 0.0,
w_pred_error: 0.0,
gate: 0.0,
})
}
140 changes: 140 additions & 0 deletions crates/ruvector-structural-memory/src/compaction.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
//! Compaction: score every memory once against the final context, keep the
//! top `budget`, and compare against an oracle nearest-neighbour set.

use std::collections::HashSet;
use std::time::{Duration, Instant};

use emergent_time::{Clock, StateSnapshot};

use crate::scenario::{cosine, MemoryItem};

#[derive(Clone, Copy, Debug)]
pub struct CompactionWeights {
pub w_coherence: f64,
pub w_recency: f64,
/// Decay half-scale, as a fraction of the clock's *own* total elapsed
/// internal time over the session. Fixed identically across all clocks so
/// no clock gets a hand-tuned decay scale — see crate-level docs.
pub tau_fraction: f64,
}

impl Default for CompactionWeights {
fn default() -> Self {
CompactionWeights {
w_coherence: 0.5,
w_recency: 0.5,
tau_fraction: 0.2,
}
}
}

pub struct CompactionResult {
pub kept: HashSet<usize>,
/// Wall-clock time to build the clock's cumulative-time array and score
/// every memory. Excludes session/snapshot generation (shared setup cost,
/// identical for all clocks).
pub elapsed: Duration,
}

/// Score every memory against `final_context` using `clock`'s notion of age,
/// keep the top `budget` by score.
pub fn compact<C: Clock>(
clock: &C,
snapshots: &[StateSnapshot],
memories: &[MemoryItem],
final_context: &[f64],
budget: usize,
weights: CompactionWeights,
) -> CompactionResult {
let start = Instant::now();
let cumulative = clock.cumulative(snapshots);
let total_time = *cumulative.last().unwrap_or(&0.0);
let tau = (weights.tau_fraction * total_time).max(1e-9);
let final_time = *cumulative.last().unwrap_or(&0.0);

let mut scored: Vec<(usize, f64)> = memories
.iter()
.map(|m| {
let age = (final_time - cumulative[m.write_step]).max(0.0);
let coh = cosine(&m.embedding, final_context);
let rec = (-age / tau).exp();
let score = weights.w_coherence * coh + weights.w_recency * rec;
(m.id, score)
})
.collect();
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
let kept: HashSet<usize> = scored.into_iter().take(budget).map(|(id, _)| id).collect();
let elapsed = start.elapsed();
CompactionResult { kept, elapsed }
}

/// True top-`k` memories by raw cosine similarity to `final_context` — what
/// an unlimited-memory oracle would return for the final query.
pub fn oracle_top_k(memories: &[MemoryItem], final_context: &[f64], k: usize) -> HashSet<usize> {
let mut scored: Vec<(usize, f64)> = memories
.iter()
.map(|m| (m.id, cosine(&m.embedding, final_context)))
.collect();
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
scored.into_iter().take(k).map(|(id, _)| id).collect()
}

/// Fraction of the oracle top-k that survived compaction.
pub fn recall_at_k(kept: &HashSet<usize>, oracle: &HashSet<usize>) -> f64 {
if oracle.is_empty() {
return 1.0;
}
let hit = oracle.iter().filter(|id| kept.contains(*id)).count();
hit as f64 / oracle.len() as f64
}

#[cfg(test)]
mod tests {
use super::*;
use crate::clocks::structural_embedding_clock;
use crate::scenario::{generate_session, ScenarioConfig};
use emergent_time::WallClock;

#[test]
fn kept_set_never_exceeds_budget() {
let cfg = ScenarioConfig::default();
let session = generate_session(&cfg);
let final_context = session.contexts.last().unwrap().clone();
let budget = 10;
let res = compact(
&WallClock,
&session.snapshots,
&session.memories,
&final_context,
budget,
CompactionWeights::default(),
);
assert!(res.kept.len() <= budget);
}

#[test]
fn oracle_recall_of_itself_is_one() {
let cfg = ScenarioConfig::default();
let session = generate_session(&cfg);
let final_context = session.contexts.last().unwrap().clone();
let oracle = oracle_top_k(&session.memories, &final_context, 15);
assert!((recall_at_k(&oracle, &oracle) - 1.0).abs() < 1e-12);
}

#[test]
fn structural_clock_runs_end_to_end() {
let cfg = ScenarioConfig::default();
let session = generate_session(&cfg);
let final_context = session.contexts.last().unwrap().clone();
let clock = structural_embedding_clock();
let res = compact(
&clock,
&session.snapshots,
&session.memories,
&final_context,
20,
CompactionWeights::default(),
);
assert!(!res.kept.is_empty());
}
}
46 changes: 46 additions & 0 deletions crates/ruvector-structural-memory/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//! Structural-time agent memory decay.
//!
//! Agent memory compaction (e.g. `ruvector-agent-memory`, nightly 2026-06-14)
//! scores a stored memory's "recency" against **wall-clock time**: the number
//! of turns/steps since it was written. This crate isolates that one variable
//! and asks whether `emergent-time`'s [`emergent_time::StructuralProperTime`]
//! — internal time defined as accumulated *embedding-arc-length* (and,
//! optionally, entropy) rather than step count — makes a better recency clock
//! for compaction retention scoring.
//!
//! The mechanism is simple: during a long, low-drift stretch of a session
//! (the agent is heads-down on one topic), a structural clock accumulates
//! almost no internal time, so memories written early and late in that
//! stretch end up at nearly the same structural age even though many wall
//! steps separate them. A wall clock cannot make that distinction — it ages
//! every memory at a constant rate regardless of whether anything actually
//! changed. See `src/main.rs` for the benchmark that measures the
//! consequence: compaction recall against an oracle nearest-neighbour set.
//!
//! Three clocks are compared, all literal instances of `emergent-time`
//! types — no new clock math is introduced by this crate:
//!
//! 1. [`emergent_time::WallClock`] — the baseline (today's production
//! convention).
//! 2. `StructuralProperTime` with only the embedding channel weighted
//! ([`clocks::structural_embedding_clock`]) — pure accumulated context
//! drift.
//! 3. `StructuralProperTime` with embedding + entropy channels weighted
//! ([`clocks::structural_full_clock`]) — drift plus a genuine derived
//! "topic uncertainty" signal (Shannon entropy of the softmax over
//! cosine similarities to the session's topic centroids, via
//! [`emergent_time::entropy::entropy_from_spectrum`]).
//!
//! The graph and prediction-error channels of `StructuralProperTime` are left
//! at zero weight throughout: this harness has no honest signal source for
//! them (no dependency graph, no forward model). Wiring those channels to
//! real RuVector primitives (`ruvector-mincut` for `ΔG`, a task-success
//! predictor for `ΔE`) is noted as future work, not simulated here.

pub mod clocks;
pub mod compaction;
pub mod scenario;

pub use clocks::{structural_embedding_clock, structural_full_clock};
pub use compaction::{compact, oracle_top_k, recall_at_k, CompactionWeights};
pub use scenario::{generate_session, ScenarioConfig, Session};
Loading
Loading