diff --git a/.github/workflows/publish-all.yml b/.github/workflows/publish-all.yml index 5e7dedd66..0c413edd2 100644 --- a/.github/workflows/publish-all.yml +++ b/.github/workflows/publish-all.yml @@ -223,6 +223,43 @@ jobs: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} continue-on-error: true + # Graph condensation + perception stack (PR #547). Published in dependency + # order: mincut (substrate) -> graph-condense / perception -> wasm bindings. + # Each new crate pins ruvector-mincut = "2.2.3", so mincut 2.2.3 must reach + # the index first — hence the publish + settle step below. + - name: Wait for crates.io index + run: sleep 30 + + - name: Publish ruvector-mincut + run: cargo publish -p ruvector-mincut --allow-dirty + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + continue-on-error: true + + - name: Wait for crates.io index + run: sleep 30 + + - name: Publish ruvector-graph-condense + run: cargo publish -p ruvector-graph-condense --allow-dirty + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + continue-on-error: true + + - name: Publish ruvector-perception + run: cargo publish -p ruvector-perception --allow-dirty + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + continue-on-error: true + + - name: Wait for crates.io index + run: sleep 30 + + - name: Publish ruvector-graph-condense-wasm + run: cargo publish -p ruvector-graph-condense-wasm --allow-dirty + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + continue-on-error: true + - name: Summary run: | echo "## crates.io Publishing" >> $GITHUB_STEP_SUMMARY @@ -230,6 +267,10 @@ jobs: echo "✅ ruvector-attention" >> $GITHUB_STEP_SUMMARY echo "✅ ruvector-math-wasm" >> $GITHUB_STEP_SUMMARY echo "✅ ruvector-attention-wasm" >> $GITHUB_STEP_SUMMARY + echo "✅ ruvector-mincut" >> $GITHUB_STEP_SUMMARY + echo "✅ ruvector-graph-condense" >> $GITHUB_STEP_SUMMARY + echo "✅ ruvector-perception" >> $GITHUB_STEP_SUMMARY + echo "✅ ruvector-graph-condense-wasm" >> $GITHUB_STEP_SUMMARY # ============================================================================ # Phase 5: Publish to npm diff --git a/.github/workflows/regression-guard.yml b/.github/workflows/regression-guard.yml index a5fe697e4..8c3f43049 100644 --- a/.github/workflows/regression-guard.yml +++ b/.github/workflows/regression-guard.yml @@ -392,3 +392,40 @@ jobs: done <<< "$entries" done < <(find npm/packages -name package.json -not -path '*/node_modules/*') exit $fail + + # PR #547: the graph-condense + perception crates are workspace members, so + # `cargo check --workspace` builds them — but no job runs their unit tests, + # integration tests, or benches, so they can silently rot. Run them explicitly. + graph-condense-perception-tests: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: cargo test graph-condense + perception + run: | + set -e + cargo test -p ruvector-graph-condense --all-features + cargo test -p ruvector-perception --all-features + # wasm crate: rlib path type-checks on host; the cdylib/getrandom-js + # path is exercised on wasm32 by the no-systemtime-in-wasm-crates job. + cargo check -p ruvector-graph-condense-wasm + + # PR #547: these crates were built and tested against the local workspace + # ruvector-mincut (2.2.3, path dep). A `version = "2.0.x"` pin would resolve + # downstream to the stale published mincut 2.0.6 and could ship a crate that + # fails to compile for crates.io consumers. Forbid regressing the pin to 2.0.x. + mincut-pin-tracks-workspace-version: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - name: ruvector-mincut pin must not regress to 2.0.x + run: | + set -e + if grep -rnE 'ruvector-mincut[^#]*version *= *"[~^]?2\.0\.' \ + crates/ruvector-graph-condense/Cargo.toml \ + crates/ruvector-graph-condense-wasm/Cargo.toml \ + crates/ruvector-perception/Cargo.toml ; then + echo "::error::ruvector-mincut pinned to 2.0.x but the workspace is 2.2.3 (regression of PR #547). A crates.io publish would resolve mincut to the stale published 2.0.6 and may not compile downstream. Keep the pin aligned with the workspace version." + exit 1 + fi diff --git a/Cargo.lock b/Cargo.lock index 078e1b29f..47bb4492c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9458,6 +9458,31 @@ dependencies = [ "zstd", ] +[[package]] +name = "ruvector-graph-condense" +version = "2.2.3" +dependencies = [ + "criterion 0.5.1", + "rand 0.8.5", + "rayon", + "ruvector-mincut 2.2.3", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "ruvector-graph-condense-wasm" +version = "2.2.3" +dependencies = [ + "getrandom 0.2.17", + "ruvector-graph-condense", + "ruvector-mincut 2.2.3", + "serde", + "serde_json", + "wasm-bindgen", +] + [[package]] name = "ruvector-graph-node" version = "2.2.3" @@ -9870,6 +9895,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "ruvector-perception" +version = "2.2.3" +dependencies = [ + "ruvector-mincut 2.2.3", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.18", +] + [[package]] name = "ruvector-profiler" version = "2.2.3" diff --git a/Cargo.toml b/Cargo.toml index 38128585a..d2464666e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -233,6 +233,11 @@ members = [ "crates/ruvllm_retrieval_diffusion", # RAIRS IVF: Redundant Assignment + Amplified Inverse Residual (ADR-193) "crates/ruvector-rairs", + # Structure-preserving graph condensation via dynamic min-cut communities + "crates/ruvector-graph-condense", + "crates/ruvector-graph-condense-wasm", + # Perception substrate: delta -> boundary -> coherence -> proof -> action + "crates/ruvector-perception", ] resolver = "2" diff --git a/crates/ruvector-graph-condense-wasm/Cargo.toml b/crates/ruvector-graph-condense-wasm/Cargo.toml new file mode 100644 index 000000000..df0b85c1a --- /dev/null +++ b/crates/ruvector-graph-condense-wasm/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "ruvector-graph-condense-wasm" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +readme = "README.md" +description = "WASM bindings for ruvector-graph-condense: structure-preserving + differentiable-min-cut graph condensation in the browser / on the edge" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +# Core condenser without the Rayon parallel feature (wasm32 has no threads). +ruvector-graph-condense = { version = "2.2.3", path = "../ruvector-graph-condense", default-features = false } +ruvector-mincut = { version = "2.2.3", path = "../ruvector-mincut", default-features = false, features = ["wasm"] } +wasm-bindgen = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +# Force the JS RNG backend only on wasm (rand 0.8 -> getrandom 0.2); keeps the +# `js` feature out of native builds / feature unification. +[target.'cfg(target_arch = "wasm32")'.dependencies] +getrandom = { version = "0.2", features = ["js"] } + +[lints] +workspace = true diff --git a/crates/ruvector-graph-condense-wasm/README.md b/crates/ruvector-graph-condense-wasm/README.md new file mode 100644 index 000000000..aab0980b8 --- /dev/null +++ b/crates/ruvector-graph-condense-wasm/README.md @@ -0,0 +1,30 @@ +# RuVector Graph Condense — WASM + +[![Crates.io](https://img.shields.io/crates/v/ruvector-graph-condense-wasm.svg)](https://crates.io/crates/ruvector-graph-condense-wasm) +[![Documentation](https://docs.rs/ruvector-graph-condense-wasm/badge.svg)](https://docs.rs/ruvector-graph-condense-wasm) +[![License](https://img.shields.io/crates/l/ruvector-graph-condense-wasm.svg)](LICENSE) +[![GitHub](https://img.shields.io/badge/GitHub-ruvnet%2Fruvector-blue?logo=github)](https://github.com/ruvnet/ruvector) +[![ruv.io](https://img.shields.io/badge/ruv.io-AI%20Infrastructure-orange)](https://ruv.io) + +**WASM bindings for [`ruvector-graph-condense`](https://crates.io/crates/ruvector-graph-condense).** + +*Structure-preserving + differentiable-min-cut graph condensation in the browser or on the edge.* + +--- + +Thin `wasm-bindgen` wrapper over the core condenser, built without the Rayon +`parallel` feature (wasm32 has no threads) and with the JS `getrandom` backend +gated to `cfg(target_arch = "wasm32")` so native builds are unaffected. + +For the algorithm, region methods, and limitations, see the core crate's +[README](https://crates.io/crates/ruvector-graph-condense) and **ADR-196 / ADR-197**. + +## Build + +```bash +wasm-pack build crates/ruvector-graph-condense-wasm --target web +``` + +## License + +MIT © [ruv.io](https://ruv.io) diff --git a/crates/ruvector-graph-condense-wasm/src/lib.rs b/crates/ruvector-graph-condense-wasm/src/lib.rs new file mode 100644 index 000000000..fb02755c9 --- /dev/null +++ b/crates/ruvector-graph-condense-wasm/src/lib.rs @@ -0,0 +1,136 @@ +//! WASM bindings for `ruvector-graph-condense`. +//! +//! Exposes the structure-preserving condenser and the trained differentiable +//! min-cut condenser to JavaScript / the browser / edge runtimes, so a graph can +//! be condensed into a small deployable artifact client-side. Built without the +//! `parallel` (Rayon) feature, since `wasm32-unknown-unknown` has no threads. +//! +//! Graphs are passed as flat typed arrays from JS (`src`, `dst`, `w`: parallel +//! arrays, one entry per undirected edge; `features`: row-major `n × dim` `f32` +//! embeddings). Results are returned as JSON (a serialised `CondensedGraph`). + +use ruvector_graph_condense::{ + CondenseConfig, CondenseMethod, DiffCutConfig, GraphCondenser, NodeFeatures, +}; +use ruvector_mincut::DynamicGraph; +use wasm_bindgen::prelude::*; + +fn build( + n: u32, + src: &[u32], + dst: &[u32], + w: &[f32], + features: &[f32], + dim: u32, +) -> Result<(DynamicGraph, NodeFeatures), String> { + let n = n as usize; + let dim = dim as usize; + if src.len() != dst.len() || src.len() != w.len() { + return Err("src/dst/w length mismatch".into()); + } + if features.len() != n * dim { + return Err(format!( + "features length {} != n*dim {}", + features.len(), + n * dim + )); + } + let g = DynamicGraph::new(); + let mut f = NodeFeatures::new(dim, 0); + for v in 0..n { + f.set_embedding(v as u64, features[v * dim..(v + 1) * dim].to_vec()) + .map_err(|e| e.to_string())?; + g.add_vertex(v as u64); + } + for i in 0..src.len() { + let _ = g.insert_edge(src[i] as u64, dst[i] as u64, w[i] as f64); + } + Ok((g, f)) +} + +fn run(config: CondenseConfig, args: BuildArgs) -> String { + match build(args.n, args.src, args.dst, args.w, args.features, args.dim) { + Ok((g, f)) => match GraphCondenser::new(config).condense(&g, &f) { + Ok(c) => serde_json::to_string(&c).unwrap_or_else(|e| err_json(&e.to_string())), + Err(e) => err_json(&e.to_string()), + }, + Err(e) => err_json(&e), + } +} + +struct BuildArgs<'a> { + n: u32, + src: &'a [u32], + dst: &'a [u32], + w: &'a [f32], + features: &'a [f32], + dim: u32, +} + +fn err_json(msg: &str) -> String { + format!( + "{{\"error\":{}}}", + serde_json::to_string(msg).unwrap_or_default() + ) +} + +/// Condense with the default structure-preserving `WeakBoundary` method. +/// Returns a JSON `CondensedGraph` (or `{"error": "..."}`). +#[wasm_bindgen] +pub fn condense_weak( + n: u32, + src: &[u32], + dst: &[u32], + w: &[f32], + features: &[f32], + dim: u32, +) -> String { + run( + CondenseConfig::default(), + BuildArgs { + n, + src, + dst, + w, + features, + dim, + }, + ) +} + +/// Condense with the trained differentiable min-cut method (Adam + warm-start). +#[wasm_bindgen] +pub fn condense_diffmincut( + n: u32, + src: &[u32], + dst: &[u32], + w: &[f32], + features: &[f32], + dim: u32, + num_clusters: u32, +) -> String { + let cfg = CondenseConfig { + method: CondenseMethod::DiffMinCut(DiffCutConfig { + num_clusters: num_clusters.max(1) as usize, + ..Default::default() + }), + normalize_centroids: false, + }; + run( + cfg, + BuildArgs { + n, + src, + dst, + w, + features, + dim, + }, + ) +} + +/// Crate version (handy for cache-busting a deployed bundle). +#[wasm_bindgen] +pub fn version() -> String { + env!("CARGO_PKG_VERSION").to_string() +} diff --git a/crates/ruvector-graph-condense/Cargo.toml b/crates/ruvector-graph-condense/Cargo.toml new file mode 100644 index 000000000..98271db21 --- /dev/null +++ b/crates/ruvector-graph-condense/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "ruvector-graph-condense" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +readme = "README.md" +description = "Structure-preserving graph condensation: collapse large feature graphs into small synthetic graphs using dynamic min-cut community boundaries" +keywords = ["graph", "condensation", "distillation", "min-cut", "gnn"] +categories = ["algorithms", "science", "mathematics"] + +[lib] +crate-type = ["rlib"] + +[dependencies] +# Substrate: dynamic min-cut engine (DynamicGraph, CommunityDetector, GraphPartitioner). +# Default features only (exact + approximate); integration re-exports are unconditional. +ruvector-mincut = { version = "2.2.3", path = "../ruvector-mincut", default-features = false } + +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +rand = { workspace = true } +rayon = { workspace = true, optional = true } + +[features] +default = ["parallel"] +# Rayon-based parallelism for the differentiable min-cut optimiser. Off for +# targets without threads (e.g. wasm32-unknown-unknown). +parallel = ["dep:rayon"] + +[dev-dependencies] +criterion = { workspace = true } + +[[bench]] +name = "condense_bench" +harness = false + +[lints] +workspace = true diff --git a/crates/ruvector-graph-condense/README.md b/crates/ruvector-graph-condense/README.md new file mode 100644 index 000000000..2237d9067 --- /dev/null +++ b/crates/ruvector-graph-condense/README.md @@ -0,0 +1,74 @@ +# RuVector Graph Condense + +[![Crates.io](https://img.shields.io/crates/v/ruvector-graph-condense.svg)](https://crates.io/crates/ruvector-graph-condense) +[![Documentation](https://docs.rs/ruvector-graph-condense/badge.svg)](https://docs.rs/ruvector-graph-condense) +[![License](https://img.shields.io/crates/l/ruvector-graph-condense.svg)](LICENSE) +[![GitHub](https://img.shields.io/badge/GitHub-ruvnet%2Fruvector-blue?logo=github)](https://github.com/ruvnet/ruvector) +[![ruv.io](https://img.shields.io/badge/ruv.io-AI%20Infrastructure-orange)](https://ruv.io) + +**Training-free, structure-preserving, provenance-retaining graph condensation.** + +*Collapse a large feature graph into a small synthetic graph of super-nodes while preserving its cut structure — plus a differentiable min-cut loss.* + +--- + +## Why This Matters + +Graph condensation shrinks a graph + per-node embeddings (+ optional labels) into a much smaller graph that downstream tasks can still reason over. The published SOTA — GCond, SFGC, GEOM, SGDD — *synthesises* a fake graph via expensive, supervised bi-level gradient/distribution/trajectory matching, and **discards the node→original mapping**. + +`ruvector-graph-condense` takes the complementary, training-free route the 2024–2026 condensation surveys flag as under-explored: + +- **Min-cut community structure as the condensation prior** (not k-means). +- **Cuts preserved by construction** — boundary edges become weighted super-edges; `metrics::cut_inflation` quantifies fidelity. +- **Provenance retained** — every `CondensedNode` keeps its `members`, so each super-node is auditable and explainable. +- **A differentiable min-cut *loss*** (`diffcut`, MinCutPool-style relaxed normalized cut + orthogonality) — analytic gradients, gradient-checked across K=2,3,4 to <1e-5. + +Built on the dynamic min-cut engine [`ruvector-mincut`](https://crates.io/crates/ruvector-mincut). + +## Quick Start + +```rust +use ruvector_graph_condense::{CondenseConfig, GraphCondenser, NodeFeatures}; +use ruvector_mincut::DynamicGraph; + +// Build a graph (insert_edge returns a Result; &self — it is concurrent). +let graph = DynamicGraph::new(); +let _ = graph.insert_edge(0, 1, 1.0); +let _ = graph.insert_edge(1, 2, 1.0); +let _ = graph.insert_edge(2, 3, 0.1); // weak boundary edge + +// Per-vertex embeddings (+ optional labels): NodeFeatures::new(dim, num_classes). +let mut features = NodeFeatures::new(2, 1); +for v in 0..4u64 { + features.set(v, vec![v as f32, 0.0], 0).unwrap(); +} + +let condenser = GraphCondenser::new(CondenseConfig::default()); // WeakBoundary, 0.5 +let condensed = condenser.condense(&graph, &features).unwrap(); + +for node in &condensed.nodes { + // Each super-node keeps the original vertices it came from (provenance). + println!("super-node {:?} <- members {:?}", node.representative, node.members); +} +``` + +## Region Methods (`CondenseMethod`) + +| Method | Notes | +|--------|-------| +| `WeakBoundary` (default) | Linear-time union-find on weak edges. ~4 ms @ 2048 nodes. | +| `MinCutCommunity` / `Partition` | Delegate to the min-cut engine. Structure-aware on graphs with sharp bottlenecks; documented best-effort otherwise. | +| `ConnectedComponents` | Cheap baseline — one region per component. | +| `DiffMinCut` | Differentiable, *trained* assignment (opt-in). | + +## Honest Limitations + +- The recursive **global min-cut engine methods degenerate to singleton-peeling** on graphs without sharp bottlenecks and are super-linear (~24 s @ 96 nodes) — which is why the linear-time `WeakBoundary` is the default. +- **`DiffMinCut` is K-sensitive** (known MinCutPool finickiness): it recovers small/dense graphs but underperforms `WeakBoundary` at large K. Momentum + unit-scale init help, but there is no convergence guarantee. +- This is structure-preserving **coarsening-condensation** (keeps provenance) — **not** accuracy-matched GCond-style condensation; no GNN-retrain accuracy numbers are claimed. + +See **ADR-196** (structure-preserving condensation) and **ADR-197** (differentiable min-cut loss) for the full design and findings. + +## License + +MIT © [ruv.io](https://ruv.io) diff --git a/crates/ruvector-graph-condense/benches/condense_bench.rs b/crates/ruvector-graph-condense/benches/condense_bench.rs new file mode 100644 index 000000000..b6c897c3c --- /dev/null +++ b/crates/ruvector-graph-condense/benches/condense_bench.rs @@ -0,0 +1,210 @@ +//! Condensation throughput benchmarks. +//! +//! Run with: `cargo bench -p ruvector-graph-condense --bench condense_bench` +//! +//! Two groups, because the methods differ by orders of magnitude: +//! +//! * **scalable** — `WeakBoundary` (default) and `ConnectedComponents` are +//! single-pass + union-find, ~microseconds even at thousands of nodes. +//! * **engine** — `MinCutCommunity` and `Partition` delegate to the recursive +//! dynamic-min-cut engine, which copies the graph per split; they are +//! super-linear and benchmarked only at small sizes to document the cost. + +use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; +use ruvector_graph_condense::condense::{CondenseConfig, CondenseMethod, GraphCondenser}; +use ruvector_graph_condense::diffcut::{DiffCutCondenser, DiffCutConfig}; +use ruvector_graph_condense::metrics::evaluate_full; +use ruvector_graph_condense::synthetic::PlantedPartition; + +fn planted(communities: usize, size: usize, seed: u64) -> PlantedPartition { + PlantedPartition { + num_communities: communities, + community_size: size, + dim: 16, + p_intra: 0.4, + p_inter: 0.002, + seed, + ..Default::default() + } +} + +/// Fast methods, swept to larger graphs. +fn bench_scalable(c: &mut Criterion) { + let mut group = c.benchmark_group("condense_scalable"); + for &(communities, size) in &[(8usize, 32usize), (16, 64), (32, 64)] { + let pp = planted(communities, size, 1); + let (graph, features) = pp.generate(); + let n = pp.total_vertices(); + group.throughput(Throughput::Elements(n as u64)); + + for (name, method) in [ + ( + "weak_boundary", + CondenseMethod::WeakBoundary { + relative_threshold: 0.5, + }, + ), + ("connected_components", CondenseMethod::ConnectedComponents), + ] { + let condenser = GraphCondenser::new(CondenseConfig { + method, + normalize_centroids: false, + }); + group.bench_with_input( + BenchmarkId::new(name, n), + &(condenser, &graph, &features), + |b, (condenser, graph, features)| { + b.iter(|| { + let c = condenser.condense(graph, features).unwrap(); + criterion::black_box(c.node_count()) + }); + }, + ); + } + } + group.finish(); +} + +/// Engine-backed methods, small sizes only (super-linear cost). +fn bench_engine(c: &mut Criterion) { + let mut group = c.benchmark_group("condense_engine"); + group.sample_size(10); + // Capped small: recursive global min-cut is super-linear (e.g. ~24s at 96 + // nodes), so larger sizes would make the suite intractable. The point is to + // document the cost gap vs. the scalable group, not to sweep. + for &(communities, size) in &[(3usize, 10usize), (4, 12)] { + let pp = planted(communities, size, 2); + let (graph, features) = pp.generate(); + let n = pp.total_vertices(); + group.throughput(Throughput::Elements(n as u64)); + + for (name, method) in [ + ( + "mincut_community", + CondenseMethod::MinCutCommunity { min_region_size: 2 }, + ), + ( + "partition", + CondenseMethod::Partition { + num_regions: communities, + }, + ), + ] { + let condenser = GraphCondenser::new(CondenseConfig { + method, + normalize_centroids: false, + }); + group.bench_with_input( + BenchmarkId::new(name, n), + &(condenser, &graph, &features), + |b, (condenser, graph, features)| { + b.iter(|| { + let c = condenser.condense(graph, features).unwrap(); + criterion::black_box(c.node_count()) + }); + }, + ); + } + } + group.finish(); +} + +/// Cost of the full metric bundle (includes two exact min-cut solves). +fn bench_metrics(c: &mut Criterion) { + let pp = planted(8, 24, 3); + let (graph, features) = pp.generate(); + let condenser = GraphCondenser::new(CondenseConfig::default()); + let condensed = condenser.condense(&graph, &features).unwrap(); + + c.bench_function("evaluate_full_with_cut", |b| { + b.iter(|| { + let m = evaluate_full(&graph, &condensed); + criterion::black_box(m.node_reduction_ratio) + }); + }); +} + +/// Differentiable min-cut training cost (gradient descent over the assignment). +fn bench_diffcut(c: &mut Criterion) { + let mut group = c.benchmark_group("condense_diffcut"); + group.sample_size(10); + for &(communities, size) in &[(4usize, 16usize), (8, 24)] { + let pp = planted(communities, size, 4); + let (graph, _features) = pp.generate(); + let n = pp.total_vertices(); + group.throughput(Throughput::Elements(n as u64)); + let condenser = DiffCutCondenser::new(DiffCutConfig { + num_clusters: communities, + iterations: 100, + seed: 1, + ..Default::default() + }); + group.bench_with_input( + BenchmarkId::new("train", n), + &(condenser, &graph), + |b, (condenser, graph)| { + b.iter(|| { + let r = condenser.train(graph).unwrap(); + criterion::black_box(r.loss().total) + }); + }, + ); + } + group.finish(); +} + +/// DiffMinCut optimisation levers on a larger graph: full-sequential vs +/// full-parallel vs edge-minibatch (fixed 100 iterations, early-stop off). +fn bench_diffcut_levers(c: &mut Criterion) { + let mut group = c.benchmark_group("condense_diffcut_levers"); + group.sample_size(10); + let pp = planted(16, 64, 5); // 1024 nodes + let (graph, _f) = pp.generate(); + let n = pp.total_vertices(); + group.throughput(Throughput::Elements(n as u64)); + let base = DiffCutConfig { + num_clusters: 16, + iterations: 100, + tolerance: 0.0, + seed: 1, + ..Default::default() + }; + let variants = [ + ("full_sequential", DiffCutConfig { ..base.clone() }), + ( + "full_parallel", + DiffCutConfig { + parallel: true, + ..base.clone() + }, + ), + ( + "minibatch_2048", + DiffCutConfig { + minibatch_edges: Some(2048), + ..base.clone() + }, + ), + ]; + for (name, cfg) in variants { + let condenser = DiffCutCondenser::new(cfg); + group.bench_with_input( + BenchmarkId::new(name, n), + &(condenser, &graph), + |b, (c, g)| { + b.iter(|| criterion::black_box(c.train(g).unwrap().loss().total)); + }, + ); + } + group.finish(); +} + +criterion_group!( + benches, + bench_scalable, + bench_engine, + bench_diffcut, + bench_diffcut_levers, + bench_metrics +); +criterion_main!(benches); diff --git a/crates/ruvector-graph-condense/examples/accuracy_eval.rs b/crates/ruvector-graph-condense/examples/accuracy_eval.rs new file mode 100644 index 000000000..c4b2eb0e3 --- /dev/null +++ b/crates/ruvector-graph-condense/examples/accuracy_eval.rs @@ -0,0 +1,225 @@ +//! Accuracy-retention evaluation — the graph-condensation field's standard +//! protocol: train a GNN on the **condensed** graph, test it on the **original** +//! graph's held-out nodes, and report `accuracy(condensed) / accuracy(full)`. +//! +//! Run: `cargo run --release -p ruvector-graph-condense --example accuracy_eval` +//! +//! Honest scope: this runs on a *controlled synthetic* node-classification task +//! (planted communities as classes, noisy features so the graph actually +//! matters), not the canonical Cora/Citeseer benchmarks — so it is a +//! substantiated *retention* measurement, not a literal "beats GCond on Cora" +//! claim. It closes the gap of having no learning-accuracy validation at all. + +#![allow(clippy::needless_range_loop)] // index-heavy numeric example code + +use rand::rngs::StdRng; +use rand::{Rng, SeedableRng}; +use ruvector_graph_condense::gnn_eval::{accuracy, Gcn, GcnConfig, GcnGraph}; +use ruvector_graph_condense::{ + CondenseConfig, CondenseMethod, CondensedGraph, DiffCutConfig, GraphCondenser, NodeFeatures, +}; +use ruvector_mincut::DynamicGraph; + +struct Task { + classes: usize, + per_class: usize, // nodes per class + dim: usize, + p_intra: f64, + p_inter: f64, + noise: f64, + seed: u64, +} + +impl Task { + fn n(&self) -> usize { + self.classes * self.per_class + } + + /// Build graph + features + per-node class labels. Node `i` has class + /// `i / per_class`; features are a class centroid + Gaussian-ish noise so + /// raw features overlap and the graph carries real signal. + fn generate(&self) -> (DynamicGraph, NodeFeatures, Vec) { + let mut rng = StdRng::seed_from_u64(self.seed); + let g = DynamicGraph::new(); + let mut f = NodeFeatures::new(self.dim, self.classes); + let mut labels = vec![0usize; self.n()]; + + let centroids: Vec> = (0..self.classes) + .map(|c| { + (0..self.dim) + .map(|d| if d % self.classes == c { 1.5 } else { 0.0 }) + .collect() + }) + .collect(); + + for i in 0..self.n() { + let cls = i / self.per_class; + labels[i] = cls; + let emb: Vec = (0..self.dim) + .map(|d| (centroids[cls][d] + self.noise * rng.gen_range(-1.0..1.0)) as f32) + .collect(); + f.set(i as u64, emb, cls).unwrap(); + g.add_vertex(i as u64); + } + for a in 0..self.n() { + for b in (a + 1)..self.n() { + let same = a / self.per_class == b / self.per_class; + let p = if same { self.p_intra } else { self.p_inter }; + if rng.gen_bool(p) { + let _ = g.insert_edge(a as u64, b as u64, 1.0); + } + } + } + (g, f, labels) + } +} + +/// Extract contiguous `0..n` edge list / feature matrix from the graph. +fn full_arrays( + g: &DynamicGraph, + f: &NodeFeatures, + n: usize, +) -> (Vec<(usize, usize, f64)>, Vec) { + let edges = g + .edges() + .iter() + .map(|e| (e.source as usize, e.target as usize, e.weight)) + .collect(); + let dim = f.dim(); + let mut x = vec![0f64; n * dim]; + for i in 0..n { + if let Some(emb) = f.embedding(i as u64) { + for d in 0..dim { + x[i * dim + d] = emb[d] as f64; + } + } + } + (edges, x) +} + +/// Build the GCN training arrays for a condensed graph: centroids as features, +/// dominant class as label, super-edges as adjacency. +fn condensed_arrays(c: &CondensedGraph) -> (GcnGraph, Vec, Vec) { + let cn = c.node_count(); + let dim = c.dim; + let mut x = vec![0f64; cn * dim]; + let mut labels = vec![0usize; cn]; + for (i, node) in c.nodes.iter().enumerate() { + for d in 0..dim { + x[i * dim + d] = node.centroid[d] as f64; + } + labels[i] = node.dominant_class().unwrap_or(0); + } + let edges: Vec<(usize, usize, f64)> = c + .edges + .iter() + .map(|e| (e.source as usize, e.target as usize, e.weight)) + .collect(); + (GcnGraph::from_edges(cn, &edges), x, labels) +} + +fn split(n: usize, train_frac: f64, seed: u64) -> (Vec, Vec) { + let mut rng = StdRng::seed_from_u64(seed); + let (mut tr, mut te) = (Vec::new(), Vec::new()); + for i in 0..n { + if rng.gen_bool(train_frac) { + tr.push(i); + } else { + te.push(i); + } + } + (tr, te) +} + +fn main() { + let task = Task { + classes: 6, + per_class: 60, + dim: 24, + p_intra: 0.12, + p_inter: 0.004, + noise: 1.4, + seed: 2026, + }; + let n = task.n(); + let (graph, feats, labels) = task.generate(); + let (full_edges, x_full) = full_arrays(&graph, &feats, n); + let full_graph = GcnGraph::from_edges(n, &full_edges); + let (train, test) = split(n, 0.6, 7); + let cfg = GcnConfig::default(); + + println!( + "Task: {} nodes, {} classes, {} edges, dim {}, noise {} (features overlap; graph matters)", + n, + task.classes, + graph.num_edges(), + task.dim, + task.noise + ); + println!("Protocol: train GNN on condensed graph -> test on original held-out nodes.\n"); + + // Baseline: train on the FULL graph's train split. + let base = Gcn::train( + &cfg, + &full_graph, + &x_full, + task.dim, + &labels, + task.classes, + &train, + ); + let acc_full = accuracy(&base.predict(&full_graph, &x_full), &labels, &test); + println!( + "Baseline GNN (trained on full graph): test accuracy {:.1}%\n", + acc_full * 100.0 + ); + + for (name, method) in [ + ( + "WeakBoundary", + CondenseMethod::WeakBoundary { + relative_threshold: 0.5, + }, + ), + ( + "DiffMinCut", + CondenseMethod::DiffMinCut(DiffCutConfig { + num_clusters: task.classes * 3, // a few super-nodes per class -> more GNN training signal + restarts: 3, + iterations: 500, + ..Default::default() + }), + ), + ] { + let c = GraphCondenser::new(CondenseConfig { + method, + normalize_centroids: false, + }) + .condense(&graph, &feats) + .unwrap(); + let (cg, x_cond, lab_cond) = condensed_arrays(&c); + let all: Vec = (0..c.node_count()).collect(); + // Train on condensed, test on the ORIGINAL graph's test split. + let model = Gcn::train(&cfg, &cg, &x_cond, task.dim, &lab_cond, task.classes, &all); + let acc_cond = accuracy(&model.predict(&full_graph, &x_full), &labels, &test); + let retention = if acc_full > 0.0 { + acc_cond / acc_full + } else { + 0.0 + }; + println!( + "{name:>12}: {} -> {} super-nodes ({:.0}x) | test acc {:.1}% | retention {:.1}%", + n, + c.node_count(), + c.node_reduction_ratio(), + acc_cond * 100.0, + retention * 100.0, + ); + } + + println!( + "\nRetention near 100% means a GNN trained on the tiny condensed graph classifies the\n\ + original's held-out nodes about as well as one trained on the full graph — the field's\n\ + core success criterion, here measured on controlled synthetic data." + ); +} diff --git a/crates/ruvector-graph-condense/examples/worldgraph.rs b/crates/ruvector-graph-condense/examples/worldgraph.rs new file mode 100644 index 000000000..6d6ba4c22 --- /dev/null +++ b/crates/ruvector-graph-condense/examples/worldgraph.rs @@ -0,0 +1,173 @@ +//! WorldGraph condensation demo — RuView `WorldGraph -> condense -> OccWorld`. +//! +//! Run: `cargo run -p ruvector-graph-condense --example worldgraph` +//! +//! RuView (github.com/ruvnet/RuView) records `WorldGraph` snapshots — a stream +//! of spatial-occupancy observations from WiFi CSI sensing — and feeds them to +//! an OccWorld world-model retrainer. A day of sensing is millions of +//! observations; training on all of them on an edge device is impractical. +//! +//! This example simulates a small "day" of WorldGraph observations as a feature +//! graph (observations = nodes with occupancy embeddings + an activity label; +//! edges = spatial-temporal adjacency, heavy inside an activity, light across +//! transitions) and condenses it into a handful of **event summaries** — exactly +//! the `EventSummary { embedding, confidence, ... }` shape from the design brief, +//! realised as [`CondensedNode`]s with provenance. + +use rand::rngs::StdRng; +use rand::{Rng, SeedableRng}; +use ruvector_graph_condense::{ + condense, evaluate_full, CondenseConfig, CondenseMethod, DiffCutConfig, GraphCondenser, + NodeFeatures, +}; +use ruvector_mincut::DynamicGraph; + +/// A simulated "day": `num_events` activities, each spanning `obs_per_event` +/// consecutive observations, joined by light transition edges. +struct DaySim { + num_events: usize, + obs_per_event: usize, + num_activities: usize, + dim: usize, + seed: u64, +} + +impl DaySim { + #[allow(clippy::needless_range_loop)] // `e` is the event index, used widely + fn generate(&self) -> (DynamicGraph, NodeFeatures, Vec) { + let mut rng = StdRng::seed_from_u64(self.seed); + let g = DynamicGraph::new(); + let mut feats = NodeFeatures::new(self.dim, self.num_activities); + let mut true_event = Vec::new(); // ground-truth event id per observation + + // Each event gets a distinct occupancy centroid and an activity label. + let centroids: Vec> = (0..self.num_events) + .map(|e| { + let mut c = vec![0f32; self.dim]; + c[e % self.dim] = 5.0 + (e / self.dim) as f32 * 5.0; + c + }) + .collect(); + let activity_of = |e: usize| e % self.num_activities; + + let mut id = 0u64; + let mut first_of_event = Vec::new(); + for e in 0..self.num_events { + first_of_event.push(id); + let prev_first = id; + for i in 0..self.obs_per_event { + let mut emb = centroids[e].clone(); + for x in &mut emb { + *x += rng.gen_range(-0.4..0.4); + } + feats.set(id, emb, activity_of(e)).unwrap(); + true_event.push(e); + // Temporal chain inside the event (heavy edges). + if i > 0 { + let _ = g.insert_edge(id - 1, id, 1.0); + } + // Dense intra-event co-occurrence: link to a few random earlier + // observations of the same event, so each event is a coherent + // community (not a thin chain). + let links = i.min(3); + for _ in 0..links { + let other = prev_first + rng.gen_range(0..i as u64); + let _ = g.insert_edge(other, id, 1.0); + } + id += 1; + } + } + // Light transition edges between consecutive events (person moves zones). + for e in 1..self.num_events { + let a = first_of_event[e] - 1; // last obs of previous event + let b = first_of_event[e]; // first obs of this event + let _ = g.insert_edge(a, b, 0.1); + } + (g, feats, true_event) + } +} + +fn report(title: &str, g: &DynamicGraph, condensed: &ruvector_graph_condense::CondensedGraph) { + let m = evaluate_full(g, condensed); + println!("\n=== {title} ==="); + println!( + " observations (nodes): {} -> condensed events: {} ({:.1}x reduction)", + m.source_nodes, m.condensed_nodes, m.node_reduction_ratio + ); + println!( + " edges: {} -> {} ({:.1}x) intra-weight kept: {:.1}% mean coherence: {:.2}", + m.source_edges, + m.condensed_edges, + m.edge_reduction_ratio, + m.intra_weight_ratio * 100.0, + m.mean_coherence + ); + println!( + " activity purity: {:.1}% cut inflation: {}", + m.label_purity * 100.0, + m.cut_inflation + .map(|c| format!("{c:.3} (1.0 = global cut preserved)")) + .unwrap_or_else(|| "n/a".into()) + ); + println!(" event summaries (CondensedNode == EventSummary):"); + for n in condensed.nodes.iter().take(6) { + println!( + " event {:>2}: {:>3} obs | representative=obs#{:<3} | activity={:?} | confidence(coherence)={:.2}", + n.id, + n.weight, + n.representative, + n.dominant_class(), + n.coherence + ); + } + if condensed.nodes.len() > 6 { + println!(" ... ({} more)", condensed.nodes.len() - 6); + } +} + +fn main() { + let day = DaySim { + num_events: 12, + obs_per_event: 50, + num_activities: 4, + dim: 8, + seed: 2026, + }; + let (graph, features, _truth) = day.generate(); + println!( + "Simulated WorldGraph: {} observations across {} events, {} edges.", + graph.num_vertices(), + day.num_events, + graph.num_edges() + ); + + // 1) Default structure-preserving condensation (weak-boundary) — the + // recommended pipeline for a full-day, many-event WorldGraph. + let weak = condense(&graph, &features).expect("condense"); + report("WeakBoundary (default)", &graph, &weak); + println!( + " -> a day of {} observations becomes {} deployable event summaries \ + (the artifact OccWorld would retrain on).", + graph.num_vertices(), + weak.node_count() + ); + + // 2) Trained differentiable min-cut on the SAME large-K WorldGraph. With + // Adam + warm-start init (default) it now recovers all 12 events — the + // optimisation work that made the trained method viable at scale. + let diff = GraphCondenser::new(CondenseConfig { + method: CondenseMethod::DiffMinCut(DiffCutConfig { + num_clusters: day.num_events, + ..Default::default() + }), + normalize_centroids: false, + }) + .condense(&graph, &features) + .expect("diff condense"); + report("DiffMinCut (trained, Adam + warm-start)", &graph, &diff); + println!( + "\nBoth methods recover the day's events; DiffMinCut now scales to large K \ + via Adam + warm-start (it refines the WeakBoundary prior with the \ + differentiable normalized-cut objective)." + ); +} diff --git a/crates/ruvector-graph-condense/src/condense.rs b/crates/ruvector-graph-condense/src/condense.rs new file mode 100644 index 000000000..11d8aff68 --- /dev/null +++ b/crates/ruvector-graph-condense/src/condense.rs @@ -0,0 +1,500 @@ +//! The condensation engine: partition a feature graph into structural regions +//! and collapse each region into a representative super-node. +//! +//! Unlike gradient-/distribution-matching condensation (GCond, GCDM, SFGC), +//! which *synthesise* a small graph by optimising a learning objective, this is +//! a **structure-preserving** condenser: regions come from the dynamic min-cut +//! community structure, so the condensed topology mirrors the real cut +//! structure of the source graph. Boundary edges survive as weighted +//! super-edges; cuts are preserved by construction rather than by training. + +use crate::diffcut::{DiffCutCondenser, DiffCutConfig}; +use crate::error::{CondenseError, Result}; +use crate::features::NodeFeatures; +use crate::node::{CondensedEdge, CondensedGraph, CondensedNode}; +use crate::regions::{ + centroid_and_medoid, class_distribution, ensure_coverage, l2_normalize, weak_boundary_regions, +}; +use ruvector_mincut::{CommunityDetector, DynamicGraph, GraphPartitioner, VertexId}; +use std::collections::HashMap; +use std::sync::Arc; + +/// How the source graph is partitioned into regions before collapsing. +/// +/// Note: region detection only decides *membership*. Super-edges are always +/// rebuilt from the original graph's edges, so structure preservation does not +/// depend on the method chosen here. +#[derive(Debug, Clone, PartialEq)] +pub enum CondenseMethod { + /// **Default.** Cut every edge lighter than `relative_threshold * mean + /// edge weight`, then take the connected components of what remains. This + /// is a one-shot approximation to removing the light min-cut boundaries: + /// robust, deterministic, and effective whenever intra-community edges are + /// heavier than inter ones. With near-uniform weights it degrades gracefully + /// to [`CondenseMethod::ConnectedComponents`]. + WeakBoundary { + /// Fraction of the mean edge weight below which an edge is treated as a + /// boundary and removed. `0.5` is a sensible default. + relative_threshold: f64, + }, + /// Recursive min-cut community detection via + /// [`ruvector_mincut::CommunityDetector`]. Structure-aware for graphs with + /// clear bottlenecks, but recursive *global* min cut tends to peel off + /// single low-degree vertices otherwise (many tiny regions); prefer + /// [`CondenseMethod::WeakBoundary`]. `min_region_size` bounds recursion. + MinCutCommunity { + /// Recursion stops splitting regions at or below this size. + min_region_size: usize, + }, + /// Recursive bisection into up to `num_regions` regions via + /// [`ruvector_mincut::GraphPartitioner`]. Effective on clustered graphs; + /// reduction is graph-dependent (the bisection can peel single vertices, + /// which become singleton regions). Prefer [`CondenseMethod::WeakBoundary`]. + Partition { + /// Target number of regions. + num_regions: usize, + }, + /// **Differentiable min-cut** (relaxed normalized cut, MinCutPool-style): + /// learns a soft `N×K` assignment by gradient descent on a cut + + /// orthogonality loss, then hardens it (argmax) into regions. The only + /// method whose regions are *trained* to preserve the cut — see + /// [`crate::diffcut`]. `K` upper-bounds the super-node count. + DiffMinCut(DiffCutConfig), + /// Cheap baseline: one region per connected component. + ConnectedComponents, +} + +impl Default for CondenseMethod { + fn default() -> Self { + CondenseMethod::WeakBoundary { + relative_threshold: 0.5, + } + } +} + +/// Configuration for [`GraphCondenser`]. +/// +/// `Default` yields [`CondenseMethod::WeakBoundary`] with a `0.5` threshold and +/// no centroid normalisation. +#[derive(Debug, Clone, Default)] +pub struct CondenseConfig { + /// Region partitioning strategy. + pub method: CondenseMethod, + /// L2-normalise centroids after averaging (useful for cosine-space + /// embeddings such as HNSW vectors). + pub normalize_centroids: bool, +} + +/// Stateless condenser. Construct once with a [`CondenseConfig`] and reuse +/// across graphs. +#[derive(Debug, Clone, Default)] +pub struct GraphCondenser { + config: CondenseConfig, +} + +impl GraphCondenser { + /// Create a condenser with the given configuration. + pub fn new(config: CondenseConfig) -> Self { + Self { config } + } + + /// Borrow the active configuration. + pub fn config(&self) -> &CondenseConfig { + &self.config + } + + /// Condense `graph` using the per-vertex `features`. + /// + /// Every vertex in `graph` must have an embedding in `features` (a vertex + /// with no incident edges is still condensed, as a singleton region). + /// + /// # Errors + /// - [`CondenseError::EmptyGraph`] if the graph has no vertices. + /// - [`CondenseError::MissingFeature`] if a vertex lacks an embedding. + /// - [`CondenseError::InvalidConfig`] for a degenerate configuration. + pub fn condense( + &self, + graph: &DynamicGraph, + features: &NodeFeatures, + ) -> Result { + let vertices = graph.vertices(); + if vertices.is_empty() { + return Err(CondenseError::EmptyGraph); + } + let dim = features.dim(); + let num_classes = features.num_classes(); + + // 1. Partition into structural regions, then guarantee full coverage + // and a deterministic ordering (region id == position). + let mut regions = self.partition_regions(graph)?; + ensure_coverage(&mut regions, &vertices); + for r in &mut regions { + r.sort_unstable(); + } + regions.retain(|r| !r.is_empty()); + regions.sort_by(|a, b| a[0].cmp(&b[0])); + + // 2. Vertex -> region index. + let mut region_of: HashMap = HashMap::with_capacity(vertices.len()); + for (ri, members) in regions.iter().enumerate() { + for &v in members { + region_of.insert(v, ri); + } + } + + // 3. Single edge pass: internal vs boundary weight (for coherence) and + // super-edge accumulation. + let n = regions.len(); + let mut internal_w = vec![0f64; n]; + let mut boundary_w = vec![0f64; n]; + let mut super_edges: HashMap<(usize, usize), (f64, u32)> = HashMap::new(); + + for e in graph.edges() { + // region_of is total over graph vertices after ensure_coverage. + let rs = region_of[&e.source]; + let rt = region_of[&e.target]; + if rs == rt { + internal_w[rs] += e.weight; + } else { + boundary_w[rs] += e.weight; + boundary_w[rt] += e.weight; + let key = if rs < rt { (rs, rt) } else { (rt, rs) }; + let slot = super_edges.entry(key).or_insert((0.0, 0)); + slot.0 += e.weight; + slot.1 += 1; + } + } + + // 4. Build super-nodes. + let mut nodes = Vec::with_capacity(n); + for (ri, members) in regions.iter().enumerate() { + let (mut centroid, representative) = centroid_and_medoid(members, features, dim)?; + if self.config.normalize_centroids { + l2_normalize(&mut centroid); + } + let class_distribution = class_distribution(members, features, num_classes); + let iw = internal_w[ri]; + let bw = boundary_w[ri]; + let coherence = if iw + bw <= 0.0 { + 1.0 + } else { + (iw / (iw + bw)) as f32 + }; + nodes.push(CondensedNode { + id: ri as u64, + centroid, + weight: members.len() as u32, + class_distribution, + coherence, + representative, + members: members.clone(), + }); + } + + // 5. Build super-edges (region index == id), canonical & sorted. + let mut edges: Vec = super_edges + .into_iter() + .map(|((s, t), (w, c))| CondensedEdge { + source: s as u64, + target: t as u64, + weight: w, + crossings: c, + }) + .collect(); + edges.sort_by_key(|e| (e.source, e.target)); + + Ok(CondensedGraph { + nodes, + edges, + source_nodes: vertices.len(), + source_edges: graph.num_edges(), + dim, + num_classes, + }) + } + + fn partition_regions(&self, graph: &DynamicGraph) -> Result>> { + match &self.config.method { + CondenseMethod::ConnectedComponents => Ok(graph.connected_components()), + CondenseMethod::WeakBoundary { relative_threshold } => { + Ok(weak_boundary_regions(graph, *relative_threshold)) + } + CondenseMethod::MinCutCommunity { min_region_size } => { + let arc = Arc::new(graph.clone()); + let mut detector = CommunityDetector::new(arc); + Ok(detector.detect(*min_region_size).to_vec()) + } + CondenseMethod::Partition { num_regions } => { + if *num_regions == 0 { + return Err(CondenseError::InvalidConfig( + "num_regions must be > 0".to_string(), + )); + } + let arc = Arc::new(graph.clone()); + let partitioner = GraphPartitioner::new(arc, *num_regions); + Ok(partitioner.partition()) + } + CondenseMethod::DiffMinCut(cfg) => { + let result = DiffCutCondenser::new(cfg.clone()).train(graph)?; + Ok(result.hard_regions()) + } + } + } +} + +/// Convenience wrapper: condense with default ([`CondenseMethod::WeakBoundary`]) +/// settings. +pub fn condense(graph: &DynamicGraph, features: &NodeFeatures) -> Result { + GraphCondenser::default().condense(graph, features) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn two_triangles() -> (DynamicGraph, NodeFeatures) { + let g = DynamicGraph::new(); + for &(u, v, w) in &[ + (0, 1, 1.0), + (1, 2, 1.0), + (2, 0, 1.0), + (3, 4, 1.0), + (4, 5, 1.0), + (5, 3, 1.0), + (2, 3, 0.05), + ] { + g.insert_edge(u, v, w).unwrap(); + } + let mut f = NodeFeatures::new(2, 2); + // Cluster A near (0,0) labelled 0, cluster B near (10,10) labelled 1. + for v in 0..3u64 { + f.set(v, vec![v as f32 * 0.01, 0.0], 0).unwrap(); + } + for v in 3..6u64 { + f.set(v, vec![10.0 + v as f32 * 0.01, 10.0], 1).unwrap(); + } + (g, f) + } + + #[test] + fn empty_graph_errors() { + let g = DynamicGraph::new(); + let f = NodeFeatures::new(2, 0); + assert!(matches!( + condense(&g, &f).unwrap_err(), + CondenseError::EmptyGraph + )); + } + + #[test] + fn missing_feature_errors() { + let g = DynamicGraph::new(); + g.insert_edge(0, 1, 1.0).unwrap(); + let mut f = NodeFeatures::new(2, 0); + f.set_embedding(0, vec![0.0, 0.0]).unwrap(); + // vertex 1 has no feature + assert!(matches!( + condense(&g, &f).unwrap_err(), + CondenseError::MissingFeature(1) + )); + } + + #[test] + fn condenses_two_communities() { + let (g, f) = two_triangles(); + let c = condense(&g, &f).unwrap(); + // Should collapse 6 nodes into 2 communities. + assert_eq!(c.source_nodes, 6); + assert_eq!(c.node_count(), 2); + assert_eq!(c.total_weight(), 6); + // Exactly one super-edge across the bridge. + assert_eq!(c.edge_count(), 1); + let e = c.edges[0]; + assert_eq!((e.source, e.target), (0, 1)); + assert_eq!(e.crossings, 1); + assert!((e.weight - 0.05).abs() < 1e-9); + // Region ids are deterministic & sorted; first region holds {0,1,2}. + assert_eq!(c.nodes[0].members, vec![0, 1, 2]); + assert_eq!(c.nodes[1].members, vec![3, 4, 5]); + // Pure, well-formed class distributions. + assert_eq!(c.nodes[0].dominant_class(), Some(0)); + assert_eq!(c.nodes[1].dominant_class(), Some(1)); + assert!(c.nodes[0].purity() > 0.99); + // High internal cohesion (3 internal edges vs 0.05 boundary). + assert!(c.nodes[0].coherence > 0.9); + } + + #[test] + fn mincut_community_recovers_clear_bottleneck() { + // Dense triangles (weight 5) joined by a single light bridge (weight 1). + // This is the regime where recursive min-cut community detection works: + // a sharp bottleneck and no low-degree vertices to peel. + let g = DynamicGraph::new(); + for &(u, v, w) in &[ + (0, 1, 5.0), + (1, 2, 5.0), + (2, 0, 5.0), + (3, 4, 5.0), + (4, 5, 5.0), + (5, 3, 5.0), + (2, 3, 1.0), + ] { + g.insert_edge(u, v, w).unwrap(); + } + let mut f = NodeFeatures::new(1, 0); + for v in 0..6u64 { + f.set_embedding(v, vec![v as f32]).unwrap(); + } + let c = GraphCondenser::new(CondenseConfig { + method: CondenseMethod::MinCutCommunity { min_region_size: 2 }, + normalize_centroids: false, + }) + .condense(&g, &f) + .unwrap(); + // The min-cut engine reduces and fully covers the graph. It may split + // more finely than the planted 2 communities (recursive global min cut + // is aggressive); we assert reduction + coverage, not exact recovery — + // exact community recovery is the default WeakBoundary method's job. + assert_eq!(c.total_weight(), 6); // full coverage + assert!(c.node_count() >= 2 && c.node_count() < 6); // runs + reduces + } + + #[test] + fn diff_mincut_condenses_via_trained_assignment() { + use crate::diffcut::DiffCutConfig; + let (g, f) = two_triangles(); + let c = GraphCondenser::new(CondenseConfig { + method: CondenseMethod::DiffMinCut(DiffCutConfig { + num_clusters: 2, + ..Default::default() + }), + normalize_centroids: false, + }) + .condense(&g, &f) + .unwrap(); + assert_eq!(c.node_count(), 2); + assert_eq!(c.total_weight(), 6); + assert_eq!(c.nodes[0].members, vec![0, 1, 2]); + assert_eq!(c.nodes[1].members, vec![3, 4, 5]); + assert_eq!(c.edge_count(), 1); // the bridge -> one super-edge + } + + #[test] + fn weak_boundary_falls_back_to_components_without_contrast() { + // Uniform weights -> no edge is below 0.5*mean -> nothing cut -> + // regions equal connected components (here, one). + let g = DynamicGraph::new(); + for &(u, v) in &[(0, 1), (1, 2), (2, 0)] { + g.insert_edge(u, v, 1.0).unwrap(); + } + let mut f = NodeFeatures::new(1, 0); + for v in 0..3u64 { + f.set_embedding(v, vec![v as f32]).unwrap(); + } + let c = condense(&g, &f).unwrap(); // default WeakBoundary + assert_eq!(c.node_count(), 1); + assert_eq!(c.total_weight(), 3); + } + + #[test] + fn partition_runs_and_covers() { + // GraphPartitioner is best-effort; we assert it runs and covers every + // vertex exactly once (reduction is graph-dependent, not guaranteed). + let g = DynamicGraph::new(); + for i in 0..15u64 { + g.insert_edge(i, i + 1, 1.0).unwrap(); + } + let mut f = NodeFeatures::new(1, 0); + for v in 0..16u64 { + f.set_embedding(v, vec![v as f32]).unwrap(); + } + let c = GraphCondenser::new(CondenseConfig { + method: CondenseMethod::Partition { num_regions: 4 }, + normalize_centroids: false, + }) + .condense(&g, &f) + .unwrap(); + assert_eq!(c.total_weight(), 16); // full, non-overlapping coverage + assert!(c.node_count() <= 16 && c.node_count() >= 1); + } + + #[test] + fn partition_zero_regions_errors() { + let g = DynamicGraph::new(); + g.insert_edge(0, 1, 1.0).unwrap(); + let mut f = NodeFeatures::new(1, 0); + f.set_embedding(0, vec![0.0]).unwrap(); + f.set_embedding(1, vec![1.0]).unwrap(); + let err = GraphCondenser::new(CondenseConfig { + method: CondenseMethod::Partition { num_regions: 0 }, + normalize_centroids: false, + }) + .condense(&g, &f) + .unwrap_err(); + assert!(matches!(err, CondenseError::InvalidConfig(_))); + } + + #[test] + fn coverage_includes_isolated_vertex() { + let g = DynamicGraph::new(); + g.insert_edge(0, 1, 1.0).unwrap(); + g.add_vertex(99); // isolated + let mut f = NodeFeatures::new(1, 0); + for v in [0u64, 1, 99] { + f.set_embedding(v, vec![v as f32]).unwrap(); + } + let c = GraphCondenser::new(CondenseConfig { + method: CondenseMethod::ConnectedComponents, + normalize_centroids: false, + }) + .condense(&g, &f) + .unwrap(); + // {0,1} component + {99} singleton = 2 regions covering all vertices. + assert_eq!(c.total_weight(), 3); + assert!(c.nodes.iter().any(|n| n.members == vec![99])); + } + + #[test] + fn centroid_is_member_mean_and_medoid_valid() { + let g = DynamicGraph::new(); + g.insert_edge(0, 1, 1.0).unwrap(); + g.insert_edge(1, 2, 1.0).unwrap(); + g.insert_edge(2, 0, 1.0).unwrap(); + let mut f = NodeFeatures::new(1, 0); + f.set_embedding(0, vec![0.0]).unwrap(); + f.set_embedding(1, vec![2.0]).unwrap(); + f.set_embedding(2, vec![4.0]).unwrap(); + let c = GraphCondenser::new(CondenseConfig { + method: CondenseMethod::ConnectedComponents, + normalize_centroids: false, + }) + .condense(&g, &f) + .unwrap(); + assert_eq!(c.node_count(), 1); + assert!((c.nodes[0].centroid[0] - 2.0).abs() < 1e-6); + // Medoid is the member nearest the mean (2.0) -> vertex 1. + assert_eq!(c.nodes[0].representative, 1); + } + + #[test] + fn normalize_centroids_unit_length() { + let g = DynamicGraph::new(); + g.insert_edge(0, 1, 1.0).unwrap(); + let mut f = NodeFeatures::new(2, 0); + f.set_embedding(0, vec![3.0, 0.0]).unwrap(); + f.set_embedding(1, vec![3.0, 0.0]).unwrap(); + let c = GraphCondenser::new(CondenseConfig { + method: CondenseMethod::ConnectedComponents, + normalize_centroids: true, + }) + .condense(&g, &f) + .unwrap(); + let norm: f32 = c.nodes[0] + .centroid + .iter() + .map(|x| x * x) + .sum::() + .sqrt(); + assert!((norm - 1.0).abs() < 1e-6); + } +} diff --git a/crates/ruvector-graph-condense/src/cutloss.rs b/crates/ruvector-graph-condense/src/cutloss.rs new file mode 100644 index 000000000..9be0ce398 --- /dev/null +++ b/crates/ruvector-graph-condense/src/cutloss.rs @@ -0,0 +1,477 @@ +//! Differentiable relaxed-min-cut loss core (all maths in `f64`). +//! +//! Pure functions shared by [`crate::diffcut`]: the compact graph view, the +//! row-softmax, the loss (`L_cut + λ·L_ortho`) and its analytic gradients. Kept +//! separate from the optimiser/orchestration so each file stays small and the +//! gradient-checked maths is isolated. + +#[cfg(feature = "parallel")] +use rayon::prelude::*; +use ruvector_mincut::{DynamicGraph, VertexId}; +use std::collections::HashMap; + +pub(crate) const EPS: f64 = 1e-12; + +/// The three components of the loss at a point. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct MinCutLoss { + /// Relaxed normalized-cut term in `[-1, 0]` (lower is better). + pub cut: f64, + /// Orthogonality / balance term in `[0, 2]` (lower is better). + pub ortho: f64, + /// `cut + λ·ortho`. + pub total: f64, +} + +/// Contiguous, index-mapped view of a graph for the loss maths. +/// +/// Carries both an edge list (for minibatch scatter) and a CSR adjacency (for +/// conflict-free, row-parallel `A·S`). +pub(crate) struct CompactGraph { + pub(crate) n: usize, + pub(crate) degree: Vec, + pub(crate) edges: Vec<(usize, usize, f64)>, + pub(crate) vertices: Vec, + /// CSR row offsets, length `n + 1`. + nbr_off: Vec, + /// CSR neighbours `(col, weight)`, length `2 * num_edges`. + nbr: Vec<(usize, f64)>, +} + +impl CompactGraph { + pub(crate) fn from_graph(graph: &DynamicGraph) -> Self { + let mut vertices = graph.vertices(); + vertices.sort_unstable(); // deterministic row order + let mut index: HashMap = HashMap::with_capacity(vertices.len()); + for (i, &v) in vertices.iter().enumerate() { + index.insert(v, i); + } + let n = vertices.len(); + let mut degree = vec![0f64; n]; + let mut edges = Vec::with_capacity(graph.num_edges()); + let mut deg_count = vec![0usize; n]; + for e in graph.edges() { + let i = index[&e.source]; + let j = index[&e.target]; + edges.push((i, j, e.weight)); + degree[i] += e.weight; + degree[j] += e.weight; + deg_count[i] += 1; + deg_count[j] += 1; + } + // Build CSR (both directions) from the edge list. + let mut nbr_off = vec![0usize; n + 1]; + for i in 0..n { + nbr_off[i + 1] = nbr_off[i] + deg_count[i]; + } + let mut cursor = nbr_off[..n].to_vec(); + let mut nbr = vec![(0usize, 0f64); edges.len() * 2]; + for &(i, j, w) in &edges { + nbr[cursor[i]] = (j, w); + cursor[i] += 1; + nbr[cursor[j]] = (i, w); + cursor[j] += 1; + } + Self { + n, + degree, + edges, + vertices, + nbr_off, + nbr, + } + } + + /// Vertex-id → row-index map (rows are sorted-ascending vertices). + pub(crate) fn index_map(&self) -> HashMap { + self.vertices + .iter() + .enumerate() + .map(|(i, &v)| (v, i)) + .collect() + } +} + +pub(crate) fn softmax_rows(logits: &[f64], n: usize, k: usize) -> Vec { + let mut s = vec![0f64; n * k]; + for i in 0..n { + let row = &logits[i * k..(i + 1) * k]; + let max = row.iter().copied().fold(f64::NEG_INFINITY, f64::max); + let mut sum = 0f64; + for c in 0..k { + let e = (row[c] - max).exp(); + s[i * k + c] = e; + sum += e; + } + let inv = 1.0 / sum; + for c in 0..k { + s[i * k + c] *= inv; + } + } + s +} + +/// `A · S` (`N×K`) via CSR — each output row depends only on its node's +/// neighbours, so it is conflict-free and row-parallel. Deterministic +/// regardless of thread count (fixed row + neighbour order). +pub(crate) fn as_matrix( + g: &CompactGraph, + s: &[f64], + n: usize, + k: usize, + parallel: bool, +) -> Vec { + let mut as_mat = vec![0f64; n * k]; + let row_fn = |i: usize, row: &mut [f64]| { + for idx in g.nbr_off[i]..g.nbr_off[i + 1] { + let (j, w) = g.nbr[idx]; + let sj = &s[j * k..(j + 1) * k]; + for c in 0..k { + row[c] += w * sj[c]; + } + } + }; + #[cfg(feature = "parallel")] + if parallel { + as_mat + .par_chunks_mut(k) + .enumerate() + .for_each(|(i, row)| row_fn(i, row)); + return as_mat; + } + let _ = parallel; + as_mat + .chunks_mut(k) + .enumerate() + .for_each(|(i, row)| row_fn(i, row)); + as_mat +} + +/// Stochastic `A · S` estimate from a sampled subset of edges, scaled by +/// `|E| / |sample|`. O(|sample|·K) per call — the lever for million-edge graphs. +pub(crate) fn as_matrix_minibatch( + g: &CompactGraph, + s: &[f64], + n: usize, + k: usize, + sample: &[usize], +) -> Vec { + let mut as_mat = vec![0f64; n * k]; + if sample.is_empty() { + return as_mat; + } + let scale = g.edges.len() as f64 / sample.len() as f64; + for &e in sample { + let (i, j, w) = g.edges[e]; + let ws = w * scale; + for c in 0..k { + as_mat[i * k + c] += ws * s[j * k + c]; + as_mat[j * k + c] += ws * s[i * k + c]; + } + } + as_mat +} + +/// Forward-only loss (full-batch, sequential). +pub(crate) fn forward(g: &CompactGraph, s: &[f64], k: usize, lambda: f64) -> MinCutLoss { + let as_mat = as_matrix(g, s, g.n, k, false); + let (cut, _, ortho, _) = cut_and_ortho(g, s, &as_mat, k, false, false); + MinCutLoss { + cut, + ortho, + total: cut + lambda * ortho, + } +} + +/// Loss and gradient w.r.t. `S` (full-batch, sequential) — convenience used by +/// the gradient-check test. +#[cfg(test)] +pub(crate) fn loss_and_grad_wrt_soft( + g: &CompactGraph, + s: &[f64], + k: usize, + lambda: f64, +) -> (MinCutLoss, Vec) { + let as_mat = as_matrix(g, s, g.n, k, false); + loss_and_grad_with_as(g, s, &as_mat, k, lambda, false) +} + +/// Loss and gradient given a precomputed `A·S`. `parallel` parallelises the +/// heavy `O(N·K²)` loops (SᵀS build, ortho gradient) deterministically. +pub(crate) fn loss_and_grad_with_as( + g: &CompactGraph, + s: &[f64], + as_mat: &[f64], + k: usize, + lambda: f64, + parallel: bool, +) -> (MinCutLoss, Vec) { + let (cut, grad_cut, ortho, grad_ortho) = cut_and_ortho(g, s, as_mat, k, true, parallel); + let n = g.n; + let mut grad = grad_cut; + for idx in 0..n * k { + grad[idx] += lambda * grad_ortho[idx]; + } + ( + MinCutLoss { + cut, + ortho, + total: cut + lambda * ortho, + }, + grad, + ) +} + +/// Rows per Rayon task — coarse enough to amortise dispatch overhead. +fn rows_per_task(n: usize) -> usize { + #[cfg(feature = "parallel")] + let threads = rayon::current_num_threads(); + #[cfg(not(feature = "parallel"))] + let threads = 1usize; + (n / (threads * 4)).max(1) +} + +/// `P = SᵀS` (`K×K`). Both paths use the *same* chunked partial-sum ordering +/// (parallel only changes who computes each chunk), so parallel is bit-identical +/// to sequential — no float-reordering surprises. +fn gram(s: &[f64], n: usize, k: usize, parallel: bool) -> Vec { + let chunk = rows_per_task(n) * k; + let acc_block = |block: &[f64]| -> Vec { + let mut local = vec![0f64; k * k]; + for row in block.chunks(k) { + for a in 0..k { + let sa = row[a]; + if sa != 0.0 { + for b in 0..k { + local[a * k + b] += sa * row[b]; + } + } + } + } + local + }; + #[cfg(feature = "parallel")] + let partials: Vec> = if parallel { + s.par_chunks(chunk).map(acc_block).collect() + } else { + s.chunks(chunk).map(acc_block).collect() + }; + #[cfg(not(feature = "parallel"))] + let partials: Vec> = { + let _ = parallel; + s.chunks(chunk).map(acc_block).collect() + }; + let mut p = vec![0f64; k * k]; + for part in partials { + for i in 0..k * k { + p[i] += part[i]; + } + } + p +} + +/// Shared core given a precomputed `A·S`: (cut, grad_cut, ortho, grad_ortho). +/// The gradient vectors are empty when `want_grad` is false. +fn cut_and_ortho( + g: &CompactGraph, + s: &[f64], + as_mat: &[f64], + k: usize, + want_grad: bool, + parallel: bool, +) -> (f64, Vec, f64, Vec) { + let n = g.n; + + // numer = Tr(SᵀAS), denom = Tr(SᵀDS) (O(N·K), kept sequential). + let mut numer = 0f64; + for idx in 0..n * k { + numer += s[idx] * as_mat[idx]; + } + let mut denom = 0f64; + for i in 0..n { + let mut s2 = 0f64; + for c in 0..k { + let v = s[i * k + c]; + s2 += v * v; + } + denom += g.degree[i] * s2; + } + let cut = if denom > EPS { -numer / denom } else { 0.0 }; + + let mut grad_cut = Vec::new(); + if want_grad { + grad_cut = vec![0f64; n * k]; + if denom > EPS { + // ∂L_cut/∂S = -2/denom · (AS + L_cut·DS); rows are independent. + let coef = -2.0 / denom; + let row = |i: usize, gc: &mut [f64]| { + let di = g.degree[i]; + for c in 0..k { + gc[c] = coef * (as_mat[i * k + c] + cut * di * s[i * k + c]); + } + }; + #[cfg(feature = "parallel")] + if parallel { + grad_cut + .par_chunks_mut(k) + .enumerate() + .for_each(|(i, gc)| row(i, gc)); + } else { + grad_cut + .chunks_mut(k) + .enumerate() + .for_each(|(i, gc)| row(i, gc)); + } + #[cfg(not(feature = "parallel"))] + grad_cut + .chunks_mut(k) + .enumerate() + .for_each(|(i, gc)| row(i, gc)); + } + } + + let p = gram(s, n, k, parallel); + let np = p.iter().map(|x| x * x).sum::().sqrt(); + let inv_sqrt_k = 1.0 / (k as f64).sqrt(); + + let mut ortho = 0f64; + let mut q = vec![0f64; k * k]; + if np > EPS { + let mut sq = 0f64; + for a in 0..k { + for b in 0..k { + let target = if a == b { inv_sqrt_k } else { 0.0 }; + let qv = p[a * k + b] / np - target; + q[a * k + b] = qv; + sq += qv * qv; + } + } + ortho = sq.sqrt(); + } + + let mut grad_ortho = Vec::new(); + if want_grad { + grad_ortho = vec![0f64; n * k]; + if np > EPS && ortho > EPS { + // Gf = Q/ortho ; G_P = Gf/np − (⟨Gf,P⟩/np³)·P ; ∂L/∂S = 2·S·G_P + let mut dot = 0f64; + for idx in 0..k * k { + dot += (q[idx] / ortho) * p[idx]; + } + let np3 = np * np * np; + let mut gp = vec![0f64; k * k]; + for idx in 0..k * k { + gp[idx] = (q[idx] / ortho) / np - (dot / np3) * p[idx]; + } + // ∂L/∂S row i = 2 · S[i] · G_P; rows independent. + let row = |s_row: &[f64], go: &mut [f64]| { + for kk in 0..k { + let mut acc = 0f64; + for b in 0..k { + acc += s_row[b] * gp[b * k + kk]; + } + go[kk] = 2.0 * acc; + } + }; + #[cfg(feature = "parallel")] + if parallel { + grad_ortho + .par_chunks_mut(k) + .zip(s.par_chunks(k)) + .for_each(|(go, s_row)| row(s_row, go)); + } else { + grad_ortho + .chunks_mut(k) + .zip(s.chunks(k)) + .for_each(|(go, s_row)| row(s_row, go)); + } + #[cfg(not(feature = "parallel"))] + grad_ortho + .chunks_mut(k) + .zip(s.chunks(k)) + .for_each(|(go, s_row)| row(s_row, go)); + } + } + + (cut, grad_cut, ortho, grad_ortho) +} + +/// Backprop a gradient w.r.t. `S` through the row-softmax to the logits `Θ`. +pub(crate) fn softmax_backprop(s: &[f64], grad_s: &[f64], n: usize, k: usize) -> Vec { + let mut grad = vec![0f64; n * k]; + for i in 0..n { + let mut dot = 0f64; + for c in 0..k { + dot += grad_s[i * k + c] * s[i * k + c]; + } + for c in 0..k { + grad[i * k + c] = s[i * k + c] * (grad_s[i * k + c] - dot); + } + } + grad +} + +#[cfg(test)] +mod tests { + use super::*; + + fn barbell() -> DynamicGraph { + let g = DynamicGraph::new(); + for &(u, v, w) in &[ + (0, 1, 1.0), + (1, 2, 1.0), + (2, 0, 1.0), + (3, 4, 1.0), + (4, 5, 1.0), + (5, 3, 1.0), + (2, 3, 0.05), + ] { + g.insert_edge(u, v, w).unwrap(); + } + g + } + + #[test] + fn gradient_matches_finite_differences() { + // Decisive correctness test: analytic ∂L/∂Θ vs finite differences across + // several K (proves the K-general gradient formulas, not just K=2). + use rand::rngs::StdRng; + use rand::{Rng, SeedableRng}; + let g = CompactGraph::from_graph(&barbell()); + let n = g.n; + let lambda = 1.0; + let h = 1e-6; + for k in [2usize, 3, 4] { + let mut rng = StdRng::seed_from_u64(99 + k as u64); + let mut theta = vec![0f64; n * k]; + for t in &mut theta { + *t = rng.gen_range(-0.5..0.5); + } + let s = softmax_rows(&theta, n, k); + let (_, grad_s) = loss_and_grad_wrt_soft(&g, &s, k, lambda); + let analytic = softmax_backprop(&s, &grad_s, n, k); + let mut max_abs_err = 0f64; + for idx in 0..n * k { + let mut tp = theta.clone(); + tp[idx] += h; + let lp = forward(&g, &softmax_rows(&tp, n, k), k, lambda).total; + let mut tm = theta.clone(); + tm[idx] -= h; + let lm = forward(&g, &softmax_rows(&tm, n, k), k, lambda).total; + let num = (lp - lm) / (2.0 * h); + max_abs_err = max_abs_err.max((num - analytic[idx]).abs()); + } + assert!(max_abs_err < 1e-5, "k={k}: grad mismatch {max_abs_err}"); + } + } + + #[test] + fn uniform_assignment_fools_cut_but_not_ortho() { + let g = CompactGraph::from_graph(&barbell()); + let soft = vec![0.5f64; g.n * 2]; + let l = forward(&g, &soft, 2, 1.0); + // numer==denom -> cut "fooled" to -1; ortho catches the collapse. + assert!((l.cut + 1.0).abs() < 1e-9, "cut {}", l.cut); + assert!(l.ortho > 0.5, "ortho {}", l.ortho); + } +} diff --git a/crates/ruvector-graph-condense/src/diffcut.rs b/crates/ruvector-graph-condense/src/diffcut.rs new file mode 100644 index 000000000..5faeb2a14 --- /dev/null +++ b/crates/ruvector-graph-condense/src/diffcut.rs @@ -0,0 +1,449 @@ +//! Trainable differentiable min-cut condenser — the relaxed normalized-cut +//! objective (MinCutPool-style; loss + analytic gradients live in +//! [`crate::cutloss`]) optimised into a cluster assignment. +//! +//! The 2024–2026 surveys flag a differentiable min-cut term in the condensation +//! loss as unpublished. This module makes that objective practical **on large-K +//! problems** with three standard-but-essential ingredients: +//! +//! - **Adam** (default) instead of plain GD — adaptive, robust on the +//! ill-conditioned, non-convex cut objective. +//! - **Warm-start init** (default) — seed the logits from the cheap +//! [`crate::CondenseMethod::WeakBoundary`] structural prior and *refine* with +//! the differentiable objective, rather than descending from random noise. +//! This is the same coreset/K-Center idea GCond/SFGC use, and it is what makes +//! K ≫ 2 converge. +//! - **Restarts** — keep the lowest-loss run. +//! +//! Hardening the trained assignment (argmax) yields the regions consumed by +//! [`crate::condense`] via [`crate::CondenseMethod::DiffMinCut`]. + +use crate::cutloss::{ + as_matrix, as_matrix_minibatch, forward, loss_and_grad_with_as, softmax_backprop, softmax_rows, + CompactGraph, +}; +use crate::error::{CondenseError, Result}; +use rand::rngs::StdRng; +use rand::{Rng, SeedableRng}; +use ruvector_mincut::{DynamicGraph, VertexId}; +use std::collections::HashMap; + +pub use crate::cutloss::MinCutLoss; + +/// First-order optimiser used to minimise the loss. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Optimizer { + /// (Heavy-ball) stochastic gradient descent. `momentum = 0` is plain GD. + Sgd { + /// Momentum coefficient in `[0, 1)`. + momentum: f64, + }, + /// Adam — adaptive moments; far more robust for large `K`. + Adam { + /// First-moment decay (typ. 0.9). + beta1: f64, + /// Second-moment decay (typ. 0.999). + beta2: f64, + /// Numerical-stability epsilon (typ. 1e-8). + epsilon: f64, + }, +} + +impl Default for Optimizer { + fn default() -> Self { + Optimizer::Adam { + beta1: 0.9, + beta2: 0.999, + epsilon: 1e-8, + } + } +} + +/// How the cluster logits are initialised before optimisation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum InitStrategy { + /// Unit-scale random logits. + Random, + /// **Default.** Seed from the [`crate::CondenseMethod::WeakBoundary`] + /// structural prior, then refine — the key to large-K convergence. + #[default] + WarmStart, +} + +/// Configuration for the differentiable min-cut condenser. `Default` is a +/// large-K-ready setup: Adam + warm-start. +#[derive(Debug, Clone, PartialEq)] +pub struct DiffCutConfig { + /// Number of clusters `K` (upper bound on condensed super-nodes). + pub num_clusters: usize, + /// Weight `λ` on the orthogonality (anti-collapse) term. + pub ortho_weight: f64, + /// Optimiser step size (Adam likes ~0.05; SGD ~0.3). + pub learning_rate: f64, + /// Number of optimisation iterations per restart. + pub iterations: usize, + /// Optimiser. + pub optimizer: Optimizer, + /// Logit initialisation strategy. + pub init: InitStrategy, + /// Number of independent restarts; the lowest-loss run wins (min 1). + pub restarts: usize, + /// Early-stop when the loss improves by less than this between iterations + /// (`0.0` disables). Warm-start starts near the optimum, so this typically + /// cuts most of `iterations`. + pub tolerance: f64, + /// Use Rayon to parallelise the per-iteration `A·S` and parameter update. + /// Deterministic (row-parallel); pays off on large graphs, adds overhead on + /// tiny ones, so it defaults to `false`. + pub parallel: bool, + /// If `Some(b)`, estimate the gradient from `b` randomly sampled edges per + /// iteration (stochastic) instead of the full edge set — the lever for + /// million-edge graphs. `None` = full batch (exact). + pub minibatch_edges: Option, + /// RNG seed (determinism). + pub seed: u64, +} + +impl Default for DiffCutConfig { + fn default() -> Self { + Self { + num_clusters: 8, + ortho_weight: 1.0, + learning_rate: 0.05, + iterations: 300, + optimizer: Optimizer::default(), + init: InitStrategy::default(), + restarts: 1, + tolerance: 1e-6, + parallel: false, + minibatch_edges: None, + seed: 0x0D1F_FC07, + } + } +} + +impl DiffCutConfig { + fn validate(&self) -> Result<()> { + if self.num_clusters == 0 { + return Err(CondenseError::InvalidConfig( + "num_clusters must be > 0".to_string(), + )); + } + Ok(()) + } +} + +/// Result of training: the learned assignment plus provenance. +#[derive(Debug, Clone)] +pub struct DiffCutResult { + soft: Vec, + vertices: Vec, + k: usize, + loss: MinCutLoss, + iterations_run: usize, +} + +impl DiffCutResult { + /// Number of clusters `K`. + pub fn num_clusters(&self) -> usize { + self.k + } + + /// Final (best-restart) loss. + pub fn loss(&self) -> MinCutLoss { + self.loss + } + + /// Iterations actually run in the best restart (≤ `iterations`; lower when + /// early-stopping triggered). + pub fn iterations_run(&self) -> usize { + self.iterations_run + } + + /// Borrow the soft assignment matrix (row-major `N×K`). + pub fn soft_assignment(&self) -> &[f64] { + &self.soft + } + + /// Hard regions: group vertices by argmax cluster. Empty clusters are + /// dropped; every vertex is assigned exactly once. + pub fn hard_regions(&self) -> Vec> { + let mut buckets: HashMap> = HashMap::new(); + for i in 0..self.vertices.len() { + let row = &self.soft[i * self.k..(i + 1) * self.k]; + let mut best = 0usize; + let mut best_v = row[0]; + for (c, &v) in row.iter().enumerate().skip(1) { + if v > best_v { + best_v = v; + best = c; + } + } + buckets.entry(best).or_default().push(self.vertices[i]); + } + buckets.into_values().collect() + } +} + +/// Trainable differentiable min-cut condenser. +#[derive(Debug, Clone)] +pub struct DiffCutCondenser { + config: DiffCutConfig, +} + +impl DiffCutCondenser { + /// Create a condenser with the given configuration. + pub fn new(config: DiffCutConfig) -> Self { + Self { config } + } + + /// Borrow the configuration. + pub fn config(&self) -> &DiffCutConfig { + &self.config + } + + /// Train the soft assignment by minimising the min-cut loss. + /// + /// # Errors + /// [`CondenseError::EmptyGraph`] for a graph with no vertices, or + /// [`CondenseError::InvalidConfig`] for `num_clusters == 0`. + pub fn train(&self, graph: &DynamicGraph) -> Result { + self.config.validate()?; + let g = CompactGraph::from_graph(graph); + if g.n == 0 { + return Err(CondenseError::EmptyGraph); + } + let (n, k) = (g.n, self.config.num_clusters); + let restarts = self.config.restarts.max(1); + + let mut best: Option<(Vec, MinCutLoss, usize)> = None; + for r in 0..restarts { + let seed = self + .config + .seed + .wrapping_add((r as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15)); + let mut rng = StdRng::seed_from_u64(seed); + let mut theta = match self.config.init { + InitStrategy::Random => random_logits(n, k, &mut rng), + InitStrategy::WarmStart => warm_start_logits(&g, graph, k, &mut rng), + }; + let iters = self.optimize(&g, &mut theta, n, k, &mut rng); + let soft = softmax_rows(&theta, n, k); + let loss = forward(&g, &soft, k, self.config.ortho_weight); + if best.as_ref().map_or(true, |(_, b, _)| loss.total < b.total) { + best = Some((soft, loss, iters)); + } + } + + let (soft, loss, iterations_run) = best.expect("restarts >= 1"); + Ok(DiffCutResult { + soft, + vertices: g.vertices, + k, + loss, + iterations_run, + }) + } + + /// Run the configured optimiser in place on `theta`; returns the number of + /// iterations actually performed (early-stops on loss convergence). `rng` + /// drives edge-minibatch sampling when enabled. + fn optimize( + &self, + g: &CompactGraph, + theta: &mut [f64], + n: usize, + k: usize, + rng: &mut StdRng, + ) -> usize { + let lr = self.config.learning_rate; + let lambda = self.config.ortho_weight; + let tol = self.config.tolerance; + let parallel = self.config.parallel; + let nnz = g.edges.len(); + let minibatch = self.config.minibatch_edges.filter(|_| nnz > 0); + let mut prev = f64::INFINITY; + let mut vel = vec![0f64; n * k]; + let mut m = vec![0f64; n * k]; + let mut v = vec![0f64; n * k]; + let mut iters_run = 0; + + for t in 1..=self.config.iterations { + let soft = softmax_rows(theta, n, k); + // A·S: full (parallel optional) or a stochastic edge minibatch. + let as_mat = match minibatch { + Some(b) => { + let b = b.min(nnz); + let sample: Vec = (0..b).map(|_| rng.gen_range(0..nnz)).collect(); + as_matrix_minibatch(g, &soft, n, k, &sample) + } + None => as_matrix(g, &soft, n, k, parallel), + }; + // loss_and_grad gives the loss at the *current* theta for free. + let (loss, grad_s) = loss_and_grad_with_as(g, &soft, &as_mat, k, lambda, parallel); + let gt = softmax_backprop(&soft, &grad_s, n, k); + + match self.config.optimizer { + Optimizer::Sgd { momentum } => { + for idx in 0..n * k { + vel[idx] = momentum * vel[idx] - lr * gt[idx]; + theta[idx] += vel[idx]; + } + } + Optimizer::Adam { + beta1, + beta2, + epsilon, + } => { + let bc1 = 1.0 - beta1.powi(t as i32); + let bc2 = 1.0 - beta2.powi(t as i32); + for idx in 0..n * k { + m[idx] = beta1 * m[idx] + (1.0 - beta1) * gt[idx]; + v[idx] = beta2 * v[idx] + (1.0 - beta2) * gt[idx] * gt[idx]; + let mhat = m[idx] / bc1; + let vhat = v[idx] / bc2; + theta[idx] -= lr * mhat / (vhat.sqrt() + epsilon); + } + } + } + + iters_run = t; + if tol > 0.0 && (prev - loss.total).abs() < tol { + break; + } + prev = loss.total; + } + iters_run + } +} + +/// Unit-scale random logits. +fn random_logits(n: usize, k: usize, rng: &mut StdRng) -> Vec { + let mut theta = vec![0f64; n * k]; + for t in &mut theta { + *t = rng.gen_range(-1.0..1.0); + } + theta +} + +/// Warm-start logits from the WeakBoundary structural prior: each detected +/// region is mapped to a cluster (largest regions get their own; overflow is +/// distributed round-robin) and biased into the logits, plus small noise. +fn warm_start_logits( + g: &CompactGraph, + graph: &DynamicGraph, + k: usize, + rng: &mut StdRng, +) -> Vec { + const BIAS: f64 = 4.0; // softmax(4 vs 0) ~ 0.98 mass on the seeded cluster + let index = g.index_map(); + + let mut regions = crate::regions::weak_boundary_regions(graph, 0.5); + // If the structural prior found no usable split (e.g. an unweighted graph, + // where WeakBoundary collapses to one component), warm-start would seed every + // node into one cluster and the optimiser would stay collapsed. Fall back to + // random init and let the min-cut objective do the partitioning. + if regions.len() < 2 { + return random_logits(g.n, k, rng); + } + // Deterministic order (weak_boundary_regions yields HashMap order): largest + // first, ties broken by smallest member id. + regions.sort_by(|a, b| { + b.len() + .cmp(&a.len()) + .then_with(|| a.iter().min().cmp(&b.iter().min())) + }); + + let mut cluster_of = vec![0usize; g.n]; + for (ri, region) in regions.iter().enumerate() { + let cluster = if ri < k { ri } else { ri % k }; + for v in region { + if let Some(&row) = index.get(v) { + cluster_of[row] = cluster; + } + } + } + + let mut theta = vec![0f64; g.n * k]; + for row in 0..g.n { + for c in 0..k { + theta[row * k + c] = rng.gen_range(-0.1..0.1); + } + theta[row * k + cluster_of[row]] += BIAS; + } + theta +} + +/// Evaluate the min-cut loss for an arbitrary soft assignment (row-major `N×K`, +/// rows in ascending-vertex order). Useful as a quality metric for any +/// assignment, learned or hand-built. +/// +/// # Errors +/// [`CondenseError::DimensionMismatch`] if `soft.len() != N*k`. +pub fn min_cut_loss( + graph: &DynamicGraph, + soft: &[f64], + k: usize, + ortho_weight: f64, +) -> Result { + let g = CompactGraph::from_graph(graph); + if soft.len() != g.n * k { + return Err(CondenseError::DimensionMismatch { + expected: g.n * k, + got: soft.len(), + }); + } + Ok(forward(&g, soft, k, ortho_weight)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn barbell() -> DynamicGraph { + let g = DynamicGraph::new(); + for &(u, v, w) in &[ + (0, 1, 1.0), + (1, 2, 1.0), + (2, 0, 1.0), + (3, 4, 1.0), + (4, 5, 1.0), + (5, 3, 1.0), + (2, 3, 0.05), + ] { + g.insert_edge(u, v, w).unwrap(); + } + g + } + + #[test] + fn warm_start_seeds_a_good_partition() { + // Warm start alone (0 iterations) should already encode the 2 triangles. + let g = barbell(); + let res = DiffCutCondenser::new(DiffCutConfig { + num_clusters: 2, + iterations: 0, + ..Default::default() + }) + .train(&g) + .unwrap(); + let mut regions = res.hard_regions(); + for r in &mut regions { + r.sort_unstable(); + } + regions.sort_by_key(|r| r[0]); + assert_eq!(regions, vec![vec![0, 1, 2], vec![3, 4, 5]]); + } + + #[test] + fn adam_refines_to_low_cut() { + let g = barbell(); + let res = DiffCutCondenser::new(DiffCutConfig { + num_clusters: 2, + ..Default::default() + }) + .train(&g) + .unwrap(); + assert!(res.loss().cut < -0.9, "cut {}", res.loss().cut); + } +} diff --git a/crates/ruvector-graph-condense/src/error.rs b/crates/ruvector-graph-condense/src/error.rs new file mode 100644 index 000000000..0c451830a --- /dev/null +++ b/crates/ruvector-graph-condense/src/error.rs @@ -0,0 +1,35 @@ +//! Error types for graph condensation. + +use thiserror::Error; + +/// Errors that can occur during graph condensation. +#[derive(Debug, Error)] +pub enum CondenseError { + /// The input graph has no vertices, so there is nothing to condense. + #[error("empty graph: nothing to condense")] + EmptyGraph, + + /// A feature vector did not match the configured embedding dimension. + #[error("feature dimension mismatch: expected {expected}, got {got}")] + DimensionMismatch { + /// The dimension the [`crate::NodeFeatures`] container was created with. + expected: usize, + /// The dimension of the offending vector. + got: usize, + }, + + /// A vertex present in the graph had no associated feature vector. + #[error("vertex {0} has no feature vector")] + MissingFeature(u64), + + /// The configuration was internally inconsistent. + #[error("invalid config: {0}")] + InvalidConfig(String), + + /// An error bubbled up from the underlying min-cut engine. + #[error("min-cut engine error: {0}")] + MinCut(#[from] ruvector_mincut::MinCutError), +} + +/// Convenience result alias for condensation operations. +pub type Result = std::result::Result; diff --git a/crates/ruvector-graph-condense/src/features.rs b/crates/ruvector-graph-condense/src/features.rs new file mode 100644 index 000000000..395992076 --- /dev/null +++ b/crates/ruvector-graph-condense/src/features.rs @@ -0,0 +1,142 @@ +//! Per-vertex feature storage (embeddings + optional class labels). +//! +//! Graph condensation needs more than topology: each original vertex carries a +//! feature vector (e.g. a node embedding) and, for supervised settings, a class +//! label. [`NodeFeatures`] is a thin, validated container keyed by the same +//! [`VertexId`](ruvector_mincut::VertexId) used by the min-cut engine's +//! [`DynamicGraph`](ruvector_mincut::DynamicGraph). + +use crate::error::{CondenseError, Result}; +use ruvector_mincut::VertexId; +use std::collections::HashMap; + +/// Feature store mapping graph vertices to embeddings and optional labels. +#[derive(Debug, Clone)] +pub struct NodeFeatures { + dim: usize, + num_classes: usize, + embeddings: HashMap>, + labels: HashMap, +} + +impl NodeFeatures { + /// Create an empty feature store for `dim`-dimensional embeddings. + /// + /// `num_classes` may be `0` for the unsupervised case (no class + /// distributions are produced during condensation). + pub fn new(dim: usize, num_classes: usize) -> Self { + Self { + dim, + num_classes, + embeddings: HashMap::new(), + labels: HashMap::new(), + } + } + + /// Embedding dimension. + pub fn dim(&self) -> usize { + self.dim + } + + /// Number of distinct classes (`0` if unsupervised). + pub fn num_classes(&self) -> usize { + self.num_classes + } + + /// Number of vertices with a stored embedding. + pub fn len(&self) -> usize { + self.embeddings.len() + } + + /// Whether any embeddings are stored. + pub fn is_empty(&self) -> bool { + self.embeddings.is_empty() + } + + /// Insert or replace the embedding for `vertex`. + /// + /// # Errors + /// Returns [`CondenseError::DimensionMismatch`] if `embedding.len() != dim`. + pub fn set_embedding(&mut self, vertex: VertexId, embedding: Vec) -> Result<()> { + if embedding.len() != self.dim { + return Err(CondenseError::DimensionMismatch { + expected: self.dim, + got: embedding.len(), + }); + } + self.embeddings.insert(vertex, embedding); + Ok(()) + } + + /// Attach a class label to `vertex`. Labels at or above `num_classes` are + /// accepted but will be ignored when building class distributions. + pub fn set_label(&mut self, vertex: VertexId, label: usize) { + self.labels.insert(vertex, label); + } + + /// Insert an embedding and label together. + /// + /// # Errors + /// Returns [`CondenseError::DimensionMismatch`] if the embedding dimension + /// is wrong. + pub fn set(&mut self, vertex: VertexId, embedding: Vec, label: usize) -> Result<()> { + self.set_embedding(vertex, embedding)?; + self.set_label(vertex, label); + Ok(()) + } + + /// Borrow the embedding for `vertex`, if present. + pub fn embedding(&self, vertex: VertexId) -> Option<&[f32]> { + self.embeddings.get(&vertex).map(Vec::as_slice) + } + + /// Get the label for `vertex`, if present. + pub fn label(&self, vertex: VertexId) -> Option { + self.labels.get(&vertex).copied() + } + + /// Borrow the embedding for `vertex` or fail with + /// [`CondenseError::MissingFeature`]. + pub(crate) fn require(&self, vertex: VertexId) -> Result<&[f32]> { + self.embedding(vertex) + .ok_or(CondenseError::MissingFeature(vertex)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_wrong_dimension() { + let mut f = NodeFeatures::new(3, 2); + assert!(f.set_embedding(1, vec![0.0, 1.0, 2.0]).is_ok()); + let err = f.set_embedding(2, vec![0.0, 1.0]).unwrap_err(); + assert!(matches!( + err, + CondenseError::DimensionMismatch { + expected: 3, + got: 2 + } + )); + } + + #[test] + fn stores_and_reads_back() { + let mut f = NodeFeatures::new(2, 3); + f.set(7, vec![1.0, 2.0], 1).unwrap(); + assert_eq!(f.embedding(7), Some(&[1.0f32, 2.0][..])); + assert_eq!(f.label(7), Some(1)); + assert_eq!(f.len(), 1); + assert_eq!(f.num_classes(), 3); + } + + #[test] + fn require_reports_missing() { + let f = NodeFeatures::new(2, 0); + assert!(matches!( + f.require(42).unwrap_err(), + CondenseError::MissingFeature(42) + )); + } +} diff --git a/crates/ruvector-graph-condense/src/gnn_eval.rs b/crates/ruvector-graph-condense/src/gnn_eval.rs new file mode 100644 index 000000000..6174a35d1 --- /dev/null +++ b/crates/ruvector-graph-condense/src/gnn_eval.rs @@ -0,0 +1,426 @@ +//! Minimal 2-layer GCN — **for evaluating condensation quality only**. +//! +//! The graph-condensation literature is benchmarked by one protocol: train a GNN +//! on the condensed graph, then test it on the *original* graph's held-out nodes, +//! and report `accuracy(condensed) / accuracy(full)` ("retention"). Structural +//! proxies (cut preservation, purity) do not substitute for it. This module is a +//! self-contained, dependency-free (plain `f64`) reference GCN so the crate can +//! report that number honestly. +//! +//! It is deliberately small: symmetric-normalised propagation `Â = D̃^{-1/2} +//! (A+I) D̃^{-1/2}`, two graph-conv layers with ReLU, softmax cross-entropy, +//! Adam, **analytic backprop** (gradient-checked in tests). Weights are +//! graph-agnostic, so a GCN trained on the small condensed graph can be applied +//! to the full graph at test time — exactly the condensation eval protocol. + +use rand::rngs::StdRng; +use rand::{Rng, SeedableRng}; + +/// Symmetric-normalised adjacency (with self-loops) in CSR form. +pub struct GcnGraph { + n: usize, + off: Vec, + nbr: Vec<(usize, f64)>, +} + +impl GcnGraph { + /// Build `Â` from an undirected edge list `(i, j, w)` over `n` nodes. + pub fn from_edges(n: usize, edges: &[(usize, usize, f64)]) -> Self { + // Degrees including the self-loop (A + I). + let mut deg = vec![1f64; n]; + for &(i, j, w) in edges { + deg[i] += w; + deg[j] += w; + } + let inv_sqrt: Vec = deg.iter().map(|d| 1.0 / d.sqrt()).collect(); + + // Count entries per row (neighbours both directions + self). + let mut cnt = vec![1usize; n]; + for &(i, j, _) in edges { + cnt[i] += 1; + cnt[j] += 1; + } + let mut off = vec![0usize; n + 1]; + for i in 0..n { + off[i + 1] = off[i] + cnt[i]; + } + let mut cursor = off[..n].to_vec(); + let mut nbr = vec![(0usize, 0f64); off[n]]; + // Self-loops first. + for i in 0..n { + nbr[cursor[i]] = (i, inv_sqrt[i] * inv_sqrt[i]); + cursor[i] += 1; + } + for &(i, j, w) in edges { + let a = w * inv_sqrt[i] * inv_sqrt[j]; + nbr[cursor[i]] = (j, a); + cursor[i] += 1; + nbr[cursor[j]] = (i, a); + cursor[j] += 1; + } + Self { n, off, nbr } + } + + /// `Â · M` where `M` is row-major `n × d`. + fn spmm(&self, m: &[f64], d: usize) -> Vec { + let mut out = vec![0f64; self.n * d]; + for i in 0..self.n { + let orow = &mut out[i * d..(i + 1) * d]; + for e in self.off[i]..self.off[i + 1] { + let (j, w) = self.nbr[e]; + let mrow = &m[j * d..(j + 1) * d]; + for c in 0..d { + orow[c] += w * mrow[c]; + } + } + } + out + } +} + +/// A trained 2-layer GCN classifier. +pub struct Gcn { + w1: Vec, // f x h + w2: Vec, // h x c + f: usize, + h: usize, + c: usize, +} + +/// Training hyper-parameters. +#[derive(Debug, Clone)] +pub struct GcnConfig { + /// Hidden width. + pub hidden: usize, + /// Adam learning rate. + pub learning_rate: f64, + /// Training epochs. + pub epochs: usize, + /// L2 weight decay. + pub weight_decay: f64, + /// Seed for weight init. + pub seed: u64, +} + +impl Default for GcnConfig { + fn default() -> Self { + Self { + hidden: 16, + learning_rate: 0.01, + epochs: 200, + weight_decay: 5e-4, + seed: 0x6CD, + } + } +} + +fn relu(x: f64) -> f64 { + x.max(0.0) +} + +fn mm(a: &[f64], b: &[f64], n: usize, p: usize, q: usize) -> Vec { + // (n×p) · (p×q) + let mut out = vec![0f64; n * q]; + for i in 0..n { + for k in 0..p { + let aik = a[i * p + k]; + if aik != 0.0 { + for j in 0..q { + out[i * q + j] += aik * b[k * q + j]; + } + } + } + } + out +} + +fn mm_at(a: &[f64], b: &[f64], n: usize, p: usize, q: usize) -> Vec { + // (n×p)ᵀ · (n×q) = (p×q) + let mut out = vec![0f64; p * q]; + for i in 0..n { + for k in 0..p { + let aik = a[i * p + k]; + if aik != 0.0 { + for j in 0..q { + out[k * q + j] += aik * b[i * q + j]; + } + } + } + } + out +} + +fn mm_bt(a: &[f64], b: &[f64], n: usize, q: usize, p: usize) -> Vec { + // (n×q) · (p×q)ᵀ = (n×p) + let mut out = vec![0f64; n * p]; + for i in 0..n { + for k in 0..p { + let mut acc = 0f64; + for j in 0..q { + acc += a[i * q + j] * b[k * q + j]; + } + out[i * p + k] = acc; + } + } + out +} + +/// Forward intermediates kept for backprop. +struct Fwd { + ax: Vec, + h1: Vec, + ar: Vec, + probs: Vec, +} + +impl Gcn { + fn forward(&self, g: &GcnGraph, x: &[f64]) -> Fwd { + let n = g.n; + let ax = g.spmm(x, self.f); + let h1 = mm(&ax, &self.w1, n, self.f, self.h); + let r: Vec = h1.iter().map(|&v| relu(v)).collect(); + let ar = g.spmm(&r, self.h); + let o = mm(&ar, &self.w2, n, self.h, self.c); + let probs = softmax_rows(&o, n, self.c); + Fwd { ax, h1, ar, probs } + } + + /// Predicted class per node. + pub fn predict(&self, g: &GcnGraph, x: &[f64]) -> Vec { + let fwd = self.forward(g, x); + (0..g.n) + .map(|i| argmax(&fwd.probs[i * self.c..(i + 1) * self.c])) + .collect() + } + + /// Train on `(g, x, labels)` over the nodes in `mask`. Returns the trained + /// classifier. + pub fn train( + cfg: &GcnConfig, + g: &GcnGraph, + x: &[f64], + f: usize, + labels: &[usize], + c: usize, + mask: &[usize], + ) -> Gcn { + let h = cfg.hidden; + let mut rng = StdRng::seed_from_u64(cfg.seed); + // Xavier-ish init. + let s1 = (6.0 / (f + h) as f64).sqrt(); + let s2 = (6.0 / (h + c) as f64).sqrt(); + let mut model = Gcn { + w1: (0..f * h).map(|_| rng.gen_range(-s1..s1)).collect(), + w2: (0..h * c).map(|_| rng.gen_range(-s2..s2)).collect(), + f, + h, + c, + }; + // Adam state. + let (mut m1, mut v1) = (vec![0f64; f * h], vec![0f64; f * h]); + let (mut m2, mut v2) = (vec![0f64; h * c], vec![0f64; h * c]); + for t in 1..=cfg.epochs { + let (dw1, dw2) = model.grads(g, x, labels, mask); + adam_step(&mut model.w1, &dw1, &mut m1, &mut v1, cfg, t); + adam_step(&mut model.w2, &dw2, &mut m2, &mut v2, cfg, t); + } + model + } + + /// Analytic gradients of masked softmax-CE (+ L2) w.r.t. `w1`, `w2`. + fn grads( + &self, + g: &GcnGraph, + x: &[f64], + labels: &[usize], + mask: &[usize], + ) -> (Vec, Vec) { + let n = g.n; + let fwd = self.forward(g, x); + let inv = 1.0 / mask.len().max(1) as f64; + // dO = (P - onehot)/|mask| on masked rows, else 0. + let mut d_o = vec![0f64; n * self.c]; + for &i in mask { + for cc in 0..self.c { + d_o[i * self.c + cc] = fwd.probs[i * self.c + cc] * inv; + } + d_o[i * self.c + labels[i]] -= inv; + } + let dw2 = mm_at(&fwd.ar, &d_o, n, self.h, self.c); + let d_ar = mm_bt(&d_o, &self.w2, n, self.c, self.h); + let d_r = g.spmm(&d_ar, self.h); + let mut d_h1 = vec![0f64; n * self.h]; + for idx in 0..n * self.h { + d_h1[idx] = if fwd.h1[idx] > 0.0 { d_r[idx] } else { 0.0 }; + } + let dw1 = mm_at(&fwd.ax, &d_h1, n, self.f, self.h); + // L2 weight decay is applied in `adam_step`, so gradients here are the + // pure cross-entropy gradients (which the gradient check verifies). + (dw1, dw2) + } +} + +fn adam_step(w: &mut [f64], grad: &[f64], m: &mut [f64], v: &mut [f64], cfg: &GcnConfig, t: usize) { + let (b1, b2, eps): (f64, f64, f64) = (0.9, 0.999, 1e-8); + let bc1 = 1.0 - b1.powi(t as i32); + let bc2 = 1.0 - b2.powi(t as i32); + for i in 0..w.len() { + let g = grad[i] + cfg.weight_decay * w[i]; + m[i] = b1 * m[i] + (1.0 - b1) * g; + v[i] = b2 * v[i] + (1.0 - b2) * g * g; + w[i] -= cfg.learning_rate * (m[i] / bc1) / ((v[i] / bc2).sqrt() + eps); + } +} + +fn softmax_rows(o: &[f64], n: usize, c: usize) -> Vec { + let mut p = vec![0f64; n * c]; + for i in 0..n { + let row = &o[i * c..(i + 1) * c]; + let mx = row.iter().copied().fold(f64::NEG_INFINITY, f64::max); + let mut s = 0f64; + for j in 0..c { + let e = (row[j] - mx).exp(); + p[i * c + j] = e; + s += e; + } + for j in 0..c { + p[i * c + j] /= s; + } + } + p +} + +fn argmax(row: &[f64]) -> usize { + let mut best = 0; + for (i, &v) in row.iter().enumerate() { + if v > row[best] { + best = i; + } + } + best +} + +/// Accuracy over the nodes in `mask`. +pub fn accuracy(preds: &[usize], labels: &[usize], mask: &[usize]) -> f64 { + if mask.is_empty() { + return 0.0; + } + let correct = mask.iter().filter(|&&i| preds[i] == labels[i]).count(); + correct as f64 / mask.len() as f64 +} + +#[cfg(test)] +#[allow(clippy::needless_range_loop)] // index-heavy numeric test code +mod tests { + use super::*; + + fn ring(n: usize) -> (GcnGraph, Vec<(usize, usize, f64)>) { + let edges: Vec<(usize, usize, f64)> = (0..n).map(|i| (i, (i + 1) % n, 1.0)).collect(); + (GcnGraph::from_edges(n, &edges), edges) + } + + #[test] + fn gradient_matches_finite_differences() { + // The decisive correctness test for the GCN backprop. + let n = 6; + let (g, _e) = ring(n); + let f = 3; + let c = 2; + let h = 4; + let mut rng = StdRng::seed_from_u64(1); + let x: Vec = (0..n * f).map(|_| rng.gen_range(-1.0..1.0)).collect(); + let labels: Vec = (0..n).map(|i| i % c).collect(); + let mask: Vec = (0..n).collect(); + let model = Gcn { + w1: (0..f * h).map(|_| rng.gen_range(-0.5..0.5)).collect(), + w2: (0..h * c).map(|_| rng.gen_range(-0.5..0.5)).collect(), + f, + h, + c, + }; + let loss = |m: &Gcn| -> f64 { + let fwd = m.forward(&g, &x); + let inv = 1.0 / mask.len() as f64; + let mut l = 0f64; + for &i in &mask { + l -= (fwd.probs[i * c + labels[i]].max(1e-12)).ln() * inv; + } + l + }; + let (dw1, dw2) = model.grads(&g, &x, &labels, &mask); + let hh = 1e-6; + let mut max_err = 0f64; + for idx in 0..f * h { + let mut mp = Gcn { + w1: model.w1.clone(), + w2: model.w2.clone(), + f, + h, + c, + }; + mp.w1[idx] += hh; + let mut mm_ = Gcn { + w1: model.w1.clone(), + w2: model.w2.clone(), + f, + h, + c, + }; + mm_.w1[idx] -= hh; + let num = (loss(&mp) - loss(&mm_)) / (2.0 * hh); + max_err = max_err.max((num - dw1[idx]).abs()); + } + for idx in 0..h * c { + let mut mp = Gcn { + w1: model.w1.clone(), + w2: model.w2.clone(), + f, + h, + c, + }; + mp.w2[idx] += hh; + let mut mm_ = Gcn { + w1: model.w1.clone(), + w2: model.w2.clone(), + f, + h, + c, + }; + mm_.w2[idx] -= hh; + let num = (loss(&mp) - loss(&mm_)) / (2.0 * hh); + max_err = max_err.max((num - dw2[idx]).abs()); + } + assert!(max_err < 1e-6, "GCN grad mismatch: {max_err}"); + } + + #[test] + fn learns_a_separable_task() { + // Two cliques, distinct features per class -> GCN should fit train set. + let n = 20; + let mut edges = Vec::new(); + for i in 0..10 { + for j in (i + 1)..10 { + edges.push((i, j, 1.0)); + } + } + for i in 10..20 { + for j in (i + 1)..20 { + edges.push((i, j, 1.0)); + } + } + let g = GcnGraph::from_edges(n, &edges); + let f = 2; + let c = 2; + let mut x = vec![0f64; n * f]; + let mut labels = vec![0usize; n]; + for i in 0..n { + let cls = i / 10; + labels[i] = cls; + x[i * f + cls] = 1.0; + } + let mask: Vec = (0..n).collect(); + let model = Gcn::train(&GcnConfig::default(), &g, &x, f, &labels, c, &mask); + let preds = model.predict(&g, &x); + assert!(accuracy(&preds, &labels, &mask) > 0.95); + } +} diff --git a/crates/ruvector-graph-condense/src/lib.rs b/crates/ruvector-graph-condense/src/lib.rs new file mode 100644 index 000000000..b9dc288bb --- /dev/null +++ b/crates/ruvector-graph-condense/src/lib.rs @@ -0,0 +1,145 @@ +//! # ruvector-graph-condense +//! +//! Structure-preserving **graph condensation** built on RuVector's dynamic +//! min-cut engine ([`ruvector_mincut`]). +//! +//! ## What this is (and isn't) +//! +//! The graph-condensation literature (GCond, SFGC, GEOM, SGDD, …) defines +//! *condensation* as **synthesising a small fake graph** by optimising a +//! learning objective (gradient/distribution/trajectory matching) so that a GNN +//! trained on the synthetic graph matches one trained on the original. That is +//! powerful but expensive (bi-level optimisation), supervised, and — by design +//! — **destroys the mapping back to real nodes**. +//! +//! This crate takes the complementary, **training-free** route that the 2024– +//! 2026 surveys flag as under-explored: +//! +//! - **Min-cut community structure as the condensation prior.** Regions come +//! from recursive dynamic min-cut ([`ruvector_mincut::CommunityDetector`]), +//! not k-means. No published method (as of 2026) uses graph-cut community +//! detection as the core condensation mechanism — the closest analogs are +//! CGC (generic clustering, 2025) and GCTD (tensor decomposition, 2025). +//! - **A differentiable min-cut *loss*** ([`diffcut`], [`CondenseMethod::DiffMinCut`]). +//! A relaxed normalized-cut + orthogonality objective (MinCutPool-style) whose +//! region structure is *trained* by gradient descent to preserve the cut. +//! The surveys flag an explicit differentiable min-cut term in the +//! condensation loss as unpublished; only spectral terms (SGDD's LED, GDEM's +//! eigenbasis) exist. Gradients are analytic (no autodiff dependency) and +//! gradient-checked. +//! - **Cuts preserved by construction.** Every original edge that crosses a +//! region boundary survives as a weighted super-edge, so the condensed graph +//! reproduces the source's cut structure instead of having to learn it. The +//! [`metrics::cut_inflation`] proxy quantifies exactly this. +//! - **Provenance retained.** Each [`CondensedNode`] keeps its `members`, so +//! the original↔condensed mapping is intact (useful for audit / explainability +//! — the thing learned condensation throws away). +//! +//! In the field's taxonomy this is closer to **structure-preserving coarsening +//! with synthetic representatives** than to GCond-style condensation: it trades +//! peak downstream accuracy for being fast, label-optional, deterministic, +//! streaming-friendly, and interpretable. +//! +//! ## Pipeline +//! +//! ```text +//! DynamicGraph + NodeFeatures +//! │ recursive dynamic min-cut +//! ▼ +//! Regions (communities) +//! │ per region: centroid · weight · class histogram · coherence · medoid +//! ▼ +//! CondensedGraph (super-nodes + boundary-weighted super-edges) +//! ``` +//! +//! ## Quick start +//! +//! ``` +//! use ruvector_graph_condense::{condense, NodeFeatures}; +//! use ruvector_mincut::DynamicGraph; +//! +//! // Two triangles joined by a weak bridge. +//! let g = DynamicGraph::new(); +//! for &(u, v, w) in &[(0,1,1.0),(1,2,1.0),(2,0,1.0), +//! (3,4,1.0),(4,5,1.0),(5,3,1.0), +//! (2,3,0.05)] { +//! g.insert_edge(u, v, w).unwrap(); +//! } +//! let mut f = NodeFeatures::new(1, 0); +//! for v in 0..6u64 { f.set_embedding(v, vec![v as f32]).unwrap(); } +//! +//! let condensed = condense(&g, &f).unwrap(); +//! assert_eq!(condensed.node_count(), 2); // recovered both communities +//! assert_eq!(condensed.edge_count(), 1); // the bridge -> one super-edge +//! assert!(condensed.node_reduction_ratio() == 3.0); +//! ``` + +#![forbid(unsafe_code)] + +pub mod condense; +mod cutloss; +pub mod diffcut; +pub mod error; +pub mod features; +pub mod gnn_eval; +pub mod metrics; +pub mod node; +mod regions; +pub mod stream; +pub mod synthetic; + +pub use condense::{condense, CondenseConfig, CondenseMethod, GraphCondenser}; +pub use diffcut::{ + min_cut_loss, DiffCutCondenser, DiffCutConfig, DiffCutResult, InitStrategy, MinCutLoss, + Optimizer, +}; +pub use error::{CondenseError, Result}; +pub use features::NodeFeatures; +pub use metrics::{cut_inflation, evaluate, evaluate_full, CondensationMetrics}; +pub use node::{CondensedEdge, CondensedGraph, CondensedNode}; +pub use stream::StreamingCondenser; +pub use synthetic::PlantedPartition; + +/// Crate version string. +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[cfg(test)] +mod tests { + use super::*; + use ruvector_mincut::DynamicGraph; + + #[test] + fn end_to_end_condense_and_evaluate() { + let pp = PlantedPartition { + num_communities: 5, + community_size: 16, + dim: 8, + p_intra: 0.5, + p_inter: 0.001, + seed: 42, + ..Default::default() + }; + let (g, f) = pp.generate(); + let condensed = condense(&g, &f).unwrap(); + let m = evaluate(&g, &condensed); + + assert_eq!(m.source_nodes, 80); + assert!(m.condensed_nodes >= 5); + assert!(m.node_reduction_ratio > 1.0); + assert!(m.intra_weight_ratio > 0.8); + assert!(m.label_purity > 0.8); + } + + #[test] + fn public_api_is_reachable() { + let _ = VERSION; + let g = DynamicGraph::new(); + g.insert_edge(0, 1, 1.0).unwrap(); + let mut f = NodeFeatures::new(1, 0); + f.set_embedding(0, vec![0.0]).unwrap(); + f.set_embedding(1, vec![1.0]).unwrap(); + let condenser = GraphCondenser::new(CondenseConfig::default()); + let c = condenser.condense(&g, &f).unwrap(); + assert_eq!(c.total_weight(), 2); + } +} diff --git a/crates/ruvector-graph-condense/src/metrics.rs b/crates/ruvector-graph-condense/src/metrics.rs new file mode 100644 index 000000000..261e9880f --- /dev/null +++ b/crates/ruvector-graph-condense/src/metrics.rs @@ -0,0 +1,214 @@ +//! Quality metrics for a condensation result. +//! +//! Accuracy-retention (retrain-a-GNN) evaluation is out of scope for this crate +//! — the 2024–2026 literature explicitly calls for cheap *proxy* metrics that +//! avoid retraining many GNNs. These are structural proxies computable directly +//! from the source and condensed graphs. + +use crate::node::CondensedGraph; +use ruvector_mincut::{DynamicGraph, MinCutBuilder}; + +/// A bundle of cheap, retrain-free quality proxies. +#[derive(Debug, Clone, PartialEq)] +pub struct CondensationMetrics { + /// Original vertex count. + pub source_nodes: usize, + /// Condensed super-node count. + pub condensed_nodes: usize, + /// `source_nodes / condensed_nodes`. + pub node_reduction_ratio: f64, + /// Original edge count. + pub source_edges: usize, + /// Condensed super-edge count. + pub condensed_edges: usize, + /// `source_edges / condensed_edges`. + pub edge_reduction_ratio: f64, + /// Fraction of total edge weight that stayed *inside* a region. Higher is + /// better: it means the partition cut few/light edges (good community + /// structure was found). + pub intra_weight_ratio: f64, + /// Mean per-region coherence in `[0, 1]`. + pub mean_coherence: f32, + /// Weight-averaged region purity (dominant-class share); `1.0` when + /// unsupervised. + pub label_purity: f32, + /// Global-min-cut inflation: `mincut(condensed) / mincut(source)`. + /// `Some(1.0)` means the source's global min cut survives coarsening + /// exactly; `> 1.0` means the true cut got hidden inside a region. `None` + /// when undefined (disconnected source, or condensed graph too small). + pub cut_inflation: Option, +} + +/// Compute the cheap structural proxies (no min-cut solve). +pub fn evaluate(graph: &DynamicGraph, condensed: &CondensedGraph) -> CondensationMetrics { + let total_weight: f64 = graph.edges().iter().map(|e| e.weight).sum(); + let inter_weight: f64 = condensed.edges.iter().map(|e| e.weight).sum(); + let intra_weight_ratio = if total_weight > 0.0 { + ((total_weight - inter_weight) / total_weight).clamp(0.0, 1.0) + } else { + 1.0 + }; + + let (mean_coherence, label_purity) = aggregate_node_quality(condensed); + + CondensationMetrics { + source_nodes: condensed.source_nodes, + condensed_nodes: condensed.node_count(), + node_reduction_ratio: condensed.node_reduction_ratio(), + source_edges: condensed.source_edges, + condensed_edges: condensed.edge_count(), + edge_reduction_ratio: condensed.edge_reduction_ratio(), + intra_weight_ratio, + mean_coherence, + label_purity, + cut_inflation: None, + } +} + +/// Like [`evaluate`], but also solves the global min cut on both graphs to fill +/// in [`CondensationMetrics::cut_inflation`]. This is **O(min-cut)** on the full +/// source graph and is therefore opt-in. +pub fn evaluate_full(graph: &DynamicGraph, condensed: &CondensedGraph) -> CondensationMetrics { + let mut m = evaluate(graph, condensed); + m.cut_inflation = cut_inflation(graph, condensed); + m +} + +/// Ratio of the condensed graph's global min cut to the source's. See +/// [`CondensationMetrics::cut_inflation`] for interpretation. +pub fn cut_inflation(graph: &DynamicGraph, condensed: &CondensedGraph) -> Option { + // Need a meaningful cut on both sides. + if graph.num_vertices() < 2 || condensed.node_count() < 2 { + return None; + } + + let source_cut = global_min_cut(graph.edges().iter().map(|e| (e.source, e.target, e.weight)))?; + if source_cut <= 0.0 { + // Disconnected source: ratio undefined. + return None; + } + let condensed_cut = global_min_cut( + condensed + .edges + .iter() + .map(|e| (e.source, e.target, e.weight)), + )?; + + Some(condensed_cut / source_cut) +} + +/// Solve an exact global min cut over an edge iterator, returning `None` if the +/// result is non-finite (e.g. fewer than 2 connected vertices). +fn global_min_cut(edges: I) -> Option +where + I: IntoIterator, +{ + let edge_vec: Vec<(u64, u64, f64)> = edges.into_iter().collect(); + if edge_vec.is_empty() { + return None; + } + let mincut = MinCutBuilder::new() + .exact() + .with_edges(edge_vec) + .build() + .ok()?; + let v = mincut.min_cut_value(); + if v.is_finite() { + Some(v) + } else { + None + } +} + +fn aggregate_node_quality(condensed: &CondensedGraph) -> (f32, f32) { + if condensed.nodes.is_empty() { + return (0.0, 1.0); + } + let mut coherence_sum = 0.0f32; + let mut purity_weighted = 0.0f32; + let mut weight_total = 0.0f32; + for n in &condensed.nodes { + coherence_sum += n.coherence; + let w = n.weight as f32; + purity_weighted += n.purity() * w; + weight_total += w; + } + let mean_coherence = coherence_sum / condensed.nodes.len() as f32; + let label_purity = if weight_total > 0.0 { + purity_weighted / weight_total + } else { + 1.0 + }; + (mean_coherence, label_purity) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::condense::{condense, CondenseConfig, CondenseMethod, GraphCondenser}; + use crate::features::NodeFeatures; + + fn barbell() -> (DynamicGraph, NodeFeatures) { + // Two K3 cliques joined by a single weak bridge. + let g = DynamicGraph::new(); + for &(u, v, w) in &[ + (0, 1, 1.0), + (1, 2, 1.0), + (2, 0, 1.0), + (3, 4, 1.0), + (4, 5, 1.0), + (5, 3, 1.0), + (2, 3, 0.1), + ] { + g.insert_edge(u, v, w).unwrap(); + } + let mut f = NodeFeatures::new(1, 2); + for v in 0..3u64 { + f.set(v, vec![0.0], 0).unwrap(); + } + for v in 3..6u64 { + f.set(v, vec![1.0], 1).unwrap(); + } + (g, f) + } + + #[test] + fn reports_reduction_and_quality() { + let (g, f) = barbell(); + let c = condense(&g, &f).unwrap(); + let m = evaluate(&g, &c); + assert_eq!(m.source_nodes, 6); + assert_eq!(m.condensed_nodes, 2); + assert_eq!(m.node_reduction_ratio, 3.0); + // Only the 0.1 bridge crosses regions; 6 unit edges stay internal. + assert!(m.intra_weight_ratio > 0.95); + assert!(m.mean_coherence > 0.9); + assert!(m.label_purity > 0.99); + assert_eq!(m.cut_inflation, None); // evaluate() doesn't solve cuts + } + + #[test] + fn cut_inflation_preserved_for_clean_partition() { + let (g, f) = barbell(); + let c = condense(&g, &f).unwrap(); + // Source global min cut = 0.1 (the bridge). Condensed graph is a single + // super-edge of weight 0.1, so its min cut is also 0.1 -> ratio 1.0. + let infl = cut_inflation(&g, &c).expect("defined for connected barbell"); + assert!((infl - 1.0).abs() < 1e-9, "got {infl}"); + } + + #[test] + fn evaluate_full_fills_cut() { + let (g, f) = barbell(); + let c = GraphCondenser::new(CondenseConfig { + method: CondenseMethod::ConnectedComponents, + normalize_centroids: false, + }) + .condense(&g, &f) + .unwrap(); + // Connected barbell -> single component -> single super-node -> cut None. + let m = evaluate_full(&g, &c); + assert_eq!(m.condensed_nodes, 1); + assert_eq!(m.cut_inflation, None); + } +} diff --git a/crates/ruvector-graph-condense/src/node.rs b/crates/ruvector-graph-condense/src/node.rs new file mode 100644 index 000000000..2e61e4127 --- /dev/null +++ b/crates/ruvector-graph-condense/src/node.rs @@ -0,0 +1,237 @@ +//! Condensed graph data model: super-nodes (regions) and super-edges. + +use ruvector_mincut::{DynamicGraph, VertexId}; +use serde::{Deserialize, Serialize}; + +/// A single super-node in a condensed graph: one structural region of the +/// original graph collapsed to a representative summary. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CondensedNode { + /// Stable region id (assigned deterministically by the condenser). + pub id: u64, + /// Mean embedding of the region's members. + pub centroid: Vec, + /// Number of original vertices collapsed into this region. + pub weight: u32, + /// Normalised class histogram over `num_classes` (empty when unsupervised). + pub class_distribution: Vec, + /// Internal cohesion in `[0, 1]`: fraction of incident edge weight that + /// stays inside the region (1.0 = fully self-contained). + pub coherence: f32, + /// Member closest to the centroid (the region's medoid). + pub representative: VertexId, + /// The original vertices that belong to this region (sorted ascending). + pub members: Vec, +} + +impl CondensedNode { + /// The dominant class of this region, if a class distribution is present. + pub fn dominant_class(&self) -> Option { + if self.class_distribution.is_empty() { + return None; + } + let mut best = 0usize; + let mut best_p = self.class_distribution[0]; + for (i, &p) in self.class_distribution.iter().enumerate().skip(1) { + if p > best_p { + best_p = p; + best = i; + } + } + Some(best) + } + + /// Purity of the dominant class (its share of the region), or `1.0` when + /// unsupervised (empty distribution). + pub fn purity(&self) -> f32 { + if self.class_distribution.is_empty() { + return 1.0; + } + self.class_distribution + .iter() + .copied() + .fold(0.0_f32, f32::max) + } +} + +/// A weighted super-edge between two regions, aggregating every original edge +/// that crosses the corresponding region boundary. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct CondensedEdge { + /// Source region id (always `< target` for canonical undirected storage). + pub source: u64, + /// Target region id. + pub target: u64, + /// Sum of crossing original edge weights. + pub weight: f64, + /// Number of original edges merged into this super-edge. + pub crossings: u32, +} + +/// The result of condensing a graph: a small set of super-nodes and the +/// weighted super-edges connecting them, plus provenance for computing +/// reduction ratios. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CondensedGraph { + /// Super-nodes, ordered by ascending `id`. + pub nodes: Vec, + /// Super-edges (canonical, deduplicated). + pub edges: Vec, + /// Original vertex count (provenance). + pub source_nodes: usize, + /// Original edge count (provenance). + pub source_edges: usize, + /// Embedding dimension. + pub dim: usize, + /// Class count (`0` if unsupervised). + pub num_classes: usize, +} + +impl CondensedGraph { + /// Number of super-nodes. + pub fn node_count(&self) -> usize { + self.nodes.len() + } + + /// Number of super-edges. + pub fn edge_count(&self) -> usize { + self.edges.len() + } + + /// Node reduction factor (`source_nodes / condensed_nodes`). + pub fn node_reduction_ratio(&self) -> f64 { + if self.nodes.is_empty() { + return 0.0; + } + self.source_nodes as f64 / self.nodes.len() as f64 + } + + /// Edge reduction factor (`source_edges / condensed_edges`). + pub fn edge_reduction_ratio(&self) -> f64 { + if self.edges.is_empty() { + return if self.source_edges == 0 { + 1.0 + } else { + self.source_edges as f64 + }; + } + self.source_edges as f64 / self.edges.len() as f64 + } + + /// Look up a super-node by region id (binary search; nodes are id-sorted). + pub fn get_node(&self, id: u64) -> Option<&CondensedNode> { + self.nodes + .binary_search_by_key(&id, |n| n.id) + .ok() + .map(|i| &self.nodes[i]) + } + + /// Total weight (member count) across all super-nodes — equals + /// `source_nodes` for a complete partition. + pub fn total_weight(&self) -> u64 { + self.nodes.iter().map(|n| n.weight as u64).sum() + } + + /// Rebuild the condensed graph as a [`DynamicGraph`] (region id → vertex + /// id). Enables hierarchical / iterated condensation and feeding the + /// condensed structure back into the min-cut engine. + pub fn to_dynamic_graph(&self) -> DynamicGraph { + let g = DynamicGraph::with_capacity(self.nodes.len(), self.edges.len()); + for n in &self.nodes { + g.add_vertex(n.id); + } + for e in &self.edges { + // Super-edges are canonical and unique, so insert cannot collide. + let _ = g.insert_edge(e.source, e.target, e.weight); + } + g + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn node(id: u64, dist: Vec) -> CondensedNode { + CondensedNode { + id, + centroid: vec![0.0], + weight: 1, + class_distribution: dist, + coherence: 1.0, + representative: id, + members: vec![id], + } + } + + #[test] + fn dominant_class_picks_argmax() { + let n = node(0, vec![0.1, 0.7, 0.2]); + assert_eq!(n.dominant_class(), Some(1)); + let unsup = node(1, vec![]); + assert_eq!(unsup.dominant_class(), None); + } + + #[test] + fn reduction_ratios() { + let g = CondensedGraph { + nodes: vec![node(0, vec![]), node(1, vec![])], + edges: vec![CondensedEdge { + source: 0, + target: 1, + weight: 1.0, + crossings: 3, + }], + source_nodes: 100, + source_edges: 400, + dim: 1, + num_classes: 0, + }; + assert_eq!(g.node_reduction_ratio(), 50.0); + assert_eq!(g.edge_reduction_ratio(), 400.0); + assert_eq!(g.total_weight(), 2); + } + + #[test] + fn get_node_binary_search() { + let g = CondensedGraph { + nodes: vec![node(0, vec![]), node(5, vec![]), node(9, vec![])], + edges: vec![], + source_nodes: 3, + source_edges: 0, + dim: 1, + num_classes: 0, + }; + assert_eq!(g.get_node(5).map(|n| n.id), Some(5)); + assert!(g.get_node(7).is_none()); + } + + #[test] + fn round_trips_to_dynamic_graph() { + let g = CondensedGraph { + nodes: vec![node(0, vec![]), node(1, vec![]), node(2, vec![])], + edges: vec![ + CondensedEdge { + source: 0, + target: 1, + weight: 2.0, + crossings: 1, + }, + CondensedEdge { + source: 1, + target: 2, + weight: 3.0, + crossings: 1, + }, + ], + source_nodes: 3, + source_edges: 2, + dim: 1, + num_classes: 0, + }; + let dg = g.to_dynamic_graph(); + assert_eq!(dg.num_vertices(), 3); + assert_eq!(dg.num_edges(), 2); + assert_eq!(dg.edge_weight(0, 1), Some(2.0)); + } +} diff --git a/crates/ruvector-graph-condense/src/regions.rs b/crates/ruvector-graph-condense/src/regions.rs new file mode 100644 index 000000000..a627ef71c --- /dev/null +++ b/crates/ruvector-graph-condense/src/regions.rs @@ -0,0 +1,253 @@ +//! Region formation and per-region summarisation helpers. +//! +//! Split out from [`crate::condense`] to keep the orchestration small: this +//! module owns *how a region is detected and summarised* (weak-boundary +//! components, coverage, centroid/medoid, class histograms), while `condense` +//! owns the pipeline that wires them together. + +use crate::error::Result; +use crate::features::NodeFeatures; +use ruvector_mincut::{DynamicGraph, VertexId}; +use std::collections::HashMap; + +/// Regions = connected components of the graph after removing edges lighter than +/// `relative_threshold * mean_weight`. Isolated / weak-only vertices fall out as +/// singletons. Deterministic for a fixed graph. Single edge pass + union-find, +/// so it scales near-linearly. +pub(crate) fn weak_boundary_regions( + graph: &DynamicGraph, + relative_threshold: f64, +) -> Vec> { + let vertices = graph.vertices(); + let edges = graph.edges(); + + // Index vertices contiguously for the union-find. + let mut index: HashMap = HashMap::with_capacity(vertices.len()); + for (i, &v) in vertices.iter().enumerate() { + index.insert(v, i); + } + let mut uf = UnionFind::new(vertices.len()); + + let threshold = if edges.is_empty() { + 0.0 + } else { + let mean = edges.iter().map(|e| e.weight).sum::() / edges.len() as f64; + relative_threshold * mean + }; + + for e in &edges { + if e.weight >= threshold { + uf.union(index[&e.source], index[&e.target]); + } + } + + // Group vertices by their union-find root. + let mut groups: HashMap> = HashMap::new(); + for (i, &v) in vertices.iter().enumerate() { + groups.entry(uf.find(i)).or_default().push(v); + } + groups.into_values().collect() +} + +/// Append singleton regions for any graph vertex not already covered by the +/// partitioner (some partitioners drop isolated or unsplittable vertices). +pub(crate) fn ensure_coverage(regions: &mut Vec>, vertices: &[VertexId]) { + let mut seen: std::collections::HashSet = + std::collections::HashSet::with_capacity(vertices.len()); + for r in regions.iter() { + for &v in r { + seen.insert(v); + } + } + for &v in vertices { + if seen.insert(v) { + regions.push(vec![v]); + } + } +} + +/// Mean embedding and medoid (member closest to the mean) of a region. +/// `members` must be non-empty. +pub(crate) fn centroid_and_medoid( + members: &[VertexId], + features: &NodeFeatures, + dim: usize, +) -> Result<(Vec, VertexId)> { + let mut centroid = vec![0f32; dim]; + for &v in members { + let emb = features.require(v)?; + for (c, &x) in centroid.iter_mut().zip(emb.iter()) { + *c += x; + } + } + let inv = 1.0 / members.len() as f32; + for c in &mut centroid { + *c *= inv; + } + + let mut best = members[0]; + let mut best_dist = f32::INFINITY; + for &v in members { + let emb = features.require(v)?; + let d = l2_sq(¢roid, emb); + if d < best_dist { + best_dist = d; + best = v; + } + } + Ok((centroid, best)) +} + +/// Normalised class histogram over `num_classes`, or empty when unsupervised. +pub(crate) fn class_distribution( + members: &[VertexId], + features: &NodeFeatures, + num_classes: usize, +) -> Vec { + if num_classes == 0 { + return Vec::new(); + } + let mut hist = vec![0f32; num_classes]; + let mut counted = 0f32; + for &v in members { + if let Some(label) = features.label(v) { + if label < num_classes { + hist[label] += 1.0; + counted += 1.0; + } + } + } + if counted > 0.0 { + let inv = 1.0 / counted; + for h in &mut hist { + *h *= inv; + } + } + hist +} + +/// Squared Euclidean distance. +pub(crate) fn l2_sq(a: &[f32], b: &[f32]) -> f32 { + a.iter() + .zip(b.iter()) + .map(|(x, y)| { + let d = x - y; + d * d + }) + .sum() +} + +/// L2-normalise in place (no-op for a zero vector). +pub(crate) fn l2_normalize(v: &mut [f32]) { + let norm: f32 = v.iter().map(|x| x * x).sum::().sqrt(); + if norm > 0.0 { + let inv = 1.0 / norm; + for x in v { + *x *= inv; + } + } +} + +/// Minimal union-find with path compression and union by size. +struct UnionFind { + parent: Vec, + size: Vec, +} + +impl UnionFind { + fn new(n: usize) -> Self { + Self { + parent: (0..n).collect(), + size: vec![1; n], + } + } + + fn find(&mut self, mut x: usize) -> usize { + while self.parent[x] != x { + self.parent[x] = self.parent[self.parent[x]]; + x = self.parent[x]; + } + x + } + + fn union(&mut self, a: usize, b: usize) { + let (ra, rb) = (self.find(a), self.find(b)); + if ra == rb { + return; + } + let (big, small) = if self.size[ra] >= self.size[rb] { + (ra, rb) + } else { + (rb, ra) + }; + self.parent[small] = big; + self.size[big] += self.size[small]; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn weak_boundary_splits_on_light_edges() { + let g = DynamicGraph::new(); + // Heavy clique {0,1,2}, heavy clique {3,4,5}, light bridge 2-3. + for &(u, v, w) in &[ + (0, 1, 1.0), + (1, 2, 1.0), + (2, 0, 1.0), + (3, 4, 1.0), + (4, 5, 1.0), + (5, 3, 1.0), + (2, 3, 0.05), + ] { + g.insert_edge(u, v, w).unwrap(); + } + let mut regions = weak_boundary_regions(&g, 0.5); + for r in &mut regions { + r.sort_unstable(); + } + regions.sort_by_key(|r| r[0]); + assert_eq!(regions, vec![vec![0, 1, 2], vec![3, 4, 5]]); + } + + #[test] + fn ensure_coverage_adds_missing() { + let mut regions = vec![vec![0u64, 1]]; + ensure_coverage(&mut regions, &[0, 1, 2, 3]); + let singletons: usize = regions.iter().filter(|r| r.len() == 1).count(); + assert_eq!(singletons, 2); // 2 and 3 added + } + + #[test] + fn centroid_mean_and_medoid() { + let mut f = NodeFeatures::new(1, 0); + f.set_embedding(0, vec![0.0]).unwrap(); + f.set_embedding(1, vec![2.0]).unwrap(); + f.set_embedding(2, vec![4.0]).unwrap(); + let (centroid, medoid) = centroid_and_medoid(&[0, 1, 2], &f, 1).unwrap(); + assert!((centroid[0] - 2.0).abs() < 1e-6); + assert_eq!(medoid, 1); + } + + #[test] + fn class_dist_normalises() { + let mut f = NodeFeatures::new(1, 3); + f.set(0, vec![0.0], 0).unwrap(); + f.set(1, vec![0.0], 0).unwrap(); + f.set(2, vec![0.0], 2).unwrap(); + let d = class_distribution(&[0, 1, 2], &f, 3); + assert!((d[0] - 2.0 / 3.0).abs() < 1e-6); + assert_eq!(d[1], 0.0); + assert!((d[2] - 1.0 / 3.0).abs() < 1e-6); + } + + #[test] + fn normalize_unit_length() { + let mut v = vec![3.0f32, 4.0]; + l2_normalize(&mut v); + let n: f32 = v.iter().map(|x| x * x).sum::().sqrt(); + assert!((n - 1.0).abs() < 1e-6); + } +} diff --git a/crates/ruvector-graph-condense/src/stream.rs b/crates/ruvector-graph-condense/src/stream.rs new file mode 100644 index 000000000..4792c6404 --- /dev/null +++ b/crates/ruvector-graph-condense/src/stream.rs @@ -0,0 +1,215 @@ +//! Streaming condensation: maintain a growing graph + features and re-condense +//! on demand. +//! +//! The 2024–2026 literature treats streaming/temporal condensation as an open +//! problem (only OpenGC and CaT/PUMA touch it, in restricted settings). This +//! crate takes a deliberately honest stance: it does **lazy re-condensation**, +//! not true incremental region surgery. Edges/features are buffered into a +//! [`DynamicGraph`]; the condensed view is rebuilt when it is read while dirty, +//! or every `rebuild_interval` mutations. The win is amortisation and a stable +//! API for edge pipelines (e.g. condensing a RuView WorldGraph as it grows), +//! not sublinear updates — that remains future work. + +use crate::condense::{CondenseConfig, GraphCondenser}; +use crate::error::Result; +use crate::features::NodeFeatures; +use crate::node::CondensedGraph; +use ruvector_mincut::{DynamicGraph, VertexId, Weight}; + +/// A mutable graph + feature store that condenses lazily. +pub struct StreamingCondenser { + graph: DynamicGraph, + features: NodeFeatures, + condenser: GraphCondenser, + cached: Option, + dirty: bool, + ops_since_rebuild: usize, + rebuild_interval: usize, +} + +impl StreamingCondenser { + /// Create a streaming condenser. + /// + /// `rebuild_interval` is the maximum number of mutations tolerated before + /// [`StreamingCondenser::condensed`] forces a rebuild even if not otherwise + /// read. Use `0` to rebuild only on explicit reads of a dirty state. + pub fn new( + config: CondenseConfig, + dim: usize, + num_classes: usize, + rebuild_interval: usize, + ) -> Self { + Self { + graph: DynamicGraph::new(), + features: NodeFeatures::new(dim, num_classes), + condenser: GraphCondenser::new(config), + cached: None, + dirty: true, + ops_since_rebuild: 0, + rebuild_interval, + } + } + + /// Number of vertices currently buffered. + pub fn num_vertices(&self) -> usize { + self.graph.num_vertices() + } + + /// Number of edges currently buffered. + pub fn num_edges(&self) -> usize { + self.graph.num_edges() + } + + /// Borrow the underlying graph (read-only). + pub fn graph(&self) -> &DynamicGraph { + &self.graph + } + + /// Set/replace the embedding (and optional label) for a vertex. Marks the + /// condensed view dirty. + /// + /// # Errors + /// Propagates dimension validation from [`NodeFeatures`]. + pub fn upsert_feature( + &mut self, + vertex: VertexId, + embedding: Vec, + label: Option, + ) -> Result<()> { + self.features.set_embedding(vertex, embedding)?; + if let Some(l) = label { + self.features.set_label(vertex, l); + } + self.touch(); + Ok(()) + } + + /// Insert an edge. Both endpoints must already have features (call + /// [`StreamingCondenser::upsert_feature`] first) for a later condense to + /// succeed. Duplicate edges are ignored (idempotent). + pub fn insert_edge(&mut self, u: VertexId, v: VertexId, weight: Weight) { + if self.graph.insert_edge(u, v, weight).is_ok() { + self.touch(); + } + } + + /// Update an existing edge's weight (no-op if the edge is absent). + pub fn update_edge(&mut self, u: VertexId, v: VertexId, weight: Weight) { + if self.graph.update_edge_weight(u, v, weight).is_ok() { + self.touch(); + } + } + + /// Delete an edge (no-op if absent). + pub fn delete_edge(&mut self, u: VertexId, v: VertexId) { + if self.graph.delete_edge(u, v).is_ok() { + self.touch(); + } + } + + /// Whether the cached condensed view is stale. + pub fn is_dirty(&self) -> bool { + self.dirty + } + + /// Get the current condensed view, rebuilding if dirty (or if the rebuild + /// interval has elapsed). Returns `None` only when the graph is empty. + /// + /// # Errors + /// Propagates condensation errors (e.g. a vertex missing its feature). + pub fn condensed(&mut self) -> Result> { + if self.graph.num_vertices() == 0 { + self.cached = None; + self.dirty = false; + return Ok(None); + } + let interval_elapsed = + self.rebuild_interval > 0 && self.ops_since_rebuild >= self.rebuild_interval; + if self.dirty || interval_elapsed || self.cached.is_none() { + self.rebuild()?; + } + Ok(self.cached.as_ref()) + } + + /// Force an immediate re-condensation regardless of dirty state. + /// + /// # Errors + /// Propagates condensation errors. + pub fn rebuild(&mut self) -> Result<()> { + let condensed = self.condenser.condense(&self.graph, &self.features)?; + self.cached = Some(condensed); + self.dirty = false; + self.ops_since_rebuild = 0; + Ok(()) + } + + fn touch(&mut self) { + self.dirty = true; + self.ops_since_rebuild += 1; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::condense::{CondenseConfig, CondenseMethod}; + + fn cfg() -> CondenseConfig { + CondenseConfig { + method: CondenseMethod::ConnectedComponents, + normalize_centroids: false, + } + } + + #[test] + fn empty_returns_none() { + let mut s = StreamingCondenser::new(cfg(), 1, 0, 0); + assert!(s.condensed().unwrap().is_none()); + } + + #[test] + fn condenses_after_growth() { + let mut s = StreamingCondenser::new(cfg(), 1, 0, 0); + for v in 0..4u64 { + s.upsert_feature(v, vec![v as f32], None).unwrap(); + } + s.insert_edge(0, 1, 1.0); + s.insert_edge(2, 3, 1.0); + assert!(s.is_dirty()); + let c = s.condensed().unwrap().unwrap(); + // Two components -> two super-nodes. + assert_eq!(c.node_count(), 2); + assert!(!s.is_dirty()); + } + + #[test] + fn caches_until_mutated() { + let mut s = StreamingCondenser::new(cfg(), 1, 0, 0); + s.upsert_feature(0, vec![0.0], None).unwrap(); + s.upsert_feature(1, vec![1.0], None).unwrap(); + s.insert_edge(0, 1, 1.0); + let n1 = s.condensed().unwrap().unwrap().node_count(); + assert_eq!(n1, 1); + assert!(!s.is_dirty()); + // Reading again without mutation does not re-dirty. + let _ = s.condensed().unwrap(); + assert!(!s.is_dirty()); + + // A new disconnected vertex+edge splits into a second component. + s.upsert_feature(2, vec![2.0], None).unwrap(); + s.upsert_feature(3, vec![3.0], None).unwrap(); + s.insert_edge(2, 3, 1.0); + assert!(s.is_dirty()); + assert_eq!(s.condensed().unwrap().unwrap().node_count(), 2); + } + + #[test] + fn interval_forces_rebuild_path() { + // rebuild_interval=1 exercises the interval branch; result stays correct. + let mut s = StreamingCondenser::new(cfg(), 1, 0, 1); + s.upsert_feature(0, vec![0.0], None).unwrap(); + s.upsert_feature(1, vec![1.0], None).unwrap(); + s.insert_edge(0, 1, 1.0); + assert_eq!(s.condensed().unwrap().unwrap().node_count(), 1); + } +} diff --git a/crates/ruvector-graph-condense/src/synthetic.rs b/crates/ruvector-graph-condense/src/synthetic.rs new file mode 100644 index 000000000..8d942f81f --- /dev/null +++ b/crates/ruvector-graph-condense/src/synthetic.rs @@ -0,0 +1,155 @@ +//! Synthetic planted-partition graphs for testing, benchmarking, and demos. +//! +//! Produces a graph with `num_communities` ground-truth communities: dense, +//! heavy intra-community edges and sparse, light inter-community edges, with +//! each community's embeddings drawn around a distinct centroid and sharing a +//! class label. This is the canonical stress test for a structure-preserving +//! condenser — a good condenser should recover the planted communities. + +use crate::features::NodeFeatures; +use rand::rngs::StdRng; +use rand::{Rng, SeedableRng}; +use ruvector_mincut::DynamicGraph; + +/// Parameters for a planted-partition (stochastic-block-model-style) graph. +#[derive(Debug, Clone)] +pub struct PlantedPartition { + /// Number of ground-truth communities. + pub num_communities: usize, + /// Vertices per community. + pub community_size: usize, + /// Embedding dimension. + pub dim: usize, + /// Probability of an edge between two vertices in the same community. + pub p_intra: f64, + /// Probability of an edge between two vertices in different communities. + pub p_inter: f64, + /// Weight assigned to intra-community edges. + pub w_intra: f64, + /// Weight assigned to inter-community edges. + pub w_inter: f64, + /// RNG seed for reproducibility. + pub seed: u64, +} + +impl Default for PlantedPartition { + fn default() -> Self { + Self { + num_communities: 8, + community_size: 32, + dim: 16, + p_intra: 0.4, + p_inter: 0.002, + w_intra: 1.0, + w_inter: 0.1, + seed: 0xC0FFEE, + } + } +} + +impl PlantedPartition { + /// Total vertex count. + pub fn total_vertices(&self) -> usize { + self.num_communities * self.community_size + } + + /// Generate the graph and matching [`NodeFeatures`]. + /// + /// Vertices are numbered `0..total_vertices`; community `c` owns the + /// contiguous block `[c*size, (c+1)*size)`. Every vertex receives an + /// embedding (so condensation never hits a missing feature) clustered + /// around its community centroid, plus that community's class label. + pub fn generate(&self) -> (DynamicGraph, NodeFeatures) { + let mut rng = StdRng::seed_from_u64(self.seed); + let n = self.total_vertices(); + let graph = DynamicGraph::with_capacity(n, n * 4); + let mut features = NodeFeatures::new(self.dim, self.num_communities); + + // Community centroids spaced far apart so feature space mirrors topology. + let centroids: Vec> = (0..self.num_communities) + .map(|c| { + let mut v = vec![0f32; self.dim]; + v[c % self.dim] = 10.0 * (c / self.dim + 1) as f32; + v + }) + .collect(); + + for (c, centroid) in centroids.iter().enumerate() { + for i in 0..self.community_size { + let vid = (c * self.community_size + i) as u64; + let mut emb = centroid.clone(); + for x in &mut emb { + *x += rng.gen_range(-1.0..1.0); + } + // set() only fails on dimension mismatch, which cannot happen here. + let _ = features.set(vid, emb, c); + graph.add_vertex(vid); + } + } + + // Edges. insert_edge dedups and rejects self-loops, so collisions are + // simply skipped. + for a in 0..n { + for b in (a + 1)..n { + let same = a / self.community_size == b / self.community_size; + let (p, w) = if same { + (self.p_intra, self.w_intra) + } else { + (self.p_inter, self.w_inter) + }; + if rng.gen_bool(p) { + let _ = graph.insert_edge(a as u64, b as u64, w); + } + } + } + + (graph, features) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::condense::condense; + + #[test] + fn generates_requested_size() { + let pp = PlantedPartition { + num_communities: 4, + community_size: 10, + ..Default::default() + }; + let (g, f) = pp.generate(); + assert_eq!(g.num_vertices(), 40); + assert_eq!(f.len(), 40); + assert_eq!(f.num_classes(), 4); + } + + #[test] + fn condenser_recovers_planted_structure() { + // Strong planted structure should condense to roughly the planted count + // and keep most weight intra-region. + let pp = PlantedPartition { + num_communities: 4, + community_size: 24, + dim: 8, + p_intra: 0.6, + p_inter: 0.001, + seed: 7, + ..Default::default() + }; + let (g, f) = pp.generate(); + let c = condense(&g, &f).unwrap(); + assert_eq!(c.source_nodes, 96); + // Recursive min-cut can over-split; expect at least the planted count + // and a strong reduction. + assert!(c.node_count() >= 4); + assert!(c.node_reduction_ratio() > 2.0); + let m = crate::metrics::evaluate(&g, &c); + assert!( + m.intra_weight_ratio > 0.8, + "intra ratio {}", + m.intra_weight_ratio + ); + } +} diff --git a/crates/ruvector-graph-condense/tests/accuracy.rs b/crates/ruvector-graph-condense/tests/accuracy.rs new file mode 100644 index 000000000..037c7f26c --- /dev/null +++ b/crates/ruvector-graph-condense/tests/accuracy.rs @@ -0,0 +1,141 @@ +//! End-to-end accuracy-retention test: a GNN trained on the condensed graph must +//! classify the original graph's held-out nodes nearly as well as one trained on +//! the full graph. This is the graph-condensation field's core success metric. +#![allow(clippy::needless_range_loop)] // index-heavy numeric test code + +use rand::rngs::StdRng; +use rand::{Rng, SeedableRng}; +use ruvector_graph_condense::gnn_eval::{accuracy, Gcn, GcnConfig, GcnGraph}; +use ruvector_graph_condense::{ + CondenseConfig, CondenseMethod, CondensedGraph, DiffCutConfig, GraphCondenser, NodeFeatures, +}; +use ruvector_mincut::DynamicGraph; + +fn gen( + classes: usize, + per_class: usize, + dim: usize, + noise: f64, + seed: u64, +) -> (DynamicGraph, NodeFeatures, Vec, usize) { + let n = classes * per_class; + let mut rng = StdRng::seed_from_u64(seed); + let g = DynamicGraph::new(); + let mut f = NodeFeatures::new(dim, classes); + let mut labels = vec![0usize; n]; + for i in 0..n { + let cls = i / per_class; + labels[i] = cls; + let emb: Vec = (0..dim) + .map(|d| { + let base = if d % classes == cls { 1.5 } else { 0.0 }; + (base + noise * rng.gen_range(-1.0..1.0)) as f32 + }) + .collect(); + f.set(i as u64, emb, cls).unwrap(); + g.add_vertex(i as u64); + } + for a in 0..n { + for b in (a + 1)..n { + let same = a / per_class == b / per_class; + let p = if same { 0.15 } else { 0.005 }; + if rng.gen_bool(p) { + let _ = g.insert_edge(a as u64, b as u64, 1.0); + } + } + } + (g, f, labels, n) +} + +fn full_arrays(g: &DynamicGraph, f: &NodeFeatures, n: usize) -> (GcnGraph, Vec) { + let edges: Vec<(usize, usize, f64)> = g + .edges() + .iter() + .map(|e| (e.source as usize, e.target as usize, e.weight)) + .collect(); + let dim = f.dim(); + let mut x = vec![0f64; n * dim]; + for i in 0..n { + if let Some(emb) = f.embedding(i as u64) { + for d in 0..dim { + x[i * dim + d] = emb[d] as f64; + } + } + } + (GcnGraph::from_edges(n, &edges), x) +} + +fn condensed_arrays(c: &CondensedGraph) -> (GcnGraph, Vec, Vec) { + let (cn, dim) = (c.node_count(), c.dim); + let mut x = vec![0f64; cn * dim]; + let mut labels = vec![0usize; cn]; + for (i, node) in c.nodes.iter().enumerate() { + for d in 0..dim { + x[i * dim + d] = node.centroid[d] as f64; + } + labels[i] = node.dominant_class().unwrap_or(0); + } + let edges: Vec<(usize, usize, f64)> = c + .edges + .iter() + .map(|e| (e.source as usize, e.target as usize, e.weight)) + .collect(); + (GcnGraph::from_edges(cn, &edges), x, labels) +} + +#[test] +fn condensed_graph_trains_a_usable_classifier() { + let classes = 3; + let (g, f, labels, n) = gen(classes, 24, 12, 1.2, 2026); + let (full, x_full) = full_arrays(&g, &f, n); + + // Train/test split. + let mut rng = StdRng::seed_from_u64(7); + let (mut train, mut test) = (Vec::new(), Vec::new()); + for i in 0..n { + if rng.gen_bool(0.6) { + train.push(i); + } else { + test.push(i); + } + } + + let cfg = GcnConfig { + epochs: 150, + ..Default::default() + }; + let base = Gcn::train(&cfg, &full, &x_full, f.dim(), &labels, classes, &train); + let acc_full = accuracy(&base.predict(&full, &x_full), &labels, &test); + assert!( + acc_full > 0.7, + "baseline too weak to be a fair test: {acc_full}" + ); + + // Condense (DiffMinCut, a few super-nodes per class) and train on it. + let c = GraphCondenser::new(CondenseConfig { + method: CondenseMethod::DiffMinCut(DiffCutConfig { + num_clusters: classes * 3, + restarts: 2, + iterations: 300, + ..Default::default() + }), + normalize_centroids: false, + }) + .condense(&g, &f) + .unwrap(); + let (cg, x_cond, lab_cond) = condensed_arrays(&c); + let all: Vec = (0..c.node_count()).collect(); + let model = Gcn::train(&cfg, &cg, &x_cond, f.dim(), &lab_cond, classes, &all); + let acc_cond = accuracy(&model.predict(&full, &x_full), &labels, &test); + + let retention = acc_cond / acc_full; + assert!( + c.node_count() < n / 4, + "expected real reduction, got {} of {n}", + c.node_count() + ); + assert!( + retention > 0.8, + "retention too low: cond {acc_cond:.3} / full {acc_full:.3} = {retention:.3}" + ); +} diff --git a/crates/ruvector-graph-condense/tests/diffcut.rs b/crates/ruvector-graph-condense/tests/diffcut.rs new file mode 100644 index 000000000..f383b99a5 --- /dev/null +++ b/crates/ruvector-graph-condense/tests/diffcut.rs @@ -0,0 +1,285 @@ +//! Public-API integration tests for the differentiable min-cut condenser. +//! (Internal gradient-check / maths tests live in the `diffcut` module itself.) + +use ruvector_graph_condense::{ + CondenseError, DiffCutCondenser, DiffCutConfig, InitStrategy, Optimizer, PlantedPartition, +}; +use ruvector_mincut::DynamicGraph; + +fn barbell() -> DynamicGraph { + let g = DynamicGraph::new(); + for &(u, v, w) in &[ + (0, 1, 1.0), + (1, 2, 1.0), + (2, 0, 1.0), + (3, 4, 1.0), + (4, 5, 1.0), + (5, 3, 1.0), + (2, 3, 0.05), + ] { + g.insert_edge(u, v, w).unwrap(); + } + g +} + +#[test] +fn loss_decreases_during_training() { + // From a *random* start with SGD, training must reduce the loss (a clean + // descent test, independent of the warm-start prior). + let g = barbell(); + let base = DiffCutConfig { + num_clusters: 2, + learning_rate: 0.3, + init: InitStrategy::Random, + optimizer: Optimizer::Sgd { momentum: 0.0 }, + iterations: 1, + seed: 7, + ..Default::default() + }; + let early = DiffCutCondenser::new(base.clone()) + .train(&g) + .unwrap() + .loss(); + let late = DiffCutCondenser::new(DiffCutConfig { + iterations: 300, + ..base + }) + .train(&g) + .unwrap() + .loss(); + assert!( + late.total < early.total, + "training did not reduce loss: {} -> {}", + early.total, + late.total + ); + assert!(late.cut < -0.7, "cut term {} not minimised", late.cut); +} + +#[test] +fn recovers_barbell_partition() { + let g = barbell(); + let res = DiffCutCondenser::new(DiffCutConfig { + num_clusters: 2, + ..Default::default() + }) + .train(&g) + .unwrap(); + let mut regions = res.hard_regions(); + for r in &mut regions { + r.sort_unstable(); + } + regions.sort_by_key(|r| r[0]); + assert_eq!(regions, vec![vec![0, 1, 2], vec![3, 4, 5]]); +} + +/// Weighted dominant-class purity of a hard assignment vs. ground-truth +/// communities (vertex `v` belongs to community `v / community_size`). +fn purity(regions: &[Vec], community_size: u64) -> f64 { + let mut correct = 0u64; + let mut total = 0u64; + for r in regions { + let mut counts: std::collections::HashMap = std::collections::HashMap::new(); + for &v in r { + *counts.entry(v / community_size).or_default() += 1; + } + correct += counts.values().copied().max().unwrap_or(0); + total += r.len() as u64; + } + correct as f64 / total.max(1) as f64 +} + +#[test] +fn warm_start_recovers_many_clusters() { + // The headline "works on big problems" test: K = 8 on 8 planted communities. + let pp = PlantedPartition { + num_communities: 8, + community_size: 24, + dim: 8, + p_intra: 0.5, + p_inter: 0.002, + seed: 3, + ..Default::default() + }; + let (g, _f) = pp.generate(); + let res = DiffCutCondenser::new(DiffCutConfig { + num_clusters: 8, + ..Default::default() // Adam + warm-start + }) + .train(&g) + .unwrap(); + let pur = purity(&res.hard_regions(), pp.community_size as u64); + assert!(pur > 0.85, "warm-start purity at K=8 too low: {pur}"); +} + +#[test] +fn warm_start_beats_random_at_large_k() { + // Same graph, same budget: warm-start should reach a lower (better) loss + // than random init at large K — the whole point of the optimisation work. + let pp = PlantedPartition { + num_communities: 8, + community_size: 20, + dim: 8, + p_intra: 0.5, + p_inter: 0.002, + seed: 11, + ..Default::default() + }; + let (g, _f) = pp.generate(); + let common = DiffCutConfig { + num_clusters: 8, + iterations: 200, + seed: 1, + ..Default::default() + }; + let warm = DiffCutCondenser::new(common.clone()).train(&g).unwrap(); + let rand = DiffCutCondenser::new(DiffCutConfig { + init: InitStrategy::Random, + ..common + }) + .train(&g) + .unwrap(); + assert!( + warm.loss().total <= rand.loss().total, + "warm-start ({}) not better than random ({})", + warm.loss().total, + rand.loss().total + ); + let pur_warm = purity(&warm.hard_regions(), pp.community_size as u64); + let pur_rand = purity(&rand.hard_regions(), pp.community_size as u64); + assert!( + pur_warm >= pur_rand, + "warm purity {pur_warm} < random purity {pur_rand}" + ); +} + +#[test] +fn determinism_same_seed_same_result() { + let g = barbell(); + let cfg = DiffCutConfig { + num_clusters: 2, + iterations: 200, + seed: 5, + ..Default::default() + }; + let a = DiffCutCondenser::new(cfg.clone()).train(&g).unwrap(); + let b = DiffCutCondenser::new(cfg).train(&g).unwrap(); + assert_eq!(a.soft_assignment(), b.soft_assignment()); + assert_eq!(a.loss(), b.loss()); +} + +#[test] +fn parallel_matches_sequential_exactly() { + // Row-parallel A·S is deterministic, so parallel must equal sequential + // bit-for-bit (same seed, same config otherwise). + let pp = PlantedPartition { + num_communities: 6, + community_size: 24, + dim: 8, + seed: 4, + ..Default::default() + }; + let (g, _f) = pp.generate(); + let base = DiffCutConfig { + num_clusters: 6, + iterations: 120, + seed: 2, + tolerance: 0.0, // disable early-stop so both run identical iterations + ..Default::default() + }; + let seq = DiffCutCondenser::new(base.clone()).train(&g).unwrap(); + let par = DiffCutCondenser::new(DiffCutConfig { + parallel: true, + ..base + }) + .train(&g) + .unwrap(); + assert_eq!(seq.soft_assignment(), par.soft_assignment()); + assert_eq!(seq.loss(), par.loss()); +} + +#[test] +fn minibatch_recovers_structure() { + // Stochastic edge-minibatch should still recover the planted communities + // (warm-start prior + refinement), at a fraction of the per-step edge cost. + let pp = PlantedPartition { + num_communities: 6, + community_size: 24, + dim: 8, + p_intra: 0.5, + p_inter: 0.002, + seed: 9, + ..Default::default() + }; + let (g, _f) = pp.generate(); + let res = DiffCutCondenser::new(DiffCutConfig { + num_clusters: 6, + minibatch_edges: Some(256), + iterations: 150, + seed: 1, + ..Default::default() + }) + .train(&g) + .unwrap(); + let pur = purity(&res.hard_regions(), pp.community_size as u64); + assert!(pur > 0.8, "minibatch purity too low: {pur}"); +} + +#[test] +fn early_stopping_cuts_iterations() { + // Warm-start lands near the optimum, so early-stop should finish well under + // the iteration cap. + let pp = PlantedPartition { + num_communities: 6, + community_size: 20, + dim: 8, + seed: 6, + ..Default::default() + }; + let (g, _f) = pp.generate(); + let res = DiffCutCondenser::new(DiffCutConfig { + num_clusters: 6, + iterations: 1000, + tolerance: 1e-4, + seed: 1, + ..Default::default() + }) + .train(&g) + .unwrap(); + assert!( + res.iterations_run() < 1000, + "early-stop did not trigger: {}", + res.iterations_run() + ); +} + +#[test] +fn empty_graph_errors() { + let g = DynamicGraph::new(); + assert!(matches!( + DiffCutCondenser::new(DiffCutConfig::default()) + .train(&g) + .unwrap_err(), + CondenseError::EmptyGraph + )); +} + +#[test] +fn zero_clusters_errors() { + let g = barbell(); + let err = DiffCutCondenser::new(DiffCutConfig { + num_clusters: 0, + ..Default::default() + }) + .train(&g) + .unwrap_err(); + assert!(matches!(err, CondenseError::InvalidConfig(_))); +} + +#[test] +fn public_min_cut_loss_dimension_check() { + use ruvector_graph_condense::min_cut_loss; + let g = barbell(); + let err = min_cut_loss(&g, &[0.5; 3], 2, 1.0).unwrap_err(); + assert!(matches!(err, CondenseError::DimensionMismatch { .. })); +} diff --git a/crates/ruvector-perception/Cargo.toml b/crates/ruvector-perception/Cargo.toml new file mode 100644 index 000000000..c53004729 --- /dev/null +++ b/crates/ruvector-perception/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "ruvector-perception" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +description = "The layer under classification: physical delta -> boundary -> coherence -> proof -> action. A trusted-physical-memory engine that emits structured delta witnesses, not class labels." +keywords = ["sensing", "coherence", "min-cut", "edge-ai", "anomaly"] +categories = ["algorithms", "science"] + +[lib] +crate-type = ["rlib"] + +[dependencies] +# Boundary detection reuses the dynamic min-cut engine. +ruvector-mincut = { version = "2.2.3", path = "../ruvector-mincut", default-features = false } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +sha2 = "0.10" + +[dev-dependencies] + +[lints] +workspace = true diff --git a/crates/ruvector-perception/src/absence.rs b/crates/ruvector-perception/src/absence.rs new file mode 100644 index 000000000..12677e52f --- /dev/null +++ b/crates/ruvector-perception/src/absence.rs @@ -0,0 +1,129 @@ +//! Contrastive absence sensing: detect a *missing* expected continuation as a +//! structured signal, not a threshold alert. The expected temporal pattern is a +//! sequence of zone events (e.g. `bed_exit → bathroom_path → return_path`); when +//! a continuation edge never arrives within its deadline, the sequence graph is +//! left incomplete — that incompleteness is the signal. + +/// A structured absence: an expected next step that did not occur in time. +#[derive(Debug, Clone, PartialEq)] +pub struct Absence { + /// The step that was expected but never arrived. + pub missing_step: String, + /// The last step that *did* occur. + pub after: String, + /// How long we have waited past the last observed step. + pub elapsed: u64, +} + +/// Monitors progress through an expected sequence and flags missing +/// continuations. +#[derive(Debug, Clone)] +pub struct SequenceMonitor { + steps: Vec, + deadline: u64, + pos: usize, + last_t: Option, + started: bool, +} + +impl SequenceMonitor { + /// New monitor for an ordered list of expected zone events, with a + /// per-step deadline (in the same time units as observations). + pub fn new(steps: Vec, deadline: u64) -> Self { + Self { + steps, + deadline, + pos: 0, + last_t: None, + started: false, + } + } + + /// Whether the full sequence has completed. + pub fn complete(&self) -> bool { + self.pos >= self.steps.len() + } + + /// Record that an event happened in `zone` at time `t`. Advances the + /// sequence if it matches the next expected step. + pub fn observe_zone(&mut self, zone: &str, t: u64) { + if self.complete() { + return; + } + if self.steps[self.pos] == zone { + self.pos += 1; + self.last_t = Some(t); + self.started = true; + } + } + + /// Check for a missing continuation as of `now`. Returns an [`Absence`] if + /// the sequence has started, is not complete, and the next step is overdue. + pub fn check(&self, now: u64) -> Option { + if !self.started || self.complete() { + return None; + } + let last = self.last_t?; + let elapsed = now.saturating_sub(last); + if elapsed > self.deadline { + Some(Absence { + missing_step: self.steps[self.pos].clone(), + after: self.steps[self.pos - 1].clone(), + elapsed, + }) + } else { + None + } + } + + /// Reset to the start (e.g. for a new day/cycle). + pub fn reset(&mut self) { + self.pos = 0; + self.last_t = None; + self.started = false; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn routine() -> SequenceMonitor { + SequenceMonitor::new( + vec![ + "bed_exit".to_string(), + "bathroom_path".to_string(), + "return_path".to_string(), + ], + 100, + ) + } + + #[test] + fn missing_return_is_flagged() { + let mut m = routine(); + m.observe_zone("bed_exit", 0); + m.observe_zone("bathroom_path", 10); + assert!(m.check(50).is_none()); // still within deadline + let a = m.check(200).expect("overdue return"); + assert_eq!(a.missing_step, "return_path"); + assert_eq!(a.after, "bathroom_path"); + assert!(a.elapsed > 100); + } + + #[test] + fn completed_routine_is_silent() { + let mut m = routine(); + m.observe_zone("bed_exit", 0); + m.observe_zone("bathroom_path", 10); + m.observe_zone("return_path", 20); + assert!(m.complete()); + assert!(m.check(10_000).is_none()); + } + + #[test] + fn unstarted_routine_is_silent() { + let m = routine(); + assert!(m.check(10_000).is_none()); + } +} diff --git a/crates/ruvector-perception/src/captcha.rs b/crates/ruvector-perception/src/captcha.rs new file mode 100644 index 000000000..3e744119e --- /dev/null +++ b/crates/ruvector-perception/src/captcha.rs @@ -0,0 +1,400 @@ +//! Physical CAPTCHA — proof-of-reality via active challenge–response. +//! +//! A replayed or statically-spoofed sensor stream can mimic *passive* readings, +//! but it cannot answer a *fresh* physical challenge. This module models that +//! interaction: the device emits a [`Stimulus`] (a chirp, an RF pulse, a tap, +//! …) and expects a multi-modal [`ObservedResponse`] with characteristic +//! per-modality delays and magnitudes. +//! +//! [`CaptchaVerifier`] learns the expected response *profile* for each stimulus +//! from known-good challenges (an EWMA over delay and magnitude per modality), +//! then [`CaptchaVerifier::verify`] scores a fresh observation against it. The +//! score is weighted by each modality's [`spoof_resistance`]: a missing +//! hard-to-fake modality (e.g. vibration or thermal) costs far more than a +//! missing easy-to-fake one (e.g. RF or optical). +//! +//! [`spoof_resistance`]: crate::modality::Physics::spoof_resistance + +use crate::modality::Modality; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// EWMA smoothing factor for profile learning. Newer observations get weight +/// `ALPHA`, the running estimate keeps `1 - ALPHA`. +const ALPHA: f32 = 0.3; + +/// Minimum fraction of the expected magnitude an observed response must reach to +/// count as a valid (non-spoofed, non-attenuated) reply. +const MAGNITUDE_FLOOR_FRACTION: f32 = 0.5; + +/// An active physical challenge emitted by the device. +/// +/// Each variant maps to a distinct emission whose echoes/responses propagate +/// across several [`Modality`]s with modality-specific delays. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Stimulus { + /// A swept-frequency acoustic tone ("chirp"). + AcousticChirp, + /// A short radio-frequency burst. + RfPulse, + /// A mechanical tap exciting structural vibration. + VibrationTap, + /// A modulated-light flash. + LightModulation, + /// A brief thermal/IR pulse. + ThermalPulse, +} + +impl Stimulus { + /// Stable short name (useful for logs and witnesses). + pub fn name(self) -> &'static str { + match self { + Stimulus::AcousticChirp => "acoustic-chirp", + Stimulus::RfPulse => "rf-pulse", + Stimulus::VibrationTap => "vibration-tap", + Stimulus::LightModulation => "light-modulation", + Stimulus::ThermalPulse => "thermal-pulse", + } + } +} + +/// One observed response on a single modality to an emitted [`Stimulus`]. +#[derive(Debug, Clone, PartialEq)] +pub struct ObservedResponse { + /// Which modality this reading came from. + pub modality: Modality, + /// Time from stimulus emission to observed response (seconds). + pub delay: f32, + /// Response magnitude in arbitrary, modality-normalised units (`>= 0`). + pub magnitude: f32, +} + +/// A complete challenge–response record: one stimulus, many modality responses. +#[derive(Debug, Clone)] +pub struct ChallengeResponse { + /// The stimulus that was emitted. + pub stimulus: Stimulus, + /// All observed responses (at most one expected per modality). + pub responses: Vec, +} + +/// The verdict produced by [`CaptchaVerifier::verify`]. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RealityProof { + /// `true` when the weighted score met the verifier's `min_score`. + pub trusted: bool, + /// Weighted fraction of expected modalities that responded correctly, in `[0, 1]`. + pub score: f32, + /// Expected modalities that were missing or out of tolerance. + pub missing: Vec, + /// Human-readable explanation of the verdict. + pub reason: String, +} + +/// Expected per-modality response, maintained as a running EWMA. +#[derive(Debug, Clone, Copy)] +struct Expected { + delay: f32, + magnitude: f32, +} + +/// Learns expected challenge-response profiles and verifies fresh observations +/// against them. +#[derive(Debug, Clone)] +pub struct CaptchaVerifier { + /// Per `(Stimulus, Modality)` expected response, learned via EWMA. + profiles: HashMap<(Stimulus, Modality), Expected>, + /// Allowed absolute deviation in delay (seconds) for a response to count. + delay_tolerance: f32, + /// Minimum weighted score to mark a proof as `trusted`. + min_score: f32, +} + +impl CaptchaVerifier { + /// Create a verifier. + /// + /// * `delay_tolerance` — max absolute delay error (seconds) tolerated per modality. + /// * `min_score` — weighted score threshold (`[0, 1]`) for `trusted`. + pub fn new(delay_tolerance: f32, min_score: f32) -> Self { + Self { + profiles: HashMap::new(), + delay_tolerance, + min_score, + } + } + + /// Learn (or update) the expected response profile for a stimulus from a + /// known-good challenge response. + /// + /// For every observed modality the expected `{delay, magnitude}` is folded + /// in with an EWMA (`ALPHA`). The first time a `(stimulus, modality)` pair + /// is seen it is initialised directly to the observed values. + pub fn learn(&mut self, cr: &ChallengeResponse) { + for r in &cr.responses { + let key = (cr.stimulus, r.modality); + self.profiles + .entry(key) + .and_modify(|e| { + e.delay = ewma(e.delay, r.delay); + e.magnitude = ewma(e.magnitude, r.magnitude); + }) + .or_insert(Expected { + delay: r.delay, + magnitude: r.magnitude, + }); + } + } + + /// Verify an observed challenge response against the learned profile. + /// + /// Each expected modality contributes weight equal to its + /// [`spoof_resistance`]; a modality passes only when an observed response + /// exists whose delay is within `delay_tolerance` and whose magnitude is at + /// least [`MAGNITUDE_FLOOR_FRACTION`] of the expected magnitude. The + /// returned `score` is `passed_weight / total_weight`. + /// + /// [`spoof_resistance`]: crate::modality::Physics::spoof_resistance + pub fn verify(&self, cr: &ChallengeResponse) -> RealityProof { + // Gather everything we expect for this stimulus. + let expected: Vec<(Modality, Expected)> = Modality::ALL + .iter() + .filter_map(|&m| self.profiles.get(&(cr.stimulus, m)).map(|e| (m, *e))) + .collect(); + + if expected.is_empty() { + return RealityProof { + trusted: false, + score: 0.0, + missing: Vec::new(), + reason: "unknown stimulus profile".to_string(), + }; + } + + let mut total_weight = 0.0_f32; + let mut passed_weight = 0.0_f32; + let mut missing: Vec = Vec::new(); + + for (modality, exp) in &expected { + let weight = modality.physics().spoof_resistance; + total_weight += weight; + + if self.responded_correctly(cr, *modality, exp) { + passed_weight += weight; + } else { + missing.push(*modality); + } + } + + // `total_weight` is > 0 because `expected` is non-empty and every + // modality has a positive spoof_resistance. + let score = passed_weight / total_weight; + let trusted = score >= self.min_score; + let reason = self.explain(trusted, &missing); + + RealityProof { + trusted, + score, + missing, + reason, + } + } + + /// Does `cr` contain a correct response for `modality` matching `exp`? + fn responded_correctly( + &self, + cr: &ChallengeResponse, + modality: Modality, + exp: &Expected, + ) -> bool { + let floor = exp.magnitude * MAGNITUDE_FLOOR_FRACTION; + cr.responses.iter().any(|r| { + r.modality == modality + && (r.delay - exp.delay).abs() <= self.delay_tolerance + && r.magnitude >= floor + }) + } + + /// Build a human-readable reason for the verdict. + fn explain(&self, trusted: bool, missing: &[Modality]) -> String { + if missing.is_empty() { + return "all expected modalities responded within tolerance".to_string(); + } + + // Surface the hardest-to-spoof missing modality first — that is the one + // an attacker is least likely to fake. + let worst = missing + .iter() + .copied() + .max_by(|a, b| { + a.physics() + .spoof_resistance + .partial_cmp(&b.physics().spoof_resistance) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .expect("missing is non-empty here"); + + let names: Vec<&str> = missing.iter().map(|m| m.name()).collect(); + let prefix = if trusted { + "degraded but trusted" + } else { + "rejected" + }; + format!( + "{prefix}: missing high-spoof-resistance modality: {} (absent/out-of-tolerance: {})", + worst.name(), + names.join(", ") + ) + } +} + +/// Exponentially-weighted moving-average update. +fn ewma(prev: f32, sample: f32) -> f32 { + (1.0 - ALPHA) * prev + ALPHA * sample +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A representative known-good response for an acoustic chirp across the + /// fast modalities plus vibration (the hard-to-spoof one). + fn good_chirp() -> ChallengeResponse { + ChallengeResponse { + stimulus: Stimulus::AcousticChirp, + responses: vec![ + ObservedResponse { + modality: Modality::Acoustic, + delay: 0.030, + magnitude: 1.0, + }, + ObservedResponse { + modality: Modality::Vibration, + delay: 0.050, + magnitude: 0.8, + }, + ObservedResponse { + modality: Modality::Rf, + delay: 0.010, + magnitude: 0.5, + }, + ], + } + } + + #[test] + fn matching_response_is_trusted() { + let mut v = CaptchaVerifier::new(0.01, 0.8); + // Learn the profile a few times so the EWMA settles. + for _ in 0..5 { + v.learn(&good_chirp()); + } + + let proof = v.verify(&good_chirp()); + assert!( + proof.trusted, + "matching response should be trusted: {proof:?}" + ); + assert!( + proof.score > 0.99, + "score should be near 1.0: {}", + proof.score + ); + assert!(proof.missing.is_empty()); + assert_eq!( + proof.reason, + "all expected modalities responded within tolerance" + ); + } + + #[test] + fn replayed_missing_vibration_is_rejected() { + let mut v = CaptchaVerifier::new(0.01, 0.8); + for _ in 0..5 { + v.learn(&good_chirp()); + } + + // A replay that drops vibration entirely and zeroes the rest's delays. + let replay = ChallengeResponse { + stimulus: Stimulus::AcousticChirp, + responses: vec![ + ObservedResponse { + modality: Modality::Acoustic, + delay: 0.0, + magnitude: 1.0, + }, + ObservedResponse { + modality: Modality::Rf, + delay: 0.0, + magnitude: 0.5, + }, + ], + }; + + let proof = v.verify(&replay); + assert!(!proof.trusted, "replay should be rejected: {proof:?}"); + assert!( + proof.missing.contains(&Modality::Vibration), + "vibration must be flagged missing: {:?}", + proof.missing + ); + // Acoustic delay (0.0 vs ~0.03) is out of the 0.01 tolerance too. + assert!(proof.missing.contains(&Modality::Acoustic)); + assert!(proof.score < 0.8); + } + + #[test] + fn unknown_stimulus_is_rejected() { + let v = CaptchaVerifier::new(0.01, 0.8); + let probe = ChallengeResponse { + stimulus: Stimulus::ThermalPulse, + responses: vec![ObservedResponse { + modality: Modality::Thermal, + delay: 2.0, + magnitude: 1.0, + }], + }; + + let proof = v.verify(&probe); + assert!(!proof.trusted); + assert_eq!(proof.score, 0.0); + assert_eq!(proof.reason, "unknown stimulus profile"); + assert!(proof.missing.is_empty()); + } + + #[test] + fn weak_magnitude_fails_tolerance() { + let mut v = CaptchaVerifier::new(0.01, 0.9); + for _ in 0..5 { + v.learn(&good_chirp()); + } + + // Vibration arrives on time but with collapsed magnitude (< 50%). + let attenuated = ChallengeResponse { + stimulus: Stimulus::AcousticChirp, + responses: vec![ + ObservedResponse { + modality: Modality::Acoustic, + delay: 0.030, + magnitude: 1.0, + }, + ObservedResponse { + modality: Modality::Vibration, + delay: 0.050, + magnitude: 0.1, + }, + ObservedResponse { + modality: Modality::Rf, + delay: 0.010, + magnitude: 0.5, + }, + ], + }; + + let proof = v.verify(&attenuated); + assert!( + !proof.trusted, + "weak vibration should fail high threshold: {proof:?}" + ); + assert!(proof.missing.contains(&Modality::Vibration)); + } +} diff --git a/crates/ruvector-perception/src/coherence.rs b/crates/ruvector-perception/src/coherence.rs new file mode 100644 index 000000000..85e1b19ad --- /dev/null +++ b/crates/ruvector-perception/src/coherence.rs @@ -0,0 +1,151 @@ +//! Coherence field + boundary detection. +//! +//! Sensors/zones do not vote on an answer; they contribute to the *stability* of +//! a physical graph. Zones are nodes; edge weight is delta-pattern coherence +//! (quiet zones agree strongly; a zone whose physical state moved disagrees). +//! Dynamic min-cut then isolates the side that broke away — that is the moved +//! boundary, not a class label. + +use ruvector_mincut::MinCutBuilder; + +/// Where coherence broke this window. +#[derive(Debug, Clone, PartialEq)] +pub struct Boundary { + /// The single most-changed zone (the headline `changed_boundary`). + pub zone: String, + /// Every zone on the changed side of the cut. + pub side: Vec, + /// Cleanliness of the separation in `[0, 1]`: high = the changed side is + /// weakly coupled to the rest (a sharp, coherent boundary). + pub coherence: f32, +} + +/// Detect the moved boundary from per-zone delta vectors (each vector is the +/// per-modality |delta| for that zone, in a fixed modality order). +pub fn detect_boundary(deltas: &[(String, Vec)]) -> Option { + let k = deltas.len(); + if k == 0 { + return None; + } + let norm = |v: &[f64]| -> f64 { v.iter().map(|x| x * x).sum::().sqrt() }; + if k == 1 { + return Some(Boundary { + zone: deltas[0].0.clone(), + side: vec![deltas[0].0.clone()], + coherence: 0.0, + }); + } + + // Pairwise distances and the scale that maps distance -> coherence weight. + let dist = |a: &[f64], b: &[f64]| -> f64 { + a.iter() + .zip(b) + .map(|(x, y)| (x - y) * (x - y)) + .sum::() + .sqrt() + }; + let mut max_d = 0.0f64; + for i in 0..k { + for j in (i + 1)..k { + max_d = max_d.max(dist(&deltas[i].1, &deltas[j].1)); + } + } + let scale = if max_d > 1e-9 { max_d } else { 1.0 }; + const EPS: f64 = 1e-3; + let weight = |a: &[f64], b: &[f64]| -> f64 { + (1.0 - dist(a, b) / scale).max(EPS) // quiet-quiet ~1, outlier ~EPS + }; + + // Complete weighted graph over zones; global min cut isolates the outlier. + let mut edges = Vec::with_capacity(k * (k - 1) / 2); + for i in 0..k { + for j in (i + 1)..k { + edges.push((i as u64, j as u64, weight(&deltas[i].1, &deltas[j].1))); + } + } + let mincut = MinCutBuilder::new() + .exact() + .with_edges(edges) + .build() + .ok()?; + let result = mincut.min_cut(); + let (a, b) = result.partition?; + // Changed side = the smaller partition (the part that broke away). + let (changed, _rest) = if a.len() <= b.len() { (a, b) } else { (b, a) }; + if changed.is_empty() { + return None; + } + + let side: Vec = changed + .iter() + .map(|&i| deltas[i as usize].0.clone()) + .collect(); + // Headline zone = largest-magnitude delta on the changed side. + let zone = changed + .iter() + .max_by(|&&i, &&j| { + norm(&deltas[i as usize].1) + .partial_cmp(&norm(&deltas[j as usize].1)) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .map(|&i| deltas[i as usize].0.clone()) + .unwrap_or_else(|| side[0].clone()); + + // Coherence = how weakly the changed side couples to the rest. + let changed_set: std::collections::HashSet = + changed.iter().map(|&i| i as usize).collect(); + let mut cross_sum = 0.0; + let mut cross_n = 0; + for i in 0..k { + for j in (i + 1)..k { + if changed_set.contains(&i) != changed_set.contains(&j) { + cross_sum += weight(&deltas[i].1, &deltas[j].1); + cross_n += 1; + } + } + } + let mean_cross = if cross_n > 0 { + cross_sum / cross_n as f64 + } else { + 1.0 + }; + let coherence = (1.0 - mean_cross).clamp(0.0, 1.0) as f32; + + Some(Boundary { + zone, + side, + coherence, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn isolates_the_changed_zone() { + // Three quiet zones, one (B) moved. + let deltas = vec![ + ("A".to_string(), vec![0.0, 0.0, 0.0]), + ("B".to_string(), vec![3.0, 2.0, 2.5]), + ("C".to_string(), vec![0.0, 0.0, 0.0]), + ("D".to_string(), vec![0.0, 0.0, 0.0]), + ]; + let b = detect_boundary(&deltas).unwrap(); + assert_eq!(b.zone, "B"); + assert_eq!(b.side, vec!["B".to_string()]); + assert!(b.coherence > 0.8, "coherence {}", b.coherence); + } + + #[test] + fn no_change_means_low_coherence_boundary() { + let deltas = vec![ + ("A".to_string(), vec![0.0, 0.0]), + ("B".to_string(), vec![0.0, 0.0]), + ("C".to_string(), vec![0.0, 0.0]), + ]; + let b = detect_boundary(&deltas).unwrap(); + // Everything agrees -> no clean boundary. + assert!(b.coherence < 0.2, "coherence {}", b.coherence); + } +} diff --git a/crates/ruvector-perception/src/custody.rs b/crates/ruvector-perception/src/custody.rs new file mode 100644 index 000000000..bb2381cd1 --- /dev/null +++ b/crates/ruvector-perception/src/custody.rs @@ -0,0 +1,289 @@ +//! Sensor chain of custody — a tamper-evident, replayable ledger of perception +//! events. +//! +//! Every [`DeltaWitness`] already carries a SHA-256 `evidence_hash` and the +//! `prev_hash` of the witness before it, so a sequence of witnesses forms a hash +//! chain. This module wraps that chain in an append-only ledger so that every +//! action the engine takes can be *replayed* and its provenance *audited* — +//! which is what elder-care, medical, industrial, and civic-governance +//! deployments require before they trust an automated decision. +//! +//! ## Honest scope of verification +//! +//! [`CustodyLedger::verify`] checks **chain linkage**: each record's `prev_hash` +//! must equal the prior record's `evidence_hash` (and the first record's +//! `prev_hash` must be `None`). If anyone mutates a stored `evidence_hash`, the +//! link to the next record breaks and `verify` reports it. This is +//! **link-integrity**, *not* a full content re-hash: the raw signal and feature +//! bytes that produced each `evidence_hash` are not stored in the witness, so +//! this layer cannot recompute the SHA-256 from first principles. Detecting a +//! forged-but-internally-consistent hash would require those raw bytes; here we +//! detect tampering that breaks the chain. + +use crate::witness::DeltaWitness; +use serde::{Deserialize, Serialize}; + +/// Errors raised while maintaining or auditing the chain of custody. +#[derive(Debug, thiserror::Error)] +pub enum CustodyError { + /// A record's `prev_hash` did not match the expected prior `evidence_hash`. + #[error("broken chain at index {index}: prev_hash {found:?} != expected {expected:?}")] + BrokenChain { + /// Position in the ledger where the link broke. + index: usize, + /// The `prev_hash` actually found on the record. + found: Option, + /// The `evidence_hash` of the prior record (or `None` for the first). + expected: Option, + }, + /// No record carries the requested evidence hash. + #[error("no record with evidence hash {0}")] + NotFound(String), +} + +/// One entry in the ledger: the witnessed delta plus an optional outcome. +/// +/// The `outcome` is later feedback attached after the fact (e.g. "confirmed +/// fall", "false alarm", "operator acknowledged") so the audit trail records not +/// just what was perceived but what actually happened. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CustodyRecord { + /// The perception event, including its evidence/prev hash linkage. + pub witness: DeltaWitness, + /// Outcome/feedback attached to this event, if any. + pub outcome: Option, +} + +/// An append-only, tamper-evident ledger of perception events. +/// +/// Linkage is enforced at insert time by [`CustodyLedger::append`] and can be +/// re-audited at any time by [`CustodyLedger::verify`]. +#[derive(Debug, Clone, Default)] +pub struct CustodyLedger { + records: Vec, +} + +impl CustodyLedger { + /// Create an empty ledger. + pub fn new() -> Self { + Self::default() + } + + /// Append a witness to the ledger, enforcing chain linkage. + /// + /// The witness's `prev_hash` MUST equal the last record's `evidence_hash` + /// (or be `None` for the very first record); otherwise this returns + /// [`CustodyError::BrokenChain`] and the ledger is left unchanged. + pub fn append(&mut self, witness: DeltaWitness) -> Result<(), CustodyError> { + let expected = self.records.last().map(|r| r.witness.evidence_hash.clone()); + if witness.prev_hash != expected { + return Err(CustodyError::BrokenChain { + index: self.records.len(), + found: witness.prev_hash, + expected, + }); + } + self.records.push(CustodyRecord { + witness, + outcome: None, + }); + Ok(()) + } + + /// Attach an outcome/feedback to the record with the given evidence hash. + /// + /// Returns [`CustodyError::NotFound`] if no record carries that hash. + pub fn record_outcome( + &mut self, + evidence_hash: &str, + outcome: impl Into, + ) -> Result<(), CustodyError> { + let record = self + .records + .iter_mut() + .find(|r| r.witness.evidence_hash == evidence_hash) + .ok_or_else(|| CustodyError::NotFound(evidence_hash.to_string()))?; + record.outcome = Some(outcome.into()); + Ok(()) + } + + /// Re-audit the whole chain: every `prev_hash` must equal the prior record's + /// `evidence_hash` (and the first must be `None`). + /// + /// This verifies **chain linkage** — tampering with a stored `evidence_hash` + /// breaks the link to the next record and is reported here. It does **not** + /// recompute each SHA-256 from raw signal bytes (those are not stored in the + /// witness), so it is link-integrity, not full content re-hash. Returns the + /// first [`CustodyError::BrokenChain`] encountered. + pub fn verify(&self) -> Result<(), CustodyError> { + let mut expected: Option = None; + for (index, record) in self.records.iter().enumerate() { + if record.witness.prev_hash != expected { + return Err(CustodyError::BrokenChain { + index, + found: record.witness.prev_hash.clone(), + expected, + }); + } + expected = Some(record.witness.evidence_hash.clone()); + } + Ok(()) + } + + /// Number of records in the ledger. + pub fn len(&self) -> usize { + self.records.len() + } + + /// Whether the ledger holds no records. + pub fn is_empty(&self) -> bool { + self.records.is_empty() + } + + /// Borrow the full record slice (read-only; the ledger stays append-only). + pub fn records(&self) -> &[CustodyRecord] { + &self.records + } + + /// Return the chain of records from the start up to and including the record + /// with `evidence_hash` — the replayable provenance of that event. + /// + /// Returns [`CustodyError::NotFound`] if no record carries that hash. + pub fn replay_until(&self, evidence_hash: &str) -> Result, CustodyError> { + let end = self + .records + .iter() + .position(|r| r.witness.evidence_hash == evidence_hash) + .ok_or_else(|| CustodyError::NotFound(evidence_hash.to_string()))?; + Ok(self.records[..=end].iter().collect()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::modality::Modality; + use crate::witness::Action; + + /// Build a witness with explicit hash linkage; raw scores are arbitrary but + /// deterministic so tests focus on custody, not perception. + fn witness(t: u64, evidence_hash: &str, prev_hash: Option<&str>) -> DeltaWitness { + DeltaWitness { + t, + changed_boundary: format!("zone-{t}"), + supporting_modalities: vec![Modality::Rf, Modality::Vibration], + contradicting_modalities: vec![Modality::Thermal], + novelty: 0.8, + coherence: 0.7, + contradiction: 0.1, + action: Action::Alert, + evidence_hash: evidence_hash.to_string(), + prev_hash: prev_hash.map(str::to_string), + } + } + + fn three_link_ledger() -> CustodyLedger { + let mut ledger = CustodyLedger::new(); + ledger.append(witness(0, "h0", None)).unwrap(); + ledger.append(witness(1, "h1", Some("h0"))).unwrap(); + ledger.append(witness(2, "h2", Some("h1"))).unwrap(); + ledger + } + + #[test] + fn three_link_chain_verifies() { + let ledger = three_link_ledger(); + assert_eq!(ledger.len(), 3); + assert!(!ledger.is_empty()); + assert!(ledger.verify().is_ok()); + } + + #[test] + fn append_rejects_mismatched_prev_hash() { + let mut ledger = CustodyLedger::new(); + ledger.append(witness(0, "h0", None)).unwrap(); + // prev_hash should be Some("h0"), but we link it to the wrong place. + let err = ledger.append(witness(1, "h1", Some("WRONG"))).unwrap_err(); + match err { + CustodyError::BrokenChain { + index, + found, + expected, + } => { + assert_eq!(index, 1); + assert_eq!(found, Some("WRONG".to_string())); + assert_eq!(expected, Some("h0".to_string())); + } + other => panic!("expected BrokenChain, got {other:?}"), + } + // The rejected record must not have been stored. + assert_eq!(ledger.len(), 1); + } + + #[test] + fn first_record_must_have_no_prev_hash() { + let mut ledger = CustodyLedger::new(); + let err = ledger.append(witness(0, "h0", Some("h-1"))).unwrap_err(); + assert!(matches!(err, CustodyError::BrokenChain { index: 0, .. })); + assert!(ledger.is_empty()); + } + + #[test] + fn record_outcome_then_find_it() { + let mut ledger = three_link_ledger(); + ledger.record_outcome("h1", "confirmed fall").unwrap(); + let record = ledger + .records() + .iter() + .find(|r| r.witness.evidence_hash == "h1") + .unwrap(); + assert_eq!(record.outcome.as_deref(), Some("confirmed fall")); + // Other records are untouched. + assert!(ledger.records()[0].outcome.is_none()); + + // An unknown hash is reported as NotFound. + let err = ledger.record_outcome("nope", "x").unwrap_err(); + assert!(matches!(err, CustodyError::NotFound(h) if h == "nope")); + } + + #[test] + fn replay_until_returns_prefix_chain() { + let ledger = three_link_ledger(); + let chain = ledger.replay_until("h1").unwrap(); + assert_eq!(chain.len(), 2); + assert_eq!(chain[0].witness.evidence_hash, "h0"); + assert_eq!(chain[1].witness.evidence_hash, "h1"); + + // The full chain is reachable from the last hash. + assert_eq!(ledger.replay_until("h2").unwrap().len(), 3); + + // Unknown hashes are NotFound. + let err = ledger.replay_until("ghost").unwrap_err(); + assert!(matches!(err, CustodyError::NotFound(h) if h == "ghost")); + } + + #[test] + fn verify_detects_a_corrupted_link() { + // Hand-build a ledger whose middle record's evidence_hash has been + // mutated *after* insertion, so its link to the next record is broken. + // We bypass `append` (which would reject this) to simulate tampering of + // already-stored data, exactly what `verify` must catch. + let mut ledger = three_link_ledger(); + // Corrupt h1 -> the third record still points at "h1", so the link + // expected at index 2 ("h1_tampered") will mismatch its found prev_hash. + ledger.records[1].witness.evidence_hash = "h1_tampered".to_string(); + + match ledger.verify() { + Err(CustodyError::BrokenChain { + index, + found, + expected, + }) => { + assert_eq!(index, 2); + assert_eq!(found, Some("h1".to_string())); + assert_eq!(expected, Some("h1_tampered".to_string())); + } + other => panic!("expected BrokenChain at index 2, got {other:?}"), + } + } +} diff --git a/crates/ruvector-perception/src/engine.rs b/crates/ruvector-perception/src/engine.rs new file mode 100644 index 000000000..173a435e4 --- /dev/null +++ b/crates/ruvector-perception/src/engine.rs @@ -0,0 +1,259 @@ +//! The delta engine: turns a window of multi-modal readings into a proof-gated +//! [`DeltaWitness`]. The pipeline is `delta → boundary → coherence → proof → +//! action` — it models *state transition*, not a fixed task label. + +use crate::coherence::detect_boundary; +use crate::modality::Modality; +use crate::state::{Reading, WorldState}; +use crate::witness::{evidence_hash, Action, DeltaWitness, ProofGate}; +use std::collections::HashMap; + +/// Configuration for [`DeltaEngine`]. +#[derive(Debug, Clone)] +pub struct EngineConfig { + /// EWMA smoothing for baselines. + pub alpha: f32, + /// |delta| above which a modality is considered to have responded. + pub active_threshold: f32, + /// Minimum historical responsiveness for a silent modality to count as a + /// contradiction ("it usually reacts here, but didn't"). + pub responsive_min: f32, + /// How many prior changed-zone delta vectors to remember for novelty. + pub history_cap: usize, + /// Proof-gate thresholds. + pub gate: ProofGate, +} + +impl Default for EngineConfig { + fn default() -> Self { + Self { + alpha: 0.4, + active_threshold: 0.4, + responsive_min: 0.3, + history_cap: 256, + gate: ProofGate::default(), + } + } +} + +/// Stateful physical-perception engine. +pub struct DeltaEngine { + cfg: EngineConfig, + state: WorldState, + history: Vec>, // prior changed-zone delta vectors + prev_hash: Option, +} + +impl DeltaEngine { + /// Create an engine. + pub fn new(cfg: EngineConfig) -> Self { + let state = WorldState::new(cfg.alpha, cfg.active_threshold); + Self { + cfg, + state, + history: Vec::new(), + prev_hash: None, + } + } + + /// Borrow the rolling world state (baselines, responsiveness). + pub fn state(&self) -> &WorldState { + &self.state + } + + /// Observe one time window of readings and emit a proof-gated witness. + pub fn observe(&mut self, readings: &[Reading], t: u64) -> DeltaWitness { + // 1. Per-zone delta vectors over a fixed modality order. + let mut by_zone: HashMap> = HashMap::new(); + for r in readings { + by_zone + .entry(r.zone.clone()) + .or_default() + .insert(r.modality, r.value); + } + let mut zones: Vec = by_zone.keys().cloned().collect(); + zones.sort(); + + let delta_vec = |zone: &str| -> Vec { + Modality::ALL + .iter() + .map(|&m| match by_zone[zone].get(&m) { + Some(&v) => (v - self.state.baseline(zone, m)).abs() as f64, + None => 0.0, + }) + .collect() + }; + let deltas: Vec<(String, Vec)> = + zones.iter().map(|z| (z.clone(), delta_vec(z))).collect(); + + // 2. Boundary via coherence min-cut. + let boundary = detect_boundary(&deltas); + let (changed, coherence, changed_vec) = match boundary { + Some(b) => { + let v = deltas + .iter() + .find(|(z, _)| z == &b.zone) + .map(|(_, v)| v.clone()); + (b.zone, b.coherence, v.unwrap_or_default()) + } + None => { + let w = self.finish(readings, &deltas, t, NullWitness::empty()); + return w; + } + }; + + // 3. Supporting / contradicting modalities in the changed zone. + let thr = self.cfg.active_threshold; + let mut supporting = Vec::new(); + let mut contradicting = Vec::new(); + let mut contradiction = 0.0f32; + for &m in &Modality::ALL { + let mag = match by_zone[&changed].get(&m) { + Some(&v) => (v - self.state.baseline(&changed, m)).abs(), + None => 0.0, + }; + if mag >= thr { + supporting.push(m); + } else if self.state.seen(&changed, m) + && self.state.responsiveness(&changed, m) >= self.cfg.responsive_min + { + // Usually reacts here, but stayed silent — first-class disagreement. + contradicting.push(m); + contradiction = contradiction.max(m.physics().spoof_resistance); + } + } + + // 4. Novelty vs prior changed-zone states. + let novelty = self.novelty(&changed_vec); + + // 5. Proof gate -> bounded authority. + let action = self.cfg.gate.decide(novelty, coherence, contradiction); + + let w = NullWitness { + changed_boundary: changed, + supporting, + contradicting, + novelty, + coherence, + contradiction, + action, + changed_vec: Some(changed_vec), + }; + self.finish(readings, &deltas, t, w) + } + + fn novelty(&self, vec: &[f64]) -> f32 { + if self.history.is_empty() { + return 1.0; + } + let norm = |v: &[f64]| v.iter().map(|x| x * x).sum::().sqrt(); + let dist = |a: &[f64], b: &[f64]| -> f64 { + a.iter() + .zip(b) + .map(|(x, y)| (x - y) * (x - y)) + .sum::() + .sqrt() + }; + let min_d = self + .history + .iter() + .map(|h| dist(h, vec)) + .fold(f64::INFINITY, f64::min); + (min_d / (norm(vec) + 1e-9)).clamp(0.0, 1.0) as f32 + } + + /// Hash evidence, build the witness, then fold the readings into state and + /// remember the changed vector for future novelty. + fn finish( + &mut self, + readings: &[Reading], + deltas: &[(String, Vec)], + t: u64, + w: NullWitness, + ) -> DeltaWitness { + // Canonical raw + feature bytes for the evidence chain. + let mut raw = String::new(); + let mut sorted: Vec<&Reading> = readings.iter().collect(); + sorted.sort_by(|a, b| { + (a.zone.as_str(), a.modality.name()).cmp(&(b.zone.as_str(), b.modality.name())) + }); + for r in sorted { + raw.push_str(&format!("{}:{}:{:.6};", r.zone, r.modality.name(), r.value)); + } + let mut feat = String::new(); + for (z, v) in deltas { + feat.push_str(z); + for x in v { + feat.push_str(&format!(":{x:.6}")); + } + feat.push(';'); + } + let hash = evidence_hash( + raw.as_bytes(), + feat.as_bytes(), + &w.changed_boundary, + w.novelty, + w.coherence, + w.contradiction, + w.action, + self.prev_hash.as_deref(), + ); + + let witness = DeltaWitness { + t, + changed_boundary: w.changed_boundary, + supporting_modalities: w.supporting, + contradicting_modalities: w.contradicting, + novelty: w.novelty, + coherence: w.coherence, + contradiction: w.contradiction, + action: w.action, + evidence_hash: hash.clone(), + prev_hash: self.prev_hash.take(), + }; + self.prev_hash = Some(hash); + + // Remember the changed vector (compress: only store meaningful events). + if let Some(v) = w.changed_vec { + if v.iter().any(|&x| x as f32 >= self.cfg.active_threshold) { + self.history.push(v); + if self.history.len() > self.cfg.history_cap { + self.history.remove(0); + } + } + } + + // Fold readings into the rolling baselines. + for r in readings { + self.state.update(r); + } + witness + } +} + +/// Internal scratch for an in-progress witness. +struct NullWitness { + changed_boundary: String, + supporting: Vec, + contradicting: Vec, + novelty: f32, + coherence: f32, + contradiction: f32, + action: Action, + changed_vec: Option>, +} + +impl NullWitness { + fn empty() -> Self { + Self { + changed_boundary: String::new(), + supporting: Vec::new(), + contradicting: Vec::new(), + novelty: 0.0, + coherence: 0.0, + contradiction: 0.0, + action: Action::Ignore, + changed_vec: None, + } + } +} diff --git a/crates/ruvector-perception/src/hypothesis.rs b/crates/ruvector-perception/src/hypothesis.rs new file mode 100644 index 000000000..59fd1e835 --- /dev/null +++ b/crates/ruvector-perception/src/hypothesis.rs @@ -0,0 +1,358 @@ +//! Multi-modal disagreement engine — *disagreement is information, not noise*. +//! +//! When modalities contradict each other, the classical reflex is to fuse them +//! into a single agreed-upon answer (and throw away the conflict). This module +//! does the opposite: it treats a contradiction as a *question* — "why do these +//! sensors disagree?" — and answers with **ranked hypotheses** instead of a +//! forced consensus. +//! +//! The same raw disagreement can mean very different things: +//! +//! - a **real event** the slow channels haven't caught up to yet, +//! - a single channel **drifting** out of calibration, +//! - a sensor that was physically **relocated** so its readings no longer fit +//! the spatial field, +//! - an **adversarial replay** where the easy-to-spoof channels were faked while +//! the hard-to-spoof physical channels stayed silent, +//! - or a transient **environmental artifact** (an echo / reflection). +//! +//! Each candidate gets an `evidence` score in `[0, 1]` derived from *typed* +//! physics ([`Modality::physics`]) plus the qualitative shape of the +//! disagreement. We always return all five, sorted by evidence descending, so a +//! caller can inspect the full ranked field rather than a single label. + +use crate::modality::Modality; +use serde::{Deserialize, Serialize}; + +/// A candidate explanation for *why* the modalities disagree. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Hypothesis { + /// A genuine physical change; supporting (hard-to-spoof) channels fired + /// coherently and the few contradictions are explainable (e.g. latency). + RealEvent, + /// A single low-spoof-resistance channel slowly wandering out of calibration + /// — persistent, lone, and spatially incoherent. + SensorDrift, + /// A sensor was physically moved: it responds strongly but its readings no + /// longer fit the neighbours' spatial pattern (sudden + novel + incoherent). + SensorRelocation, + /// Easy-to-spoof channels (RF/Optical) report a strong event while the + /// hard-to-spoof physical channels (Vibration/Thermal) stayed silent. + AdversarialReplay, + /// A transient reflection/echo: familiar (low novelty), short-lived, mixed + /// support — present but not a durable, coherent event. + EnvironmentalArtifact, +} + +/// One scored explanation. `evidence` is normalised to `[0, 1]`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RankedHypothesis { + /// The candidate explanation. + pub hypothesis: Hypothesis, + /// Strength of support for this explanation, in `[0, 1]`. + pub evidence: f32, +} + +/// Inputs describing a single witnessed disagreement. +#[derive(Debug, Clone)] +pub struct DisagreementInput { + /// Modalities that *did* respond to the change. + pub supporting: Vec, + /// Modalities that *should* have responded in this zone but stayed silent. + pub contradicting: Vec, + /// How surprising the signal is, `[0, 1]`. + pub novelty: f32, + /// Cleanliness of the spatial boundary, `[0, 1]` (1 = crisp, 0 = smeared). + pub coherence: f32, + /// How long the signal has persisted across windows, `[0, 1]`. + pub persistence: f32, +} + +impl DisagreementInput { + /// Mean spoof-resistance of the supporting set (0 if empty). + fn supporting_spoof_resistance(&self) -> f32 { + mean_spoof_resistance(&self.supporting) + } + + /// Mean spoof-resistance of the contradicting set (0 if empty). + fn contradicting_spoof_resistance(&self) -> f32 { + mean_spoof_resistance(&self.contradicting) + } + + /// Fraction of involved modalities that are contradicting, `[0, 1]`. + /// 0 when nothing is involved at all. + fn contradiction_fraction(&self) -> f32 { + let total = self.supporting.len() + self.contradicting.len(); + if total == 0 { + return 0.0; + } + self.contradicting.len() as f32 / total as f32 + } +} + +/// Mean spoof-resistance of a modality set; 0.0 for an empty set. +fn mean_spoof_resistance(set: &[Modality]) -> f32 { + if set.is_empty() { + return 0.0; + } + let sum: f32 = set.iter().map(|m| m.physics().spoof_resistance).sum(); + sum / set.len() as f32 +} + +/// Clamp a raw score into `[0, 1]`. +fn clamp01(x: f32) -> f32 { + x.clamp(0.0, 1.0) +} + +/// Rank all candidate explanations for a disagreement by evidence (descending). +/// +/// Always returns exactly five [`RankedHypothesis`] entries. Ties keep the +/// canonical declaration order ([`Hypothesis::RealEvent`] first) because the +/// sort is stable and the candidates are pushed in that order. +pub fn rank_hypotheses(input: &DisagreementInput) -> Vec { + let mut ranked = vec![ + RankedHypothesis { + hypothesis: Hypothesis::RealEvent, + evidence: score_real_event(input), + }, + RankedHypothesis { + hypothesis: Hypothesis::SensorDrift, + evidence: score_sensor_drift(input), + }, + RankedHypothesis { + hypothesis: Hypothesis::SensorRelocation, + evidence: score_sensor_relocation(input), + }, + RankedHypothesis { + hypothesis: Hypothesis::AdversarialReplay, + evidence: score_adversarial_replay(input), + }, + RankedHypothesis { + hypothesis: Hypothesis::EnvironmentalArtifact, + evidence: score_environmental_artifact(input), + }, + ]; + + // Stable, descending by evidence. `total_cmp` keeps this deterministic even + // for NaN-free f32s and never panics. Stability preserves declaration order + // on ties. + ranked.sort_by(|a, b| b.evidence.total_cmp(&a.evidence)); + ranked +} + +/// **RealEvent**: many trustworthy supporting channels, a crisp boundary, some +/// novelty and decent persistence, with few contradictions. We weight by the +/// supporting set's mean spoof-resistance (hard-to-spoof agreement is the +/// strongest signal a thing actually happened) and penalise by the contradiction +/// fraction. +fn score_real_event(input: &DisagreementInput) -> f32 { + if input.supporting.is_empty() { + return 0.0; + } + let trust = input.supporting_spoof_resistance(); + // Reward breadth of support: two trustworthy channels beat one. + let breadth = (input.supporting.len() as f32 / 3.0).min(1.0); + let persistence_term = 0.5 + 0.5 * clamp01(input.persistence); + let novelty_term = 0.5 + 0.5 * clamp01(input.novelty); + let base = trust * clamp01(input.coherence) * persistence_term * novelty_term; + let breadth_boosted = base * (0.6 + 0.4 * breadth); + // Contradictions erode a "real event" reading. + clamp01(breadth_boosted * (1.0 - input.contradiction_fraction())) +} + +/// **SensorDrift**: a lone low-spoof-resistance channel slowly wandering. High +/// when exactly one modality supports, that modality is easy to spoof / noisy +/// (low spoof-resistance), the boundary is *incoherent* (drift is not a clean +/// spatial edge), and persistence is high (drift is slow and sustained). Novelty +/// should be modest — drift creeps, it does not jump. +fn score_sensor_drift(input: &DisagreementInput) -> f32 { + if input.supporting.len() != 1 { + return 0.0; + } + let weak_channel = 1.0 - input.supporting_spoof_resistance(); + let incoherence = 1.0 - clamp01(input.coherence); + let persistent = clamp01(input.persistence); + // Gradual: penalise high novelty (that points at relocation instead). + let gradual = 1.0 - clamp01(input.novelty); + clamp01(weak_channel * incoherence * persistent * (0.5 + 0.5 * gradual)) +} + +/// **SensorRelocation**: a sensor moved, so it still responds strongly but its +/// readings no longer fit the spatial field. Distinguished from drift by being +/// *sudden and novel* rather than gradual: support present, coherence LOW +/// (doesn't fit neighbours), novelty HIGH, and at least one contradiction +/// (neighbours that should agree don't). Persistence is not required — a +/// relocation is a step change. +fn score_sensor_relocation(input: &DisagreementInput) -> f32 { + if input.supporting.is_empty() || input.contradicting.is_empty() { + return 0.0; + } + let responding = clamp01(input.supporting_spoof_resistance().max(0.2)); + let incoherence = 1.0 - clamp01(input.coherence); + let sudden = clamp01(input.novelty); + let mismatch = input.contradiction_fraction(); + clamp01(responding * incoherence * sudden * (0.5 + 0.5 * mismatch)) +} + +/// **AdversarialReplay**: the supporting set is dominated by easy-to-spoof +/// channels (RF/Optical) while the *hard-to-spoof* physical channels +/// (Vibration/Thermal) are in the contradicting set (silent). A fake can drive +/// radios and light but cannot reproduce structural vibration or thermal mass. +/// Persistence is usually low/static for a replayed snippet, so low persistence +/// adds a little weight. +fn score_adversarial_replay(input: &DisagreementInput) -> f32 { + if input.supporting.is_empty() || input.contradicting.is_empty() { + return 0.0; + } + // Supporting must be *easy* to spoof; contradicting must be *hard* to spoof. + let support_spoofability = 1.0 - input.supporting_spoof_resistance(); + let silent_trust = input.contradicting_spoof_resistance(); + // Only meaningful when the hard channels are the silent ones. + if silent_trust <= input.supporting_spoof_resistance() { + return 0.0; + } + let static_signal = 1.0 - clamp01(input.persistence); + let core = support_spoofability * silent_trust; + clamp01(core * (0.7 + 0.3 * static_signal)) +} + +/// **EnvironmentalArtifact**: a transient reflection/echo. Familiar rather than +/// novel (low novelty), short-lived (low persistence), with moderate coherence +/// and mixed support — it shows up but never settles into a durable, trustworthy +/// event. +fn score_environmental_artifact(input: &DisagreementInput) -> f32 { + if input.supporting.is_empty() { + return 0.0; + } + let familiar = 1.0 - clamp01(input.novelty); + let transient = 1.0 - clamp01(input.persistence); + // Moderate coherence peaks at ~0.5 (an echo is neither crisp nor formless). + let moderate_coherence = 1.0 - (clamp01(input.coherence) - 0.5).abs() * 2.0; + // Low-trust support is more echo-like than a hard physical channel. + let soft_support = 1.0 - input.supporting_spoof_resistance(); + clamp01(familiar * transient * (0.4 + 0.6 * moderate_coherence) * (0.5 + 0.5 * soft_support)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn first(input: &DisagreementInput) -> Hypothesis { + rank_hypotheses(input)[0].hypothesis + } + + #[test] + fn returns_all_five_sorted_descending() { + let input = DisagreementInput { + supporting: vec![Modality::Vibration], + contradicting: vec![], + novelty: 0.5, + coherence: 0.5, + persistence: 0.5, + }; + let ranked = rank_hypotheses(&input); + assert_eq!(ranked.len(), 5); + for w in ranked.windows(2) { + assert!(w[0].evidence >= w[1].evidence); + assert!((0.0..=1.0).contains(&w[0].evidence)); + } + } + + #[test] + fn many_trustworthy_supporters_imply_real_event() { + // Hard-to-spoof channels agree, boundary is crisp, contradictions are + // absent — this is what a genuine physical event looks like. + let input = DisagreementInput { + supporting: vec![Modality::Vibration, Modality::Thermal, Modality::Acoustic], + contradicting: vec![], + novelty: 0.7, + coherence: 0.9, + persistence: 0.7, + }; + assert_eq!(first(&input), Hypothesis::RealEvent); + } + + #[test] + fn lone_weak_persistent_channel_implies_drift() { + // A single easy-to-spoof channel, no clean boundary, sustained over time, + // creeping (low novelty): the signature of calibration drift. + let input = DisagreementInput { + supporting: vec![Modality::Rf], + contradicting: vec![], + novelty: 0.2, + coherence: 0.1, + persistence: 0.9, + }; + assert_eq!(first(&input), Hypothesis::SensorDrift); + } + + #[test] + fn easy_channels_loud_hard_channels_silent_imply_replay() { + // RF + Optical (easy to spoof) report a strong event, while Vibration + + // Thermal (hard to spoof) are silent — a classic replayed/faked signal. + let input = DisagreementInput { + supporting: vec![Modality::Rf, Modality::Optical], + contradicting: vec![Modality::Vibration, Modality::Thermal], + novelty: 0.6, + coherence: 0.6, + persistence: 0.1, + }; + assert_eq!(first(&input), Hypothesis::AdversarialReplay); + } + + #[test] + fn sudden_novel_incoherent_with_contradiction_implies_relocation() { + // A trustworthy sensor still responds strongly, but suddenly (high + // novelty), incoherently, and its neighbours contradict it: it moved. + let input = DisagreementInput { + supporting: vec![Modality::Vibration], + contradicting: vec![Modality::Acoustic], + novelty: 0.95, + coherence: 0.1, + persistence: 0.2, + }; + let ranked = rank_hypotheses(&input); + // Relocation should out-rank drift here because the change is sudden. + let reloc = ranked + .iter() + .find(|r| r.hypothesis == Hypothesis::SensorRelocation) + .unwrap() + .evidence; + let drift = ranked + .iter() + .find(|r| r.hypothesis == Hypothesis::SensorDrift) + .unwrap() + .evidence; + assert!(reloc > drift); + } + + #[test] + fn familiar_transient_implies_environmental_artifact() { + // Low novelty, short-lived, moderate coherence, soft support: an echo. + let input = DisagreementInput { + supporting: vec![Modality::Optical], + contradicting: vec![], + novelty: 0.05, + coherence: 0.5, + persistence: 0.05, + }; + assert_eq!(first(&input), Hypothesis::EnvironmentalArtifact); + } + + #[test] + fn empty_supporting_is_deterministic_and_bounded() { + let input = DisagreementInput { + supporting: vec![], + contradicting: vec![Modality::Thermal], + novelty: 0.5, + coherence: 0.5, + persistence: 0.5, + }; + let ranked = rank_hypotheses(&input); + assert_eq!(ranked.len(), 5); + for r in &ranked { + assert!((0.0..=1.0).contains(&r.evidence)); + } + } +} diff --git a/crates/ruvector-perception/src/identity.rs b/crates/ruvector-perception/src/identity.rs new file mode 100644 index 000000000..1790c442f --- /dev/null +++ b/crates/ruvector-perception/src/identity.rs @@ -0,0 +1,241 @@ +//! Resonant identity layer: continuity recognition for physical objects. +//! +//! Every physical object emits a *resonant response signature* — a vibration, +//! acoustic, or RF-reflection embedding that depends on its mass, geometry, +//! material, fastening, and contents. This layer does not ask *"what is this?"*; +//! it asks *"is this STILL the same physical thing?"* +//! +//! By enrolling a known signature and comparing fresh observations against it, +//! we detect **identity drift**: a panel has loosened, a pipe has filled with +//! water, a bearing has worn, a casing has been tampered with. Small, gradual +//! changes (aging, ambient noise) are absorbed by an exponentially-weighted +//! moving average (EWMA) so the stored signature tracks slow drift, while a +//! sudden large change trips the `changed` flag. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// The result of comparing a fresh signature against an enrolled one. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct IdentityDrift { + /// The object identifier this observation pertains to. + pub id: String, + /// Cosine distance (1 - cosine similarity) clamped to `[0, 1]`. + pub drift: f32, + /// Whether the drift exceeded the configured threshold (identity changed). + pub changed: bool, +} + +/// A trusted memory of resonant signatures keyed by object identity. +/// +/// Stores one EWMA-smoothed signature per enrolled object. Observations that +/// stay within the drift threshold slowly update the stored signature; large +/// jumps are flagged and left to update the memory (the stored signature is +/// preserved so a transient tamper does not poison the baseline). +#[derive(Debug, Clone)] +pub struct IdentityMemory { + signatures: HashMap>, + drift_threshold: f32, + alpha: f32, +} + +impl IdentityMemory { + /// Create an empty identity memory. + /// + /// `drift_threshold` in `[0, 1]`: cosine-distance above which identity is + /// considered changed. `alpha` in `[0, 1]`: EWMA update rate for the stored + /// signature when identity is unchanged (higher = faster adaptation). Both + /// are clamped to `[0, 1]` defensively. + pub fn new(drift_threshold: f32, alpha: f32) -> Self { + Self { + signatures: HashMap::new(), + drift_threshold: drift_threshold.clamp(0.0, 1.0), + alpha: alpha.clamp(0.0, 1.0), + } + } + + /// Enroll (or overwrite) a known object's resonant signature embedding. + pub fn enroll(&mut self, id: impl Into, signature: Vec) { + self.signatures.insert(id.into(), signature); + } + + /// Whether an id is enrolled. + pub fn contains(&self, id: &str) -> bool { + self.signatures.contains_key(id) + } + + /// Compare a fresh signature to the stored one. + /// + /// Returns `drift` = cosine distance (1 - cosine similarity) clamped to + /// `[0, 1]`, and `changed` = `drift > threshold`. If unchanged, the stored + /// signature is EWMA-updated (slow adaptation to aging/noise). If the id is + /// unknown, the signature is auto-enrolled and `drift = 0.0`, + /// `changed = false` is returned. A length mismatch against the stored + /// signature is treated as a change (`drift = 1.0`) without updating. + pub fn observe(&mut self, id: &str, signature: &[f32]) -> IdentityDrift { + let Some(stored) = self.signatures.get(id) else { + self.signatures.insert(id.to_string(), signature.to_vec()); + return IdentityDrift { + id: id.to_string(), + drift: 0.0, + changed: false, + }; + }; + + if stored.len() != signature.len() { + return IdentityDrift { + id: id.to_string(), + drift: 1.0, + changed: true, + }; + } + + let drift = cosine_distance(stored, signature); + let changed = drift > self.drift_threshold; + + if !changed { + let alpha = self.alpha; + // Update in place: stored = (1 - alpha) * stored + alpha * signature. + if let Some(stored_mut) = self.signatures.get_mut(id) { + for (s, &fresh) in stored_mut.iter_mut().zip(signature.iter()) { + *s = (1.0 - alpha) * *s + alpha * fresh; + } + } + } + + IdentityDrift { + id: id.to_string(), + drift, + changed, + } + } +} + +/// Cosine distance `1 - cos_sim`, clamped to `[0, 1]`. +/// +/// Guards zero norms: if either vector has (near-)zero norm, distance is `1.0` +/// when the other vector is non-zero, else `0.0` (both effectively silent = +/// indistinguishable). +fn cosine_distance(a: &[f32], b: &[f32]) -> f32 { + let norm_a = dot(a, a).sqrt(); + let norm_b = dot(b, b).sqrt(); + const EPS: f32 = 1e-12; + + let a_zero = norm_a <= EPS; + let b_zero = norm_b <= EPS; + if a_zero || b_zero { + return if a_zero && b_zero { 0.0 } else { 1.0 }; + } + + let cos_sim = dot(a, b) / (norm_a * norm_b); + (1.0 - cos_sim).clamp(0.0, 1.0) +} + +/// Dot product of two equal-length slices. +fn dot(a: &[f32], b: &[f32]) -> f32 { + a.iter().zip(b.iter()).map(|(x, y)| x * y).sum() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn near_identical_signature_is_unchanged() { + let mut mem = IdentityMemory::new(0.1, 0.2); + mem.enroll("pump-7", vec![1.0, 2.0, 3.0, 4.0]); + + // Tiny perturbation (sensor noise): same physical object. + let result = mem.observe("pump-7", &[1.01, 1.99, 3.02, 3.98]); + + assert!( + !result.changed, + "near-identical signature should be unchanged" + ); + assert!( + result.drift < 0.1, + "drift should be low, got {}", + result.drift + ); + assert_eq!(result.id, "pump-7"); + } + + #[test] + fn large_change_trips_changed() { + let mut mem = IdentityMemory::new(0.2, 0.2); + mem.enroll("panel-3", vec![1.0, 0.0, 0.0, 0.0]); + + // Orthogonal signature — panel loosened, resonance shifted entirely. + let result = mem.observe("panel-3", &[0.0, 1.0, 0.0, 0.0]); + + assert!(result.changed, "orthogonal signature should be a change"); + assert!( + result.drift > 0.2, + "drift should be high, got {}", + result.drift + ); + // Cosine distance of orthogonal vectors is exactly 1.0. + assert!((result.drift - 1.0).abs() < 1e-6); + } + + #[test] + fn unknown_id_auto_enrolls() { + let mut mem = IdentityMemory::new(0.1, 0.2); + assert!(!mem.contains("valve-1")); + + let result = mem.observe("valve-1", &[0.5, 0.5, 0.5]); + + assert!(!result.changed); + assert_eq!(result.drift, 0.0); + assert!( + mem.contains("valve-1"), + "observing unknown id should enroll it" + ); + } + + #[test] + fn length_mismatch_is_a_change() { + let mut mem = IdentityMemory::new(0.1, 0.2); + mem.enroll("bearing-2", vec![1.0, 2.0, 3.0]); + + let result = mem.observe("bearing-2", &[1.0, 2.0]); + + assert!(result.changed); + assert_eq!(result.drift, 1.0); + } + + #[test] + fn gradual_drift_absorbed_then_sudden_change_trips() { + let mut mem = IdentityMemory::new(0.15, 0.3); + mem.enroll("casing-9", vec![1.0, 1.0, 1.0, 1.0]); + + // A slow walk of small perturbations: each step is tiny relative to the + // current baseline, so EWMA absorbs it and identity stays the same. + let mut current = vec![1.0_f32, 1.0, 1.0, 1.0]; + for step in 0..20 { + let nudge = (step as f32) * 0.01; + current = vec![ + 1.0 + nudge, + 1.0 - nudge * 0.5, + 1.0 + nudge * 0.3, + 1.0 - nudge * 0.2, + ]; + let r = mem.observe("casing-9", ¤t); + assert!( + !r.changed, + "gradual step {step} should stay unchanged (drift {})", + r.drift + ); + } + + // Sudden large change — casing tampered: resonance inverts. + let tampered = vec![-1.0, -1.0, -1.0, -1.0]; + let r = mem.observe("casing-9", &tampered); + assert!( + r.changed, + "sudden inversion should trip changed (drift {})", + r.drift + ); + assert!(r.drift > 0.15); + } +} diff --git a/crates/ruvector-perception/src/lib.rs b/crates/ruvector-perception/src/lib.rs new file mode 100644 index 000000000..eebee38dc --- /dev/null +++ b/crates/ruvector-perception/src/lib.rs @@ -0,0 +1,93 @@ +//! # ruvector-perception — the layer *under* classification +//! +//! Current WiFi/edge SOTA is racing toward better *classifiers* (CSI foundation +//! models, self-supervised CSI representations, adaptive fusion). This crate +//! deliberately does **not** build a better classifier. It builds the substrate +//! underneath one: +//! +//! ```text +//! classification → confidence → alert (today) +//! delta → boundary → coherence → proof → action (here) +//! ``` +//! +//! Instead of asking *"what is this?"* it asks *"what changed, where did the +//! boundary move, and is the change coherent enough to act on?"* — and it +//! requires **evidence**, not confidence, before it grants any authority. +//! +//! ## Pipeline +//! +//! 1. **Delta** ([`state`], [`engine`]) — every reading becomes a delta against a +//! rolling multi-modal baseline. No fixed task label (fall/gesture/leak). +//! 2. **Boundary** ([`coherence`]) — zones form a coherence graph; dynamic +//! min-cut isolates the side that broke away (the moved boundary). +//! 3. **Coherence + contradiction** — a modality that *usually* reacts in a zone +//! but stayed silent is a first-class contradiction (disagreement is +//! information), weighted by the modality's physical spoof-resistance. +//! 4. **Proof** ([`witness`]) — a proof gate turns novelty/coherence/ +//! contradiction into *bounded authority* (Ignore → Observe → Alert → +//! Mutate) and emits an auditable SHA-256 evidence chain. +//! 5. **Action** — only evidence that is novel, coherent, and uncontradicted may +//! escalate; contradicted evidence is capped at *Observe*. +//! +//! Plus [`absence`]: a *missing* expected continuation (e.g. a bedtime routine +//! that never returns) is detected as structural incompleteness, not a threshold. +//! +//! ## Honest scope +//! +//! This is the **mechanism** (a trusted-physical-memory engine), demonstrated on +//! synthetic multi-modal deltas and reusing [`ruvector_mincut`] for boundary +//! detection. It is not validated on real CSI hardware, and it is not a +//! classifier — it is the auditable perception layer a classifier (or an agent) +//! would sit on top of. +//! +//! ## Example +//! +//! ``` +//! use ruvector_perception::{DeltaEngine, EngineConfig, Reading, Modality, Action}; +//! +//! let mut eng = DeltaEngine::new(EngineConfig::default()); +//! // (warm up baselines first in real use) +//! let w = eng.observe(&[ +//! Reading::new("table_left_zone", Modality::Rf, 3.0), +//! Reading::new("table_left_zone", Modality::Vibration, 3.0), +//! ], 0); +//! assert_eq!(w.changed_boundary, "table_left_zone"); +//! let _ = Action::Observe; +//! ``` + +#![forbid(unsafe_code)] + +pub mod absence; +pub mod captcha; +pub mod coherence; +pub mod custody; +pub mod engine; +pub mod hypothesis; +pub mod identity; +pub mod modality; +pub mod node; +pub mod predict; +pub mod reality; +pub mod state; +pub mod swarm; +pub mod topology; +pub mod witness; + +pub use absence::{Absence, SequenceMonitor}; +pub use captcha::{CaptchaVerifier, ChallengeResponse, ObservedResponse, RealityProof, Stimulus}; +pub use coherence::{detect_boundary, Boundary}; +pub use custody::{CustodyError, CustodyLedger, CustodyRecord}; +pub use engine::{DeltaEngine, EngineConfig}; +pub use hypothesis::{rank_hypotheses, DisagreementInput, Hypothesis, RankedHypothesis}; +pub use identity::{IdentityDrift, IdentityMemory}; +pub use modality::{Modality, Physics}; +pub use node::{NervousSystemNode, NodeEvent}; +pub use predict::{BoundaryForecast, BoundaryObservation, BoundaryPredictor}; +pub use reality::{GroundedAnswer, Query, RealityGraph}; +pub use state::{Reading, WorldState}; +pub use swarm::{FacilityGraph, FragilityReport}; +pub use topology::{NodeAssessment, NodeRole, TopologyManager}; +pub use witness::{evidence_hash, novelty_level, Action, DeltaWitness, ProofGate}; + +/// Crate version. +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/crates/ruvector-perception/src/modality.rs b/crates/ruvector-perception/src/modality.rs new file mode 100644 index 000000000..7df57eadb --- /dev/null +++ b/crates/ruvector-perception/src/modality.rs @@ -0,0 +1,114 @@ +//! Physically-typed sensing modalities (substrate-aware: each modality has its +//! own latency, decay, and spoof-resistance — edges in the coherence graph are +//! not generic, they carry physics). + +use serde::{Deserialize, Serialize}; + +/// A physical sensing modality. The graph is *typed*: an RF edge does not behave +/// like a thermal edge. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Modality { + /// Radio (WiFi CSI, BLE RSSI) — fast, multipath-sensitive, easy to spoof statically. + Rf, + /// Structural vibration (piezo / accelerometer) — propagation delay, damping. + Vibration, + /// Acoustic (mic) — echo paths, directionality. + Acoustic, + /// Thermal — slow diffusion, hysteresis; responds to animate heat sources. + Thermal, + /// Chemical (gas / QCM / SAW) — very slow, leak/identity cues. + Chemical, + /// Optical / light modulation. + Optical, +} + +impl Modality { + /// All modalities, for iteration. + pub const ALL: [Modality; 6] = [ + Modality::Rf, + Modality::Vibration, + Modality::Acoustic, + Modality::Thermal, + Modality::Chemical, + Modality::Optical, + ]; + + /// Short stable name (used in witnesses and hashing). + pub fn name(self) -> &'static str { + match self { + Modality::Rf => "rf", + Modality::Vibration => "vibration", + Modality::Acoustic => "acoustic", + Modality::Thermal => "thermal", + Modality::Chemical => "chemical", + Modality::Optical => "optical", + } + } + + /// Typed physics metadata used to weight evidence. + pub fn physics(self) -> Physics { + match self { + Modality::Rf => Physics { + latency: 0.01, + decay: 0.2, + spoof_resistance: 0.3, + }, + Modality::Vibration => Physics { + latency: 0.05, + decay: 0.5, + spoof_resistance: 0.7, + }, + Modality::Acoustic => Physics { + latency: 0.03, + decay: 0.4, + spoof_resistance: 0.6, + }, + Modality::Thermal => Physics { + latency: 2.0, + decay: 0.95, + spoof_resistance: 0.8, + }, + Modality::Chemical => Physics { + latency: 5.0, + decay: 0.98, + spoof_resistance: 0.9, + }, + Modality::Optical => Physics { + latency: 0.005, + decay: 0.1, + spoof_resistance: 0.2, + }, + } + } +} + +/// Physical constants attached to a modality edge. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct Physics { + /// Characteristic response latency (seconds). + pub latency: f32, + /// Temporal persistence in `[0, 1]` (how slowly a change fades). + pub decay: f32, + /// Resistance to static spoofing / replay in `[0, 1]` (higher = harder to fake). + pub spoof_resistance: f32, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn names_unique_and_physics_present() { + let mut seen = std::collections::HashSet::new(); + for m in Modality::ALL { + assert!(seen.insert(m.name())); + let p = m.physics(); + assert!(p.spoof_resistance >= 0.0 && p.spoof_resistance <= 1.0); + } + // Thermal is slower and harder to spoof than RF — a real physical prior. + assert!(Modality::Thermal.physics().latency > Modality::Rf.physics().latency); + assert!( + Modality::Thermal.physics().spoof_resistance > Modality::Rf.physics().spoof_resistance + ); + } +} diff --git a/crates/ruvector-perception/src/node.rs b/crates/ruvector-perception/src/node.rs new file mode 100644 index 000000000..f99724f93 --- /dev/null +++ b/crates/ruvector-perception/src/node.rs @@ -0,0 +1,157 @@ +//! Ambient nervous-system node — the appliance surface. +//! +//! Wires the perception substrate into one local "coherence node" for a room / +//! machine / building. It does **not** stream raw sensor data; it ingests +//! readings and emits **deltas, boundaries, coherence, proof-gated witnesses, +//! forecasts, and an auditable custody chain** — and answers grounded agent +//! queries. Not a camera, not an IoT hub, not a dashboard. + +use crate::custody::{CustodyError, CustodyLedger}; +use crate::engine::{DeltaEngine, EngineConfig}; +use crate::predict::{BoundaryForecast, BoundaryObservation, BoundaryPredictor}; +use crate::reality::{GroundedAnswer, Query, RealityGraph}; +use crate::state::Reading; +use crate::witness::DeltaWitness; + +/// What the node emits per observed window — structure, never raw signal. +#[derive(Debug, Clone)] +pub struct NodeEvent { + /// The proof-gated delta witness for this window. + pub witness: DeltaWitness, + /// Where coherence is forecast to break next (if anywhere). + pub forecast: Option, +} + +/// A self-contained ambient perception node. +pub struct NervousSystemNode { + engine: DeltaEngine, + reality: RealityGraph, + ledger: CustodyLedger, + predictor: BoundaryPredictor, +} + +impl NervousSystemNode { + /// Build a node. `predict_window` is the per-zone history length used by the + /// boundary-break forecaster. + pub fn new(config: EngineConfig, predict_window: usize) -> Self { + Self { + engine: DeltaEngine::new(config), + reality: RealityGraph::new(), + ledger: CustodyLedger::new(), + predictor: BoundaryPredictor::new(predict_window), + } + } + + /// Observe one window of multi-modal readings. Runs the full pipeline + /// (delta → boundary → coherence → proof → action), appends the witness to + /// the custody chain, grounds it into the reality graph, updates the + /// forecaster, and returns the emitted [`NodeEvent`]. + pub fn observe(&mut self, readings: &[Reading], t: u64) -> NodeEvent { + let witness = self.engine.observe(readings, t); + // Maintain the auditable chain (the engine produces a linked witness + // chain, so append links cleanly). + let _ = self.ledger.append(witness.clone()); + self.reality.ingest(&witness); + if !witness.changed_boundary.is_empty() { + self.predictor.observe(&BoundaryObservation::new( + witness.changed_boundary.clone(), + witness.coherence, + witness.contradiction, + t, + )); + } + let forecast = self.predictor.next_break(); + NodeEvent { witness, forecast } + } + + /// Answer a grounded agent query from physical memory. + pub fn query(&self, q: &Query) -> GroundedAnswer { + self.reality.query(q) + } + + /// The auditable custody ledger (chain of every emitted witness). + pub fn ledger(&self) -> &CustodyLedger { + &self.ledger + } + + /// Verify the integrity of the custody chain. + pub fn verify_custody(&self) -> Result<(), CustodyError> { + self.ledger.verify() + } + + /// The grounding reality graph. + pub fn reality(&self) -> &RealityGraph { + &self.reality + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::modality::Modality; + + fn warm(node: &mut NervousSystemNode) { + // Three zones so the changed-boundary is unambiguous (2-zone min-cut + // splits are symmetric and the minority side is arbitrary). + for i in 0..8u64 { + let hi = (i % 2) as f32; + node.observe( + &[ + Reading::new("zone_a", Modality::Rf, hi), + Reading::new("zone_a", Modality::Vibration, hi), + Reading::new("zone_a", Modality::Thermal, 20.0 + hi), + Reading::new("zone_b", Modality::Rf, 0.0), + Reading::new("zone_c", Modality::Rf, 0.0), + ], + i, + ); + } + } + + #[test] + fn node_emits_witness_chain_and_grounds_queries() { + let mut node = NervousSystemNode::new(EngineConfig::default(), 16); + warm(&mut node); + let n_before = node.ledger().len(); + + // An RF/vibration event in zone_a (thermal silent). + let ev = node.observe( + &[ + Reading::new("zone_a", Modality::Rf, 5.0), + Reading::new("zone_a", Modality::Vibration, 5.0), + Reading::new("zone_a", Modality::Thermal, 20.5), + Reading::new("zone_b", Modality::Rf, 0.0), + Reading::new("zone_c", Modality::Rf, 0.0), + ], + 100, + ); + assert_eq!(ev.witness.changed_boundary, "zone_a"); + + // Custody chain grew and verifies. + assert_eq!(node.ledger().len(), n_before + 1); + assert!(node.verify_custody().is_ok()); + + // The agent can query reality, grounded in a witness evidence hash. + let presence = node.query(&Query::Presence { + zone: "zone_a".into(), + }); + assert!(presence.yes); + assert!(!presence.evidence.is_empty()); + // A zone with no memory is honestly unknown. + assert!( + !node + .query(&Query::Presence { + zone: "unknown".into() + }) + .yes + ); + } + + #[test] + fn empty_node_is_safe() { + let node = NervousSystemNode::new(EngineConfig::default(), 8); + assert!(node.ledger().is_empty()); + assert!(node.verify_custody().is_ok()); + assert!(!node.query(&Query::Presence { zone: "x".into() }).yes); + } +} diff --git a/crates/ruvector-perception/src/predict.rs b/crates/ruvector-perception/src/predict.rs new file mode 100644 index 000000000..a827c1ce1 --- /dev/null +++ b/crates/ruvector-perception/src/predict.rs @@ -0,0 +1,318 @@ +//! Boundary-first world model. +//! +//! Conventional world models predict the *full* next state and measure error +//! against it. That is expensive and, for a perception substrate, beside the +//! point: we do not care what every zone will read next, we care **where +//! coherence will break next**. So instead of forecasting state, this module +//! forecasts the *boundary*: +//! +//! ```text +//! boundary_{t+1} = f(boundary_t, delta_history, modality_conflict) +//! ``` +//! +//! Each zone keeps a short rolling history of an *instability* sample. The +//! per-observation sample combines how cleanly a boundary recurs (its +//! `coherence`) with how much the modalities disagree about it (its +//! `contradiction`): +//! +//! ```text +//! instability = coherence * (1 + contradiction) +//! ``` +//! +//! A clean boundary that keeps recurring *with* contradictions is the most +//! destabilising: it is consistent enough to be real and conflicted enough to be +//! unresolved. From the window we read a *level* (mean) and a *trend* (slope), +//! and forecast `level + trend` for the next step. The zone with the highest +//! forecast is the one most likely to break next. +//! +//! The model is deterministic, allocation-light, and uses only `std` + `serde`. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +/// A single observed boundary event for one zone at one time step. +/// +/// `coherence` is how cleanly the boundary separated (see +/// [`crate::coherence::Boundary::coherence`]); `contradiction` is how strongly +/// the modalities disagreed about it. Both are expected in `[0, 1]` but are +/// clamped defensively. +#[derive(Debug, Clone)] +pub struct BoundaryObservation { + /// The zone this event concerns. + pub zone: String, + /// Cleanliness of the boundary in `[0, 1]` (high = sharp separation). + pub coherence: f32, + /// Modality disagreement in `[0, 1]` (high = unresolved conflict). + pub contradiction: f32, + /// Logical time of the observation. + pub t: u64, +} + +impl BoundaryObservation { + /// Convenience constructor. + pub fn new(zone: impl Into, coherence: f32, contradiction: f32, t: u64) -> Self { + Self { + zone: zone.into(), + coherence, + contradiction, + t, + } + } + + /// Per-observation instability sample: `coherence * (1 + contradiction)`. + /// + /// Inputs are clamped to `[0, 1]`, so the result lies in `[0, 2]`. + fn instability_sample(&self) -> f32 { + let coh = self.coherence.clamp(0.0, 1.0); + let con = self.contradiction.clamp(0.0, 1.0); + coh * (1.0 + con) + } +} + +/// A forecast of where coherence will break next, for a single zone. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BoundaryForecast { + /// The zone this forecast concerns. + pub zone: String, + /// Forecast instability for the next step, `(level + trend).max(0.0)`. + pub instability: f32, + /// Slope of the recent window. Positive = the boundary is worsening. + pub trend: f32, +} + +/// Predicts which boundary breaks next from a rolling per-zone history. +/// +/// Construct with [`BoundaryPredictor::new`], feed events with +/// [`BoundaryPredictor::observe`], then read [`BoundaryPredictor::forecast`] or +/// [`BoundaryPredictor::next_break`]. +#[derive(Debug, Clone)] +pub struct BoundaryPredictor { + /// Rolling window length kept per zone (at least 1). + window: usize, + /// Per-zone rolling instability samples, oldest first. + /// + /// `BTreeMap` keeps iteration deterministic so equal forecasts keep a stable + /// (alphabetical) order after the instability sort. + history: BTreeMap>, +} + +impl BoundaryPredictor { + /// Create a predictor keeping a rolling window of `window` samples per zone. + /// + /// A `window` of `0` is treated as `1` (a forecast needs at least one + /// sample), keeping the type total and panic-free. + pub fn new(window: usize) -> Self { + Self { + window: window.max(1), + history: BTreeMap::new(), + } + } + + /// Record an observed boundary event for a zone at time `t`. + /// + /// The derived instability sample is appended to that zone's window; the + /// oldest sample is evicted once the window is full. The `t` field is part + /// of the public record but does not affect the rolling order, which is the + /// order of `observe` calls (callers are expected to feed events in time + /// order, as the rest of the pipeline does). + pub fn observe(&mut self, obs: &BoundaryObservation) { + let sample = obs.instability_sample(); + let win = self.window; + let series = self.history.entry(obs.zone.clone()).or_default(); + series.push(sample); + if series.len() > win { + // Drop the oldest sample to keep the rolling window bounded. + let overflow = series.len() - win; + series.drain(0..overflow); + } + } + + /// Forecast per-zone instability for the next step, sorted by instability + /// descending (ties broken by zone name for determinism). + /// + /// Returns an empty vector if nothing has been observed. + pub fn forecast(&self) -> Vec { + let mut out: Vec = self + .history + .iter() + .filter(|(_, series)| !series.is_empty()) + .map(|(zone, series)| { + let level = mean(series); + let trend = slope(series); + let instability = (level + trend).max(0.0); + BoundaryForecast { + zone: zone.clone(), + instability, + trend, + } + }) + .collect(); + + out.sort_by(|a, b| { + b.instability + .partial_cmp(&a.instability) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.zone.cmp(&b.zone)) + }); + out + } + + /// The single most-likely zone to break next (highest forecast + /// instability), or `None` if nothing has been observed. + pub fn next_break(&self) -> Option { + self.forecast().into_iter().next() + } +} + +/// Arithmetic mean of a non-empty slice. Returns `0.0` for an empty slice. +fn mean(xs: &[f32]) -> f32 { + if xs.is_empty() { + return 0.0; + } + xs.iter().sum::() / xs.len() as f32 +} + +/// Least-squares slope of `xs` over indices `0..len`. +/// +/// Equivalent to the trend of the rolling window: positive means the boundary +/// is worsening. Returns `0.0` for fewer than two samples (a single point has +/// no trend). +fn slope(xs: &[f32]) -> f32 { + let n = xs.len(); + if n < 2 { + return 0.0; + } + let n_f = n as f32; + // x is the integer index 0..n; mean_x = (n-1)/2. + let mean_x = (n_f - 1.0) / 2.0; + let mean_y = mean(xs); + let mut num = 0.0f32; + let mut den = 0.0f32; + for (i, &y) in xs.iter().enumerate() { + let dx = i as f32 - mean_x; + num += dx * (y - mean_y); + den += dx * dx; + } + if den == 0.0 { + 0.0 + } else { + num / den + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rising_zone_has_positive_trend_and_higher_forecast() { + let mut p = BoundaryPredictor::new(5); + + // "kitchen" worsens: coherence and contradiction both climb. + for (i, (coh, con)) in [(0.1, 0.0), (0.3, 0.2), (0.5, 0.4), (0.7, 0.6), (0.9, 0.8)] + .into_iter() + .enumerate() + { + p.observe(&BoundaryObservation::new("kitchen", coh, con, i as u64)); + } + + // "hallway" stays calm: low coherence, no contradiction. + for i in 0..5 { + p.observe(&BoundaryObservation::new("hallway", 0.1, 0.0, i as u64)); + } + + let forecast = p.forecast(); + assert_eq!(forecast.len(), 2); + + let kitchen = forecast.iter().find(|f| f.zone == "kitchen").unwrap(); + let hallway = forecast.iter().find(|f| f.zone == "hallway").unwrap(); + + // The worsening zone has a clearly positive trend... + assert!( + kitchen.trend > 0.0, + "rising zone should have positive trend, got {}", + kitchen.trend + ); + // ...the calm zone is flat... + assert!( + hallway.trend.abs() < 1e-6, + "stable zone should be flat, got {}", + hallway.trend + ); + // ...and forecast instability is higher for the worsening zone. + assert!( + kitchen.instability > hallway.instability, + "rising {} should exceed stable {}", + kitchen.instability, + hallway.instability + ); + + // Sorted descending: the worsening zone leads. + assert_eq!(forecast[0].zone, "kitchen"); + } + + #[test] + fn next_break_returns_the_rising_zone() { + let mut p = BoundaryPredictor::new(4); + for i in 0..4 { + let coh = 0.2 + 0.2 * i as f32; + let con = 0.1 * i as f32; + p.observe(&BoundaryObservation::new("garage", coh, con, i as u64)); + p.observe(&BoundaryObservation::new("porch", 0.05, 0.0, i as u64)); + } + + let next = p.next_break().expect("a break should be predicted"); + assert_eq!(next.zone, "garage"); + assert!(next.instability > 0.0); + } + + #[test] + fn empty_predictor_yields_nothing() { + let p = BoundaryPredictor::new(8); + assert!(p.forecast().is_empty()); + assert!(p.next_break().is_none()); + } + + #[test] + fn window_evicts_oldest_samples() { + let mut p = BoundaryPredictor::new(2); + // Early calm sample then two strong ones; with window 2 the calm sample + // is evicted, so the level reflects only the recent strong activity. + p.observe(&BoundaryObservation::new("z", 0.0, 0.0, 0)); + p.observe(&BoundaryObservation::new("z", 0.9, 0.9, 1)); + p.observe(&BoundaryObservation::new("z", 0.9, 0.9, 2)); + + let f = p.next_break().unwrap(); + // Both retained samples are identical => flat trend, high level. + assert!(f.trend.abs() < 1e-6); + assert!(f.instability > 1.0, "got {}", f.instability); + } + + #[test] + fn instability_sample_is_clamped() { + // Out-of-range inputs are clamped to [0, 1] before combining. + let obs = BoundaryObservation::new("z", 2.0, 5.0, 0); + // coherence -> 1.0, contradiction -> 1.0 => 1.0 * (1 + 1) = 2.0 + assert!((obs.instability_sample() - 2.0).abs() < 1e-6); + + let neg = BoundaryObservation::new("z", -1.0, -1.0, 0); + assert!(neg.instability_sample().abs() < 1e-6); + } + + #[test] + fn forecast_is_deterministic_and_serializable() { + let mut p = BoundaryPredictor::new(3); + p.observe(&BoundaryObservation::new("a", 0.5, 0.5, 0)); + p.observe(&BoundaryObservation::new("b", 0.5, 0.5, 0)); + + let f = p.forecast(); + // Equal instability => alphabetical tie-break is stable. + assert_eq!(f[0].zone, "a"); + assert_eq!(f[1].zone, "b"); + + let json = serde_json::to_string(&f[0]).unwrap(); + let back: BoundaryForecast = serde_json::from_str(&json).unwrap(); + assert_eq!(f[0], back); + } +} diff --git a/crates/ruvector-perception/src/reality.rs b/crates/ruvector-perception/src/reality.rs new file mode 100644 index 000000000..0b9e5889d --- /dev/null +++ b/crates/ruvector-perception/src/reality.rs @@ -0,0 +1,250 @@ +//! Reality graph — grounding layer for agents. +//! +//! Agents hallucinate because they reason from prompts, not physical state. This +//! module lets an agent **query reality**: every answer is backed by witnessed +//! evidence (the [`DeltaWitness`] evidence hashes that justify it), not by text +//! inference. The agent asks "is anyone in the room? what changed since last +//! hour? which sensor is lying? is this action allowed?" and the reality graph +//! answers from physical memory. + +use crate::witness::{Action, DeltaWitness}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// A grounding question an agent can ask the physical world. +#[derive(Debug, Clone, PartialEq)] +pub enum Query { + /// Is something currently happening / present in a zone? + Presence { zone: String }, + /// Which zones changed (acted-upon) at or after time `t`? + ChangedSince { t: u64 }, + /// Which zones carry contradicted / untrusted evidence right now? + WhichUntrusted, + /// Is escalation (Alert/Mutate) currently permitted in a zone? + ActionAllowed { zone: String }, + /// The most recent witness for a zone. + LastWitness { zone: String }, +} + +/// A witness-grounded answer. `evidence` lists the SHA-256 evidence hashes that +/// justify the answer — provenance, not prose. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GroundedAnswer { + /// Boolean verdict (for yes/no queries; `false` when not applicable). + pub yes: bool, + /// Human-readable, fully grounded explanation. + pub detail: String, + /// Zones relevant to the answer (sorted). + pub zones: Vec, + /// Supporting evidence hashes (the witnesses backing this answer). + pub evidence: Vec, + /// Aggregate coherence of the supporting evidence, `[0, 1]`. + pub coherence: f32, +} + +impl GroundedAnswer { + fn none(detail: impl Into) -> Self { + Self { + yes: false, + detail: detail.into(), + zones: Vec::new(), + evidence: Vec::new(), + coherence: 0.0, + } + } +} + +/// Physical-memory graph queried by agents. Holds the latest witness per zone. +#[derive(Debug, Clone, Default)] +pub struct RealityGraph { + latest: BTreeMap, +} + +impl RealityGraph { + /// Create an empty reality graph. + pub fn new() -> Self { + Self::default() + } + + /// Fold a witness into physical memory (keyed by its changed boundary zone). + pub fn ingest(&mut self, w: &DeltaWitness) { + if w.changed_boundary.is_empty() { + return; + } + self.latest.insert(w.changed_boundary.clone(), w.clone()); + } + + /// Zones known to the reality graph (sorted). + pub fn zones(&self) -> Vec { + self.latest.keys().cloned().collect() + } + + /// Answer a grounding query from physical memory. + pub fn query(&self, q: &Query) -> GroundedAnswer { + match q { + Query::Presence { zone } => match self.latest.get(zone) { + Some(w) if w.action != Action::Ignore => GroundedAnswer { + yes: true, + detail: format!( + "activity in {zone}: {} supporting modality(ies), novelty {:.2}, action {:?}", + w.supporting_modalities.len(), + w.novelty, + w.action + ), + zones: vec![zone.clone()], + evidence: vec![w.evidence_hash.clone()], + coherence: w.coherence, + }, + Some(w) => GroundedAnswer { + yes: false, + detail: format!("{zone} quiet (last action Ignore)"), + zones: vec![zone.clone()], + evidence: vec![w.evidence_hash.clone()], + coherence: w.coherence, + }, + None => GroundedAnswer::none(format!("no physical memory for {zone}")), + }, + Query::ChangedSince { t } => { + let mut zones = Vec::new(); + let mut evidence = Vec::new(); + let mut coh = 0.0f32; + for (z, w) in &self.latest { + if w.t >= *t && w.action != Action::Ignore { + zones.push(z.clone()); + evidence.push(w.evidence_hash.clone()); + coh = coh.max(w.coherence); + } + } + GroundedAnswer { + yes: !zones.is_empty(), + detail: format!("{} zone(s) changed since t={t}", zones.len()), + zones, + evidence, + coherence: coh, + } + } + Query::WhichUntrusted => { + let mut zones = Vec::new(); + let mut evidence = Vec::new(); + let mut coh = 0.0f32; + for (z, w) in &self.latest { + if w.contradiction > 0.0 { + zones.push(z.clone()); + evidence.push(w.evidence_hash.clone()); + coh = coh.max(w.contradiction); + } + } + GroundedAnswer { + yes: !zones.is_empty(), + detail: format!( + "{} zone(s) carry contradicted evidence (a modality that usually reacts stayed silent)", + zones.len() + ), + zones, + evidence, + coherence: coh, + } + } + Query::ActionAllowed { zone } => match self.latest.get(zone) { + Some(w) => { + let allowed = matches!(w.action, Action::Alert | Action::Mutate); + GroundedAnswer { + yes: allowed, + detail: if allowed { + format!("escalation permitted in {zone}: evidence is novel, coherent, uncontradicted ({:?})", w.action) + } else { + format!("escalation NOT permitted in {zone}: action capped at {:?} (contradiction {:.2})", w.action, w.contradiction) + }, + zones: vec![zone.clone()], + evidence: vec![w.evidence_hash.clone()], + coherence: w.coherence, + } + } + None => GroundedAnswer::none(format!("no physical memory for {zone}; action denied by default")), + }, + Query::LastWitness { zone } => match self.latest.get(zone) { + Some(w) => GroundedAnswer { + yes: true, + detail: format!("last witness for {zone} at t={}, action {:?}", w.t, w.action), + zones: vec![zone.clone()], + evidence: vec![w.evidence_hash.clone()], + coherence: w.coherence, + }, + None => GroundedAnswer::none(format!("no physical memory for {zone}")), + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::modality::Modality; + + fn witness(zone: &str, t: u64, action: Action, contradiction: f32, hash: &str) -> DeltaWitness { + DeltaWitness { + t, + changed_boundary: zone.to_string(), + supporting_modalities: vec![Modality::Rf, Modality::Vibration], + contradicting_modalities: if contradiction > 0.0 { + vec![Modality::Thermal] + } else { + vec![] + }, + novelty: 0.8, + coherence: 0.9, + contradiction, + action, + evidence_hash: hash.to_string(), + prev_hash: None, + } + } + + #[test] + fn presence_is_grounded_in_a_witness() { + let mut rg = RealityGraph::new(); + rg.ingest(&witness("kitchen", 5, Action::Alert, 0.0, "h1")); + let a = rg.query(&Query::Presence { + zone: "kitchen".into(), + }); + assert!(a.yes); + assert_eq!(a.evidence, vec!["h1".to_string()]); + // A zone with no memory is honestly unknown, not hallucinated. + let b = rg.query(&Query::Presence { + zone: "garage".into(), + }); + assert!(!b.yes); + assert!(b.evidence.is_empty()); + } + + #[test] + fn untrusted_and_action_gate() { + let mut rg = RealityGraph::new(); + rg.ingest(&witness("door", 1, Action::Observe, 0.8, "hc")); // contradicted -> Observe + rg.ingest(&witness("hall", 2, Action::Mutate, 0.0, "hm")); // clean -> Mutate + let untrusted = rg.query(&Query::WhichUntrusted); + assert_eq!(untrusted.zones, vec!["door".to_string()]); + // Contradicted zone: escalation denied. Clean zone: allowed. + assert!( + !rg.query(&Query::ActionAllowed { + zone: "door".into() + }) + .yes + ); + assert!( + rg.query(&Query::ActionAllowed { + zone: "hall".into() + }) + .yes + ); + } + + #[test] + fn changed_since_filters_by_time() { + let mut rg = RealityGraph::new(); + rg.ingest(&witness("a", 1, Action::Alert, 0.0, "ha")); + rg.ingest(&witness("b", 9, Action::Alert, 0.0, "hb")); + let a = rg.query(&Query::ChangedSince { t: 5 }); + assert_eq!(a.zones, vec!["b".to_string()]); + } +} diff --git a/crates/ruvector-perception/src/state.rs b/crates/ruvector-perception/src/state.rs new file mode 100644 index 000000000..14902dade --- /dev/null +++ b/crates/ruvector-perception/src/state.rs @@ -0,0 +1,138 @@ +//! Physical state history: per-(zone, modality) rolling baselines and how +//! *responsive* each sensor usually is in each zone (used to detect a sensor +//! that "should have reacted but didn't" — the contradiction signal). + +use crate::modality::Modality; +use std::collections::HashMap; + +/// A single sensor sample in one zone at one time window. +#[derive(Debug, Clone, PartialEq)] +pub struct Reading { + /// Human-readable zone name (e.g. "table_left_zone"). + pub zone: String, + /// Which modality produced the sample. + pub modality: Modality, + /// Scalar value (already feature-extracted, e.g. band energy). + pub value: f32, +} + +impl Reading { + /// Convenience constructor. + pub fn new(zone: impl Into, modality: Modality, value: f32) -> Self { + Self { + zone: zone.into(), + modality, + value, + } + } +} + +/// Per-(zone, modality) running statistics. +#[derive(Debug, Clone, Copy)] +struct Channel { + /// EWMA baseline of the value. + baseline: f32, + /// EWMA of |delta| magnitude — the channel's typical activity. + activity: f32, + /// Fraction of updates with a significant delta (responsiveness in [0,1]). + responsiveness: f32, + /// Whether the channel has been initialised. + seen: bool, +} + +impl Default for Channel { + fn default() -> Self { + Self { + baseline: 0.0, + activity: 0.0, + responsiveness: 0.0, + seen: false, + } + } +} + +/// Rolling multi-modal world state. +#[derive(Debug, Clone, Default)] +pub struct WorldState { + channels: HashMap<(String, Modality), Channel>, + alpha: f32, // EWMA smoothing + active_threshold: f32, // |delta| above this counts as "responded" +} + +impl WorldState { + /// New state. `alpha` is the EWMA factor (e.g. 0.3); `active_threshold` is + /// the |delta| above which a channel is considered to have responded. + pub fn new(alpha: f32, active_threshold: f32) -> Self { + Self { + channels: HashMap::new(), + alpha, + active_threshold, + } + } + + /// Current baseline for a channel (0 if unseen). + pub fn baseline(&self, zone: &str, m: Modality) -> f32 { + self.channels + .get(&(zone.to_string(), m)) + .map(|c| c.baseline) + .unwrap_or(0.0) + } + + /// How responsive a channel historically is, in `[0, 1]`. + pub fn responsiveness(&self, zone: &str, m: Modality) -> f32 { + self.channels + .get(&(zone.to_string(), m)) + .map(|c| c.responsiveness) + .unwrap_or(0.0) + } + + /// Whether a channel has any history. + pub fn seen(&self, zone: &str, m: Modality) -> bool { + self.channels + .get(&(zone.to_string(), m)) + .map(|c| c.seen) + .unwrap_or(false) + } + + /// Threshold above which a |delta| counts as a response. + pub fn active_threshold(&self) -> f32 { + self.active_threshold + } + + /// Fold a reading into the rolling state (after its delta has been read). + pub fn update(&mut self, r: &Reading) { + let key = (r.zone.clone(), r.modality); + let a = self.alpha; + let thr = self.active_threshold; + let ch = self.channels.entry(key).or_default(); + if !ch.seen { + ch.baseline = r.value; + ch.seen = true; + return; + } + let delta = (r.value - ch.baseline).abs(); + let responded = if delta >= thr { 1.0 } else { 0.0 }; + ch.activity = (1.0 - a) * ch.activity + a * delta; + ch.responsiveness = (1.0 - a) * ch.responsiveness + a * responded; + ch.baseline = (1.0 - a) * ch.baseline + a * r.value; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tracks_baseline_and_responsiveness() { + let mut s = WorldState::new(0.5, 0.5); + // Thermal in zone A reacts repeatedly -> high responsiveness. + for v in [0.0, 1.0, 0.0, 1.0, 0.0, 1.0] { + s.update(&Reading::new("A", Modality::Thermal, v)); + } + assert!(s.seen("A", Modality::Thermal)); + assert!(s.responsiveness("A", Modality::Thermal) > 0.4); + // An unseen channel is quiet. + assert!(!s.seen("A", Modality::Rf)); + assert_eq!(s.responsiveness("A", Modality::Rf), 0.0); + } +} diff --git a/crates/ruvector-perception/src/swarm.rs b/crates/ruvector-perception/src/swarm.rs new file mode 100644 index 000000000..988bd71ae --- /dev/null +++ b/crates/ruvector-perception/src/swarm.rs @@ -0,0 +1,284 @@ +//! Swarm-scale min-cut sensing — *where* a coupled system is closest to breaking. +//! +//! At facility or city scale every room, machine, or router is a node in a +//! **coupling graph**: an edge weight is how strongly two nodes hold each other +//! in a coherent operating state (shared load, redundant links, correlated +//! environment). The operational question is not *"which sensor crossed a +//! threshold?"* but *"WHERE is the whole structure closest to fragmenting?"* — +//! and that is answered, globally, by the minimum cut. +//! +//! The global **min-cut value** is the total coupling that would have to fail for +//! the facility to split into two pieces: a low value means the system is one +//! weak link away from breaking apart (fragile); a high value means it is +//! robustly interconnected. The **bottleneck** nodes are those touching a +//! crossing edge — the load-bearing joints where the break would happen. +//! +//! This reuses [`ruvector_mincut`] for the cut. The cut *value* is authoritative; +//! the returned partition is best-effort (the engine may peel a single weakly +//! connected node rather than return a balanced split), so all decision-relevant +//! output keys on the **value** and on the **bottleneck set**, never on an exact +//! balanced partition. +//! +//! ## Example +//! +//! ``` +//! use ruvector_perception::FacilityGraph; +//! +//! let mut g = FacilityGraph::new(); +//! // Two tight clusters joined by one thin link. +//! g.couple("r1", "r2", 10.0); +//! g.couple("r2", "r3", 10.0); +//! g.couple("r3", "r4", 0.5); // the fragile joint +//! g.couple("r4", "r5", 10.0); +//! g.couple("r5", "r6", 10.0); +//! +//! let report = g.fragility().unwrap(); +//! assert!((report.min_cut - 0.5).abs() < 1e-6); +//! assert!(report.bottlenecks.contains(&"r3".to_string()) +//! || report.bottlenecks.contains(&"r4".to_string())); +//! ``` + +use std::collections::{BTreeMap, BTreeSet}; + +use ruvector_mincut::MinCutBuilder; +use serde::{Deserialize, Serialize}; + +/// Where a coupled facility is structurally closest to fragmenting. +/// +/// The headline number is [`min_cut`](FragilityReport::min_cut): the total +/// coupling weight that would have to fail for the system to split. Lower means +/// more fragile. [`bottlenecks`](FragilityReport::bottlenecks) lists the +/// load-bearing joints — nodes touching a crossing edge. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct FragilityReport { + /// Global min-cut weight = how close the facility is to breaking apart. + /// Lower = more fragile. + pub min_cut: f64, + /// One side of the fragile partition (best-effort; do not rely on balance). + pub side_a: Vec, + /// The other side of the fragile partition (best-effort; may be empty). + pub side_b: Vec, + /// Nodes incident to a crossing (cut) edge — the structural bottlenecks. + /// Sorted and deduped. + pub bottlenecks: Vec, +} + +/// A facility-scale coupling graph: nodes are rooms/machines/routers, edges are +/// undirected coupling strengths that accumulate across calls. +#[derive(Debug, Clone, Default)] +pub struct FacilityGraph { + /// Distinct node names. + nodes: BTreeSet, + /// Summed undirected coupling, keyed by the ordered `(min, max)` name pair. + edges: BTreeMap<(String, String), f64>, +} + +impl FacilityGraph { + /// Create an empty facility graph. + pub fn new() -> Self { + Self::default() + } + + /// Add (or accumulate) an undirected coupling strength between two facility + /// nodes. Repeated calls on the same unordered pair **sum** into one total + /// coupling weight. + /// + /// Non-positive weights and self-loops (`a == b`) are ignored, matching the + /// min-cut engine's requirement of positive weights on distinct endpoints. + pub fn couple(&mut self, a: impl Into, b: impl Into, weight: f64) { + let a = a.into(); + let b = b.into(); + if a == b || !weight.is_finite() || weight <= 0.0 { + return; + } + self.nodes.insert(a.clone()); + self.nodes.insert(b.clone()); + let key = if a <= b { (a, b) } else { (b, a) }; + *self.edges.entry(key).or_insert(0.0) += weight; + } + + /// Number of distinct nodes in the graph. + pub fn len(&self) -> usize { + self.nodes.len() + } + + /// Whether the graph has no nodes. + pub fn is_empty(&self) -> bool { + self.nodes.is_empty() + } + + /// Compute the global minimum cut: the place where the facility is + /// structurally closest to fragmenting. + /// + /// Returns `None` if there are fewer than two nodes or no edges. Otherwise + /// the [`FragilityReport`] always carries a trustworthy + /// [`min_cut`](FragilityReport::min_cut) value; the sides are best-effort and + /// `bottlenecks` lists the nodes touching a crossing edge (sorted, deduped). + /// Never panics. + pub fn fragility(&self) -> Option { + if self.nodes.len() < 2 || self.edges.is_empty() { + return None; + } + + // Stable name <-> id mapping (BTreeSet iterates in sorted order). + let names: Vec = self.nodes.iter().cloned().collect(); + let id_of: BTreeMap<&str, u64> = names + .iter() + .enumerate() + .map(|(i, n)| (n.as_str(), i as u64)) + .collect(); + + let edges: Vec<(u64, u64, f64)> = self + .edges + .iter() + .map(|((u, v), w)| (id_of[u.as_str()], id_of[v.as_str()], *w)) + .collect(); + + let mincut = MinCutBuilder::new() + .exact() + .with_edges(edges) + .build() + .ok()?; + let result = mincut.min_cut(); + let min_cut = result.value; + + // Best-effort sides from the engine partition. Fall back to "all on one + // side" when the engine returns no usable split. + let (side_a_ids, side_b_ids): (Vec, Vec) = match result.partition { + Some((a, b)) if !a.is_empty() && !b.is_empty() => (a, b), + _ => ((0..names.len() as u64).collect(), Vec::new()), + }; + + let mut side_a: Vec = side_a_ids + .iter() + .map(|&i| names[i as usize].clone()) + .collect(); + let mut side_b: Vec = side_b_ids + .iter() + .map(|&i| names[i as usize].clone()) + .collect(); + side_a.sort(); + side_b.sort(); + + // Bottlenecks = endpoints of the WEAKEST link(s) — the fragile joints + // where a break would occur. Derived from edge weights, NOT the engine + // partition: the engine's `min_cut` value is reliable but the partition + // it materialises can be inconsistent with that value (it sometimes + // peels a single node), so partition-crossing edges are not trustworthy + // bottleneck markers. The weakest edge is the true structural weak point. + let min_w = self.edges.values().copied().fold(f64::INFINITY, f64::min); + let mut bottleneck_set: BTreeSet = BTreeSet::new(); + for ((u, v), &w) in &self.edges { + if (w - min_w).abs() <= 1e-9 { + bottleneck_set.insert(u.clone()); + bottleneck_set.insert(v.clone()); + } + } + Some(FragilityReport { + min_cut, + side_a, + side_b, + bottlenecks: bottleneck_set.into_iter().collect(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn thin_link_between_two_clusters_is_the_fragility() { + // Cluster {r1,r2,r3} tightly coupled, cluster {r4,r5,r6} tightly coupled, + // joined only by the thin link r3<->r4 (weight 0.5). + let mut g = FacilityGraph::new(); + for &(a, b) in &[("r1", "r2"), ("r2", "r3"), ("r1", "r3")] { + g.couple(a, b, 10.0); + } + for &(a, b) in &[("r4", "r5"), ("r5", "r6"), ("r4", "r6")] { + g.couple(a, b, 10.0); + } + g.couple("r3", "r4", 0.5); + + assert_eq!(g.len(), 6); + let report = g.fragility().expect("two clusters -> a report"); + + // The weakest crossing is exactly the thin link. + assert!( + (report.min_cut - 0.5).abs() < 1e-6, + "min_cut = {}", + report.min_cut + ); + // ...and it is far below the intra-cluster coupling. + assert!(report.min_cut < 10.0); + // The fragile joint is r3 or r4. + assert!( + report.bottlenecks.contains(&"r3".to_string()) + || report.bottlenecks.contains(&"r4".to_string()), + "bottlenecks = {:?}", + report.bottlenecks + ); + } + + #[test] + fn fewer_than_two_nodes_is_none() { + let empty = FacilityGraph::new(); + assert!(empty.is_empty()); + assert_eq!(empty.fragility(), None); + + // A single self-loop is ignored, so still no graph. + let mut single = FacilityGraph::new(); + single.couple("only", "only", 5.0); + assert!(single.is_empty()); + assert_eq!(single.fragility(), None); + } + + #[test] + fn uniform_clique_isolates_a_single_node() { + // Strongly, uniformly coupled clique over 5 nodes. The cheapest cut is + // to isolate one node: (k-1) * weight. + let mut g = FacilityGraph::new(); + let nodes = ["a", "b", "c", "d", "e"]; + let weight = 2.0; + for i in 0..nodes.len() { + for j in (i + 1)..nodes.len() { + g.couple(nodes[i], nodes[j], weight); + } + } + let report = g.fragility().expect("clique -> a report"); + + let isolation_cost = (nodes.len() as f64 - 1.0) * weight; // 4 * 2 = 8 + assert!( + (report.min_cut - isolation_cost).abs() < 1e-6, + "min_cut = {}", + report.min_cut + ); + assert!(report.min_cut > 0.0); + assert!(!report.bottlenecks.is_empty()); + } + + #[test] + fn repeated_couplings_sum() { + let mut g = FacilityGraph::new(); + g.couple("x", "y", 1.5); + g.couple("y", "x", 2.5); // same unordered pair, reversed + // Only one edge, so the only cut separates the two nodes: total = 4.0. + let report = g.fragility().expect("two coupled nodes -> a report"); + assert!( + (report.min_cut - 4.0).abs() < 1e-6, + "min_cut = {}", + report.min_cut + ); + assert_eq!(g.len(), 2); + } + + #[test] + fn non_positive_and_self_weights_ignored() { + let mut g = FacilityGraph::new(); + g.couple("a", "b", 0.0); + g.couple("a", "b", -3.0); + g.couple("a", "a", 5.0); + assert!(g.is_empty()); + assert_eq!(g.fragility(), None); + } +} diff --git a/crates/ruvector-perception/src/topology.rs b/crates/ruvector-perception/src/topology.rs new file mode 100644 index 000000000..51e3b9e75 --- /dev/null +++ b/crates/ruvector-perception/src/topology.rs @@ -0,0 +1,401 @@ +//! # Self-healing sensor topology +//! +//! Sensors are not equal, and which ones *matter* changes over time. This module +//! keeps a running **agreement graph** between sensors (how often each pair +//! corroborates the other) and lets that graph reorganise itself so the system +//! can answer one operational question: *what is each sensor's structural role +//! right now?* +//! +//! Every node is classified into one of four [`NodeRole`]s: +//! +//! - **Critical** — removing it would fragment the topology. It is the sole (or +//! near-sole) strong link bridging two otherwise-disconnected clusters. +//! Detected with a dynamic global **min-cut**: a node on the min-cut boundary +//! that carries a crossing edge and has few strong alternatives is a bridge. +//! - **Redundant** — it has a near-duplicate peer (very high agreement with at +//! least one other sensor), so it could be put to sleep without losing +//! coverage. +//! - **Noisy** — it disagrees with essentially everyone (low mean agreement); +//! its readings are not corroborated and should be discounted. +//! - **Normal** — none of the above. +//! +//! The agreement between two sensors is accumulated as an **EWMA** (exponential +//! weighted moving average, `alpha = 0.3`) over repeated [`record_agreement`] +//! calls, so the topology drifts toward recent behaviour while staying stable. +//! +//! ``` +//! use ruvector_perception::topology::{TopologyManager, NodeRole}; +//! +//! let mut topo = TopologyManager::new(); +//! topo.record_agreement("cam_a", "cam_b", 0.95); // near-duplicates +//! topo.record_agreement("cam_a", "mic_x", 0.6); +//! topo.record_agreement("cam_b", "mic_x", 0.6); +//! let report = topo.assess(); +//! assert!(report.iter().any(|a| a.role == NodeRole::Redundant)); +//! ``` +//! +//! [`record_agreement`]: TopologyManager::record_agreement + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; + +/// EWMA smoothing factor for accumulated pairwise agreement. +const ALPHA: f32 = 0.3; +/// Minimum agreement weight for an edge to count as a topology link at all. +/// Edges below this floor are treated as "no meaningful link". +const EDGE_FLOOR: f32 = 0.05; +/// Below this *mean* incident agreement a node is considered [`NodeRole::Noisy`]. +const NOISY_MEAN: f32 = 0.3; +/// At or above this *max* incident agreement a node has a near-duplicate peer +/// and is considered [`NodeRole::Redundant`]. +const REDUNDANT_MAX: f32 = 0.85; +/// Minimum number of sensors for articulation (bridge) detection to be meaningful. +const MIN_NODES_FOR_BRIDGE: usize = 3; + +/// Structural role of a sensor within the agreement topology. +/// +/// Ordering of precedence when more than one rule fires is documented on +/// [`TopologyManager::assess`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum NodeRole { + /// Bridges two clusters; its loss would fragment the topology. + Critical, + /// Has a near-duplicate peer and could be put to sleep. + Redundant, + /// Disagrees with (almost) everyone; readings are uncorroborated. + Noisy, + /// No special structural role. + Normal, +} + +/// Per-node assessment produced by [`TopologyManager::assess`]. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct NodeAssessment { + /// Sensor name. + pub node: String, + /// Classified structural role. + pub role: NodeRole, + /// Maximum incident agreement (how close its nearest peer is, in `[0, 1]`). + pub redundancy: f32, + /// Mean incident agreement across all of its links, in `[0, 1]`. + pub agreement: f32, +} + +/// Maintains a self-healing sensor agreement graph and classifies node roles. +/// +/// Pairwise agreement is stored once per unordered pair, keyed by the +/// lexicographically ordered `(min, max)` name tuple, and accumulated as an +/// EWMA. The set of known sensor names is tracked separately so isolated +/// sensors (no edges yet) are still assessed. +#[derive(Debug, Clone, Default)] +pub struct TopologyManager { + /// EWMA agreement per unordered pair, key = `(min_name, max_name)`. + edges: BTreeMap<(String, String), f32>, + /// All sensor names ever observed. + nodes: BTreeSet, +} + +impl TopologyManager { + /// Create an empty topology manager. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Record (and accumulate) a pairwise agreement score in `[0, 1]` between + /// two sensors. + /// + /// Repeated calls update the stored value as an EWMA + /// (`new = alpha * score + (1 - alpha) * old`), so the topology adapts to + /// recent behaviour. The score is clamped to `[0, 1]`. Self-pairs + /// (`a == b`) are ignored. Both names are registered as known sensors even + /// if the pair is a self-pair. + pub fn record_agreement(&mut self, a: impl Into, b: impl Into, score: f32) { + let a = a.into(); + let b = b.into(); + self.nodes.insert(a.clone()); + self.nodes.insert(b.clone()); + if a == b { + return; // no self-loops + } + let score = score.clamp(0.0, 1.0); + let key = if a <= b { (a, b) } else { (b, a) }; + self.edges + .entry(key) + .and_modify(|w| *w = ALPHA * score + (1.0 - ALPHA) * *w) + .or_insert(score); + } + + /// Number of known sensors. + #[must_use] + pub fn len(&self) -> usize { + self.nodes.len() + } + + /// Whether no sensors are known yet. + #[must_use] + pub fn is_empty(&self) -> bool { + self.nodes.is_empty() + } + + /// Assess every node's role from the accumulated agreement graph. + /// + /// Output is sorted by node name for determinism. Role precedence when + /// multiple rules could apply: **Critical > Redundant > Noisy > Normal**. + /// (A bridge that also happens to be noisy is reported Critical, because its + /// structural fragility dominates the operational decision.) + /// + /// Graceful degenerate handling: with fewer than two sensors, or with no + /// edges above [`EDGE_FLOOR`], every node is [`NodeRole::Normal`] — except a + /// truly isolated node (no incident links at all) which is reported + /// [`NodeRole::Noisy`], since nothing corroborates it. Never panics. + #[must_use] + pub fn assess(&self) -> Vec { + if self.nodes.is_empty() { + return Vec::new(); + } + + // Stable index for each sensor (BTreeSet iterates in sorted order). + let names: Vec = self.nodes.iter().cloned().collect(); + let index: BTreeMap<&str, usize> = names + .iter() + .enumerate() + .map(|(i, n)| (n.as_str(), i)) + .collect(); + let n = names.len(); + + // Incident weights per node (only edges above the floor count). + let mut incident: Vec> = vec![Vec::new(); n]; + // Adjacency restricted to floor-passing edges, for the bridge rule. + let mut adj: Vec> = vec![Vec::new(); n]; + for ((a, b), &w) in &self.edges { + if w < EDGE_FLOOR { + continue; + } + let (ia, ib) = (index[a.as_str()], index[b.as_str()]); + incident[ia].push(w); + incident[ib].push(w); + adj[ia].push((ib, w)); + adj[ib].push((ia, w)); + } + + // Identify the min-cut bridge boundary once for the whole graph. + let critical = self.critical_nodes(n, &adj); + + names + .iter() + .enumerate() + .map(|(i, name)| { + let inc = &incident[i]; + let (agreement, redundancy) = if inc.is_empty() { + (0.0_f32, 0.0_f32) + } else { + let sum: f32 = inc.iter().sum(); + let mean = sum / inc.len() as f32; + let max = inc.iter().copied().fold(0.0_f32, f32::max); + (mean, max) + }; + + let role = if critical.contains(&i) { + NodeRole::Critical + } else if redundancy >= REDUNDANT_MAX { + NodeRole::Redundant + } else if inc.is_empty() || agreement < NOISY_MEAN { + NodeRole::Noisy + } else { + NodeRole::Normal + }; + + NodeAssessment { + node: name.clone(), + role, + redundancy, + agreement, + } + }) + .collect() + } + + /// Determine which node indices are **structural bridges** (articulation + /// points): a node whose removal fragments the strong-edge agreement graph + /// into more connected components than before. A bridge is the extreme, + /// most fragile cut — a single-edge min cut — so losing such a node splits + /// the topology. + /// + /// This is robust where a global-min-cut partition is not: it directly tests + /// "does removing this node disconnect the graph?", which cleanly separates a + /// true inter-cluster bridge (Critical) from a lone outlier that merely + /// peels off (Noisy/Redundant). Isolated nodes (no strong edges) are never + /// Critical. Needs at least [`MIN_NODES_FOR_BRIDGE`] sensors to be meaningful. + fn critical_nodes(&self, n: usize, adj: &[Vec<(usize, f32)>]) -> BTreeSet { + let mut critical = BTreeSet::new(); + if n < MIN_NODES_FOR_BRIDGE { + return critical; + } + let base = components(n, adj, None); + for v in 0..n { + if adj[v].is_empty() { + continue; // isolated node can't be a bridge + } + if components(n, adj, Some(v)) > base { + critical.insert(v); + } + } + critical + } +} + +/// Count connected components among non-isolated nodes, optionally excluding one +/// `removed` node (and its incident edges). Used for articulation detection. +fn components(n: usize, adj: &[Vec<(usize, f32)>], removed: Option) -> usize { + let mut visited = vec![false; n]; + if let Some(r) = removed { + visited[r] = true; + } + let mut comps = 0; + for start in 0..n { + if visited[start] || adj[start].is_empty() { + continue; // skip visited and truly isolated nodes + } + comps += 1; + let mut stack = vec![start]; + visited[start] = true; + while let Some(u) = stack.pop() { + for &(w, _) in &adj[u] { + if Some(w) == removed || visited[w] { + continue; + } + visited[w] = true; + stack.push(w); + } + } + } + comps +} + +#[cfg(test)] +mod tests { + use super::*; + + fn role_of<'a>(report: &'a [NodeAssessment], node: &str) -> &'a NodeRole { + &report + .iter() + .find(|a| a.node == node) + .unwrap_or_else(|| panic!("node {node} missing from report")) + .role + } + + #[test] + fn empty_manager_is_empty_and_safe() { + let topo = TopologyManager::new(); + assert!(topo.is_empty()); + assert_eq!(topo.len(), 0); + assert!(topo.assess().is_empty()); // no panic, empty result + } + + #[test] + fn near_duplicate_peer_is_redundant() { + let mut topo = TopologyManager::new(); + // a and b are near-duplicates; both also moderately agree with c. + topo.record_agreement("a", "b", 0.95); + topo.record_agreement("a", "c", 0.6); + topo.record_agreement("b", "c", 0.6); + let report = topo.assess(); + + // At least one of the duplicate pair is flagged Redundant. + let redundant = report + .iter() + .filter(|x| x.role == NodeRole::Redundant) + .count(); + assert!(redundant >= 1, "expected a redundant node, got {report:?}"); + // The redundant node should be a or b (high mutual agreement). + for a in &report { + if a.role == NodeRole::Redundant { + assert!(a.node == "a" || a.node == "b", "unexpected redundant {a:?}"); + assert!(a.redundancy >= REDUNDANT_MAX); + } + } + } + + #[test] + fn lone_disagreeing_node_is_noisy() { + let mut topo = TopologyManager::new(); + // x corroborates y and z strongly; n disagrees with all (~0.1). + topo.record_agreement("x", "y", 0.8); + topo.record_agreement("x", "z", 0.8); + topo.record_agreement("y", "z", 0.8); + topo.record_agreement("n", "x", 0.1); + topo.record_agreement("n", "y", 0.1); + topo.record_agreement("n", "z", 0.1); + let report = topo.assess(); + + assert_eq!( + *role_of(&report, "n"), + NodeRole::Noisy, + "report: {report:?}" + ); + // The well-corroborated nodes are not Noisy. + assert_ne!(*role_of(&report, "x"), NodeRole::Noisy); + } + + #[test] + fn bridge_node_between_two_clusters_is_critical() { + let mut topo = TopologyManager::new(); + // Cluster 1: {a, b, c} tightly agree. + topo.record_agreement("a", "b", 0.95); + topo.record_agreement("a", "c", 0.95); + topo.record_agreement("b", "c", 0.95); + // Cluster 2: {d, e, f} tightly agree. + topo.record_agreement("d", "e", 0.95); + topo.record_agreement("d", "f", 0.95); + topo.record_agreement("e", "f", 0.95); + // Single fragile link joining the clusters: c <-> d. + topo.record_agreement("c", "d", 0.6); + + let report = topo.assess(); + let critical: Vec<&str> = report + .iter() + .filter(|x| x.role == NodeRole::Critical) + .map(|x| x.node.as_str()) + .collect(); + + // The bridge endpoints (c and d) carry the sole crossing link and have + // strongly-connected same-side peers; the min-cut should isolate them + // as the boundary, marking at least one bridge endpoint Critical. + assert!( + critical.contains(&"c") || critical.contains(&"d"), + "expected a bridge node (c or d) to be Critical, got critical={critical:?} report={report:?}" + ); + } + + #[test] + fn ewma_accumulates_repeated_scores() { + let mut topo = TopologyManager::new(); + topo.record_agreement("p", "q", 1.0); // first observation -> stored as-is + topo.record_agreement("p", "q", 0.0); // EWMA pulls it down + let report = topo.assess(); + let p = report.iter().find(|x| x.node == "p").unwrap(); + // After 1.0 then 0.0: 0.3*0.0 + 0.7*1.0 = 0.7. + assert!((p.agreement - 0.7).abs() < 1e-4, "got {}", p.agreement); + } + + #[test] + fn output_is_sorted_by_name() { + let mut topo = TopologyManager::new(); + topo.record_agreement("zebra", "alpha", 0.5); + topo.record_agreement("mid", "alpha", 0.5); + let report = topo.assess(); + let names: Vec<&str> = report.iter().map(|a| a.node.as_str()).collect(); + assert_eq!(names, vec!["alpha", "mid", "zebra"]); + } + + #[test] + fn single_isolated_sensor_is_noisy_not_panic() { + let mut topo = TopologyManager::new(); + topo.record_agreement("solo", "solo", 0.9); // self-pair ignored as edge + let report = topo.assess(); + assert_eq!(report.len(), 1); + assert_eq!(report[0].role, NodeRole::Noisy); // nothing corroborates it + } +} diff --git a/crates/ruvector-perception/src/witness.rs b/crates/ruvector-perception/src/witness.rs new file mode 100644 index 000000000..757d09fcf --- /dev/null +++ b/crates/ruvector-perception/src/witness.rs @@ -0,0 +1,180 @@ +//! Proof-gated perception. A physical change may only drive an action if it +//! passes a proof gate — an auditable evidence chain (raw hash, feature hash, +//! novelty, coherence, contradiction, boundary, policy), not a confidence score. + +use crate::modality::Modality; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +/// Bounded authority the engine may exercise on a witnessed change. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Action { + /// Nothing changed worth noting. + Ignore, + /// Real but ambiguous/contradicted — keep watching, do not escalate. + Observe, + /// Coherent, novel, uncontradicted — raise an alert. + Alert, + /// Strong, clean, uncontradicted — allowed to mutate persistent memory. + Mutate, +} + +/// Thresholds that turn scores into bounded authority. +#[derive(Debug, Clone, Copy)] +pub struct ProofGate { + /// Below this novelty, ignore (it's business as usual). + pub novelty_min: f32, + /// Novelty at/above this is "high". + pub novelty_high: f32, + /// Minimum boundary coherence to trust the localisation. + pub coherence_min: f32, + /// At/above this contradiction, never escalate beyond Observe. + pub contradiction_max: f32, +} + +impl Default for ProofGate { + fn default() -> Self { + Self { + novelty_min: 0.25, + novelty_high: 0.6, + coherence_min: 0.5, + contradiction_max: 0.34, + } + } +} + +impl ProofGate { + /// Decide bounded authority from the three scores. Contradiction caps + /// authority at Observe; only clean, novel, uncontradicted evidence escalates. + pub fn decide(&self, novelty: f32, coherence: f32, contradiction: f32) -> Action { + if novelty < self.novelty_min { + return Action::Ignore; + } + if contradiction >= self.contradiction_max { + return Action::Observe; // evidence is internally inconsistent + } + if coherence < self.coherence_min { + return Action::Observe; // can't trust the localisation + } + if novelty >= self.novelty_high { + // Strong, clean, uncontradicted: highest authority only when + // contradiction is essentially absent. + if contradiction <= self.contradiction_max * 0.25 { + Action::Mutate + } else { + Action::Alert + } + } else { + Action::Observe + } + } +} + +/// Qualitative novelty bucket for human-readable witnesses. +pub fn novelty_level(n: f32, gate: &ProofGate) -> &'static str { + if n >= gate.novelty_high { + "high" + } else if n >= gate.novelty_min { + "medium" + } else { + "low" + } +} + +/// The structured output of perception — a delta, not a label. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DeltaWitness { + /// Time window index. + pub t: u64, + /// The zone whose physical state moved. + pub changed_boundary: String, + /// Modalities that responded coherently. + pub supporting_modalities: Vec, + /// Modalities that should have responded (historically responsive) but + /// stayed silent — first-class disagreement. + pub contradicting_modalities: Vec, + /// Novelty vs prior physical states, `[0, 1]`. + pub novelty: f32, + /// Boundary coherence (localisation cleanliness), `[0, 1]`. + pub coherence: f32, + /// Contradiction strength, `[0, 1]`. + pub contradiction: f32, + /// Bounded authority granted by the proof gate. + pub action: Action, + /// SHA-256 evidence hash for this witness (hex). + pub evidence_hash: String, + /// Previous witness hash — forms an auditable chain of custody. + pub prev_hash: Option, +} + +/// Compute the evidence hash binding raw signal, features, scores, boundary, +/// policy, and the prior witness into one auditable digest. +#[allow(clippy::too_many_arguments)] +pub fn evidence_hash( + raw: &[u8], + features: &[u8], + boundary: &str, + novelty: f32, + coherence: f32, + contradiction: f32, + action: Action, + prev: Option<&str>, +) -> String { + let mut h = Sha256::new(); + h.update(b"rvperception-v1"); + h.update((raw.len() as u64).to_le_bytes()); + h.update(raw); + h.update((features.len() as u64).to_le_bytes()); + h.update(features); + h.update(boundary.as_bytes()); + h.update(novelty.to_le_bytes()); + h.update(coherence.to_le_bytes()); + h.update(contradiction.to_le_bytes()); + h.update([action as u8]); + if let Some(p) = prev { + h.update(p.as_bytes()); + } + let digest = h.finalize(); + let mut s = String::with_capacity(64); + for b in digest { + s.push_str(&format!("{b:02x}")); + } + s +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn contradiction_caps_authority_at_observe() { + let g = ProofGate::default(); + // High novelty, clean boundary, but contradicted -> Observe, never Alert. + assert_eq!(g.decide(0.9, 0.9, 0.5), Action::Observe); + // Clean, novel, uncontradicted -> escalates. + assert_eq!(g.decide(0.9, 0.9, 0.0), Action::Mutate); + // Below novelty floor -> Ignore. + assert_eq!(g.decide(0.1, 0.9, 0.0), Action::Ignore); + } + + #[test] + fn evidence_hash_is_deterministic_and_chains() { + let a = evidence_hash(b"raw", b"feat", "zoneA", 0.9, 0.8, 0.1, Action::Alert, None); + let b = evidence_hash(b"raw", b"feat", "zoneA", 0.9, 0.8, 0.1, Action::Alert, None); + assert_eq!(a, b); + assert_eq!(a.len(), 64); + // Chaining changes the hash. + let c = evidence_hash( + b"raw", + b"feat", + "zoneA", + 0.9, + 0.8, + 0.1, + Action::Alert, + Some(&a), + ); + assert_ne!(a, c); + } +} diff --git a/crates/ruvector-perception/tests/scenarios.rs b/crates/ruvector-perception/tests/scenarios.rs new file mode 100644 index 000000000..2c27ee991 --- /dev/null +++ b/crates/ruvector-perception/tests/scenarios.rs @@ -0,0 +1,102 @@ +//! The brief's flagship scenarios: +//! 1. Move an inert object: RF/vibration/acoustic support, thermal contradicts, +//! novelty high, action = observe (a structured delta witness, not a label). +//! 2. A bedtime routine whose return never happens -> absence safety signal. + +use ruvector_perception::{ + novelty_level, Action, DeltaEngine, EngineConfig, Modality, ProofGate, Reading, SequenceMonitor, +}; + +fn warmup(eng: &mut DeltaEngine) { + // Build responsiveness in table_left_zone across RF/vibration/acoustic/thermal + // (all historically react here); other zones stay quiet. + for i in 0..8u64 { + let hi = (i % 2) as f32; + let rs = vec![ + Reading::new("table_left_zone", Modality::Rf, hi), + Reading::new("table_left_zone", Modality::Vibration, hi), + Reading::new("table_left_zone", Modality::Acoustic, hi), + Reading::new("table_left_zone", Modality::Thermal, 20.0 + hi), + Reading::new("table_right_zone", Modality::Rf, 0.0), + Reading::new("window_zone", Modality::Rf, 0.0), + ]; + eng.observe(&rs, i); + } +} + +#[test] +fn inert_object_move_produces_structured_delta_witness() { + let mut eng = DeltaEngine::new(EngineConfig::default()); + warmup(&mut eng); + + // Construct the event relative to learned baselines: RF/vibration/acoustic + // jump (object moved), thermal exactly at baseline (no heat -> silent). + let bl = |m| eng.state().baseline("table_left_zone", m); + let (bl_rf, bl_vib, bl_ac, bl_th) = ( + bl(Modality::Rf), + bl(Modality::Vibration), + bl(Modality::Acoustic), + bl(Modality::Thermal), + ); + let event = vec![ + Reading::new("table_left_zone", Modality::Rf, bl_rf + 3.0), + Reading::new("table_left_zone", Modality::Vibration, bl_vib + 3.0), + Reading::new("table_left_zone", Modality::Acoustic, bl_ac + 3.0), + Reading::new("table_left_zone", Modality::Thermal, bl_th), // silent + Reading::new( + "table_right_zone", + Modality::Rf, + eng.state().baseline("table_right_zone", Modality::Rf), + ), + Reading::new( + "window_zone", + Modality::Rf, + eng.state().baseline("window_zone", Modality::Rf), + ), + ]; + + let prev = eng.state().baseline("table_left_zone", Modality::Rf); // touch state + let _ = prev; + let w = eng.observe(&event, 100); + + // The exact witness shape from the brief. + assert_eq!(w.changed_boundary, "table_left_zone"); + assert!(w.supporting_modalities.contains(&Modality::Rf)); + assert!(w.supporting_modalities.contains(&Modality::Vibration)); + assert!(w.supporting_modalities.contains(&Modality::Acoustic)); + assert!(!w.supporting_modalities.contains(&Modality::Thermal)); + assert!( + w.contradicting_modalities.contains(&Modality::Thermal), + "thermal should contradict (usually reacts here, stayed silent): {:?}", + w.contradicting_modalities + ); + assert_eq!(novelty_level(w.novelty, &ProofGate::default()), "high"); + assert!(w.coherence > 0.5, "boundary not clean: {}", w.coherence); + // Contradicted evidence is capped at Observe — it does not escalate. + assert_eq!(w.action, Action::Observe); + // Auditable evidence chain. + assert_eq!(w.evidence_hash.len(), 64); + assert!( + w.prev_hash.is_some(), + "witness should chain to the warmup history" + ); +} + +#[test] +fn missing_routine_return_is_a_safety_signal() { + let mut routine = SequenceMonitor::new( + vec![ + "bed_exit".into(), + "bathroom_path".into(), + "return_path".into(), + ], + 100, + ); + routine.observe_zone("bed_exit", 0); + routine.observe_zone("bathroom_path", 10); + // The return edge never appears: the sequence graph stays incomplete. + assert!(routine.check(50).is_none()); + let absence = routine.check(300).expect("overdue return is a signal"); + assert_eq!(absence.missing_step, "return_path"); + assert_eq!(absence.after, "bathroom_path"); +} diff --git a/deny.toml b/deny.toml index 81ca838f6..bf5cc0bc4 100644 --- a/deny.toml +++ b/deny.toml @@ -80,6 +80,12 @@ ignore = [ # incompatible. Dependabot tracking. Re-review on 2026-07-01. "RUSTSEC-2024-0370", + # proc-macro-error2 — the (also now unmaintained) fork of the above, + # pulled transitively via validator_derive -> validator (ruvector-scipix + # example). Same crate family as RUSTSEC-2024-0370, no maintained + # successor yet. Informational. Re-review on 2026-07-01. + "RUSTSEC-2026-0173", + # number_prefix — unmaintained, used transitively by indicatif. No # known successor; indicatif itself is still maintained. Informational. "RUSTSEC-2025-0119", diff --git a/docs/adr/ADR-196-structure-preserving-graph-condensation.md b/docs/adr/ADR-196-structure-preserving-graph-condensation.md new file mode 100644 index 000000000..1b9f1d66d --- /dev/null +++ b/docs/adr/ADR-196-structure-preserving-graph-condensation.md @@ -0,0 +1,168 @@ +--- +adr: 196 +title: "Structure-Preserving Graph Condensation (ruvector-graph-condense)" +status: accepted +date: 2026-06-07 +authors: [ruvnet, claude] +related: [ADR-197] +tags: [graph, condensation, coarsening, min-cut, gnn, ruview, worldgraph, hnsw] +--- + +# ADR-196 — Structure-Preserving Graph Condensation + +## Status + +**Accepted (implemented).** Crate `crates/ruvector-graph-condense` landed on +branch `claude/graph-condensation-ruvector-lVAKm`. ADR-197 covers the +differentiable min-cut loss added on top. + +## Context + +We want to shrink large feature graphs (a graph plus a per-node embedding and an +optional class label) into a much smaller graph that a downstream consumer — +GNN training, edge inference, or RuView's `WorldGraph → OccWorld` retraining +pipeline — can use in place of the original. Two bodies of evidence shaped the +decision: + +### 1. The SOTA literature (graph condensation, 2022–2026) + +The published field — GCond (gradient matching), DosCond (one-step), GCDM +(distribution matching), SFGC (structure-free trajectory matching), SGDD +(graphon/Laplacian-Energy-Distribution), GEOM (curriculum trajectories), GC-SNTK +(kernel ridge regression), GDEM (eigenbasis), DisCo (disentangled, scales to +111M nodes) — defines **condensation** as *synthesising a small fake graph* by +optimising a bi-level learning objective so a GNN trained on the synthetic graph +matches one trained on the original. That paradigm is: + +- **Expensive** — bi-level optimisation, often second-order, hard to scale past + ~1M nodes; "lossless" results (GEOM) need 1–5% ratios and banks of expert + trajectories. +- **Supervised** — requires labels `Y'`. +- **Provenance-destroying** — a condensed node is synthetic; the mapping back to + real nodes is intentionally discarded. This breaks audit, explainability, and + link-back. + +The surveys (arXiv:2401.11720, IJCAI'24 arXiv:2402.03358) and benchmarks (GC4NC, +GC-Bench) explicitly flag as **under-explored or unpublished**: community +detection (not k-means) as a structural prior, min-cut/modularity objectives in +the condensation loss, condensation of temporal/streaming graphs, and +condensation co-designed for edge deployment. The closest training-free analogs +are CGC (clustering, 2025) and GCTD (tensor decomposition, 2025). + +### 2. The RuVector / RuView substrate + +`ruvector-mincut` already ships the relevant primitives with default features: +`DynamicGraph` (streaming insert/delete/update), `CommunityDetector` and +`GraphPartitioner` (recursive global min cut), `ClusterHierarchy`, and an exact +`MinCutBuilder`. RuView (ruvnet/RuView) consumes RuVector's mincut/HNSW/GNN/RVF +primitives and records `WorldGraph` JSON snapshots that feed an OccWorld +world-model retrainer — but has **no graph condensation anywhere**, giving this +work a concrete downstream consumer. + +## Decision + +Add a new crate, `ruvector-graph-condense`, implementing **training-free, +structure-preserving, provenance-retaining** graph condensation built on +`ruvector-mincut`. Concretely this is closer to **coarsening with synthetic +representatives** than to GCond-style condensation, and we say so plainly: + +- Partition the graph into structural **regions**. +- Collapse each region to a `CondensedNode { centroid, weight, + class_distribution, coherence, representative (medoid), members }`. `members` + is retained — the original↔condensed mapping survives. +- Rebuild **super-edges** from the *original* graph's boundary edges, so the + condensed topology reproduces the source cut structure by construction rather + than by training. + +### Region-detection methods (`CondenseMethod`) + +| Method | Mechanism | Reduction | Cost | When to use | +|---|---|---|---|---| +| **WeakBoundary** (default) | remove edges `< rel·mean_weight`, then union-find connected components | reliable when weights have contrast | linear (single pass) | general default; weighted graphs | +| MinCutCommunity | `ruvector_mincut::CommunityDetector` (recursive global min cut) | graph-dependent | **super-linear** | dense clusters + sharp bottlenecks only | +| Partition | `ruvector_mincut::GraphPartitioner` bisection | best-effort | super-linear | fixed region budget on clustered graphs | +| ConnectedComponents | components only | structural | linear | baseline / pre-separated graphs | +| DiffMinCut | trained soft assignment (see ADR-197) | `K`-bounded | iterative GD | learned cut-preserving regions | + +The **default is `WeakBoundary`** because of an empirical finding during +implementation: recursive *global* min cut (`CommunityDetector`/`GraphPartitioner`) +**degenerates to singleton-peeling** — it shaves off the single lowest-degree +boundary vertex each step — on graphs without sharp bottlenecks, giving ~N +regions and zero reduction. This is the classic reason the community-detection +literature uses modularity/conductance, not raw min cut. We keep the engine +methods available (they *are* the literal min-cut-engine integration and work on +clearly-bottlenecked graphs) but document the degeneracy and do not default to +them. + +### Quality metrics (retrain-free) + +`metrics::evaluate` returns node/edge reduction ratios, `intra_weight_ratio` +(fraction of edge weight kept inside regions), mean `coherence`, and weighted +`label_purity`. `metrics::cut_inflation` (opt-in, solves an exact min cut on both +graphs) reports `mincut(condensed)/mincut(source)`: `1.0` means the source's +global min cut survives coarsening exactly. + +### Streaming + +`StreamingCondenser` buffers edges/features into a `DynamicGraph` and +re-condenses lazily (on dirty read or every `rebuild_interval` mutations). This +is **lazy re-condensation, not sublinear incremental region surgery** — an +honest amortisation for growing graphs (e.g. a WorldGraph as it accumulates), +with true incremental updates left as future work. + +## Consequences + +**Positive** +- Fast: `WeakBoundary` condenses ~2048 nodes in ~4 ms (benchmarked); linear scaling. +- Deterministic, label-optional, dependency-light (only `ruvector-mincut` + serde/rand/thiserror). +- Interpretable: every super-node carries its `members` and a `coherence` score. +- Cuts preserved by construction; `cut_inflation` quantifies fidelity. +- Reuses the existing min-cut engine rather than reimplementing graph algorithms. + +**Negative / limitations** +- This is *not* accuracy-matched GCond-style condensation; it trades peak + downstream GNN accuracy for speed, determinism, and provenance. We do not + claim accuracy retention numbers — no GNN-retrain evaluation is in scope. +- Engine methods (MinCutCommunity/Partition) are super-linear (~24 s at 96 nodes, + measured) and prone to peeling; usable only on small/well-structured graphs. +- `WeakBoundary` needs weight contrast; on near-uniform weights it degrades to + ConnectedComponents (documented). +- Every graph vertex must have a feature vector, or condensation errors + (`MissingFeature`). + +## Alternatives considered + +1. **Implement GCond/SFGC-style learned condensation.** Rejected for v1: + requires an autodiff stack and GNN training loop, is expensive, supervised, + and destroys provenance. (ADR-197 adds the differentiable *min-cut* angle, + which is the novel, lighter-weight slice of this.) +2. **Put condensation inside `ruvector-mincut` or `ruvector-graph`.** Rejected: + condensation is a distinct bounded context with its own data model; the + workspace convention is one crate per capability. +3. **Default to an engine method (MinCutCommunity/Partition).** Rejected after + benchmarks showed singleton-peeling and super-linear cost. + +## References + +- Surveys: arXiv:2401.11720 (Graph Condensation: A Survey), arXiv:2402.03358 + (Graph Reduction, IJCAI'24); benchmarks GC4NC (arXiv:2406.16715), GC-Bench + (arXiv:2407.00615). +- Methods: GCond (ICLR'22), SFGC (NeurIPS'23), SGDD (NeurIPS'23), GEOM (ICML'24), + GDEM (ICML'24), DisCo (2024), CGC (2025), GCTD (WSDM'26). +- Substrate: `ruvector-mincut` (`DynamicGraph`, `CommunityDetector`, + `GraphPartitioner`, `MinCutBuilder`); RuView (github.com/ruvnet/RuView). +- Example: `crates/ruvector-graph-condense/examples/worldgraph.rs` — a RuView + `WorldGraph → condense → OccWorld` demo (600 observations → 12 event + summaries at 50× reduction, 100% activity purity, cut preserved). +- **Accuracy validation** (`gnn_eval` module + `examples/accuracy_eval.rs` + + `tests/accuracy.rs`): a gradient-checked 2-layer GCN runs the field's standard + protocol (train on condensed, test on original held-out nodes). On a controlled + unweighted node-classification task, `DiffMinCut` condensing 360 → 18 nodes + (20×) reaches **100% accuracy retention**. Honest scope: controlled synthetic + data, not Cora/Citeseer; `WeakBoundary` needs weight contrast (it collapses on + uniform-weight graphs, which is why the accuracy path uses `DiffMinCut`). +- **WASM deployment**: `crates/ruvector-graph-condense-wasm` exposes the + condenser to JS/browser/edge (`wasm32-unknown-unknown`, 667 KB release before + wasm-opt). The `parallel` (Rayon) feature is default-on for native and off for + wasm (no threads). +- ADR-197 (differentiable min-cut loss). diff --git a/docs/adr/ADR-197-differentiable-min-cut-condensation-loss.md b/docs/adr/ADR-197-differentiable-min-cut-condensation-loss.md new file mode 100644 index 000000000..d3bd94394 --- /dev/null +++ b/docs/adr/ADR-197-differentiable-min-cut-condensation-loss.md @@ -0,0 +1,180 @@ +--- +adr: 197 +title: "Differentiable Min-Cut Condensation Loss (diffcut)" +status: accepted +date: 2026-06-07 +authors: [ruvnet, claude] +related: [ADR-196] +tags: [graph, condensation, min-cut, normalized-cut, mincutpool, differentiable, gnn] +--- + +# ADR-197 — Differentiable Min-Cut Condensation Loss + +## Status + +**Accepted (implemented).** Module `crates/ruvector-graph-condense/src/diffcut.rs` +plus `CondenseMethod::DiffMinCut`. Builds on ADR-196. + +## Context + +ADR-196 condenses graphs by *detecting* regions (weak-boundary components, +recursive min cut, etc.) and collapsing them. The graph-condensation surveys +(arXiv:2401.11720, arXiv:2402.03358) and our own SOTA review identified a +specific, **genuinely unpublished gap**: while spectral structural terms appear +in condensation losses — SGDD's Laplacian Energy Distribution (optimal transport +on the spectrum), GDEM's eigenbasis/eigenvalue matching — there is **no +published graph-condensation method whose loss is an explicit, differentiable +min-cut / normalized-cut / modularity term**. Min-cut objectives are mature in +GNN *pooling* (MinCutPool, Bianchi et al. 2020) and in *coarsening*, but using a +relaxed-min-cut objective as the condensation mechanism itself is open. + +We want region structure that is **trained to preserve the cut**, not just +heuristically detected — without taking on the cost/complexity of a full +GCond-style bi-level GNN-gradient-matching pipeline, and without adding a heavy +autodiff dependency to a Rust crate that currently depends only on +`ruvector-mincut` + serde/rand/thiserror. + +## Decision + +Implement a self-contained **differentiable relaxed-min-cut condenser** with +**analytic gradients** (no autodiff framework), after MinCutPool. + +### Objective + +For a soft cluster assignment `S ∈ R^{N×K}` (row-softmax of learned logits `Θ`), +weighted adjacency `A`, and degree matrix `D = diag(A·1)`: + +``` +L_cut = - Tr(Sᵀ A S) / Tr(Sᵀ D S) ∈ [-1, 0] (relaxed normalized cut) +L_ortho = ‖ SᵀS / ‖SᵀS‖_F − I_K / √K ‖_F ∈ [0, 2] (anti-collapse / balance) +L = L_cut + λ · L_ortho +``` + +`L_cut` rewards heavy edges inside clusters; `L_ortho` prevents the degenerate +"all nodes in one cluster" solution (which by itself drives `L_cut → -1`). + +### Gradients (analytic, all maths in `f64`) + +- `∂L_cut/∂S = -(2/Tr(SᵀDS)) · (A S + L_cut · D S)` +- `∂L_ortho/∂S = 2 · S · G_P`, where with `P = SᵀS`, `N_P = ‖P‖_F`, + `Q = P/N_P − I/√K`, `Gf = Q/L_ortho`: + `G_P = Gf/N_P − (⟨Gf, P⟩_F / N_P³) · P` +- Backprop through row-softmax: `∂L/∂Θ_il = S_il · (gS_il − Σ_k gS_ik S_ik)` + +`A S` is computed sparsely from the edge list (`O(nnz · K)` per step); the rest +is `O(N·K + K²)`. The loss + analytic gradients live in `cutloss.rs`; the +optimiser and orchestration in `diffcut.rs`. + +### Optimisation (the part that makes large K work) + +Plain gradient descent stalls at large `K` (a known property of MinCutPool-style +objectives). Three standard ingredients fix it, all defaults: + +1. **Adam** (`Optimizer::Adam`, default) — adaptive per-parameter moments; far + more robust than SGD on the ill-conditioned, non-convex cut objective. + `Optimizer::Sgd { momentum }` remains available. +2. **Warm-start init** (`InitStrategy::WarmStart`, default) — seed the logits + from the cheap `WeakBoundary` structural prior (largest regions → own + clusters, overflow round-robin, +bias into the logits) and *refine* with the + differentiable objective, instead of descending from random noise. This is + the coreset/K-Center idea GCond/SFGC use, and it is what makes `K ≫ 2` + converge. `InitStrategy::Random` remains available. +3. **Restarts** (`restarts`) — keep the lowest-loss run. + +Result: on a 12-event WorldGraph (`examples/worldgraph.rs`) DiffMinCut reaches +**100% activity purity, cut preserved (inflation 1.000)** — matching +`WeakBoundary` — where plain-GD/random scored ~30%. Training cost fell from +~24 s (plain GD, 96 nodes) to milliseconds (Adam, `condense_diffcut` bench: +~0.96 ms @ 64, ~6.4 ms @ 192 nodes). Tests `warm_start_recovers_many_clusters` +(K=8, purity > 0.85) and `warm_start_beats_random_at_large_k` lock this in. + +### Scale levers (for large / million-node graphs) + +Three further levers, off by default, target very large graphs: + +4. **Early-stopping** (`tolerance`, default `1e-6`) — warm-start lands near the + optimum, so most iterations are wasted; stop when the loss plateaus. Test + `early_stopping_cuts_iterations`. +5. **Parallelism** (`parallel`, Rayon) — the per-iteration `A·S` (CSR, + row-parallel) and the `O(N·K²)` `SᵀS` + ortho-gradient loops run in parallel. + **Deterministic / bit-identical to sequential** (both use the same chunked + partial-sum ordering), proven by `parallel_matches_sequential_exactly`. +6. **Edge-minibatching** (`minibatch_edges`) — estimate the gradient from a + sampled subset of edges per step (`O(batch·K)` instead of `O(|E|·K)`); the + final reported loss is still computed full-batch (exact). Test + `minibatch_recovers_structure`. + +Bench (`condense_diffcut_levers`, 1024 nodes, 4 cores, 100 iters): sequential +~95 ms, parallel ~83 ms (~1.15×), minibatch(2048) ~77 ms (~1.2×). Gains are +modest at this size (Rayon dispatch overhead vs. small per-iteration work) and +grow with `N`; the value is enabling graphs that do not fit a single-threaded +full-batch budget, not speeding up small ones. + +### Correctness + +The analytic `∂L/∂Θ` is verified against **central finite differences** in +`gradient_matches_finite_differences` across **K = 2, 3, 4** (max abs error +`< 1e-5`) — the decisive test, proving the K-general formulas, not just K=2. + +### API and integration + +- `DiffCutConfig { num_clusters K, ortho_weight λ, learning_rate, iterations, + optimizer, init, restarts, tolerance, parallel, minibatch_edges, seed }`; + `DiffCutCondenser::train(&DynamicGraph) -> DiffCutResult`. Default = Adam + + warm-start + early-stop, large-K-ready. `DiffCutResult::iterations_run()` + reports how many iterations actually ran. +- `DiffCutResult::soft_assignment()` (the `N×K` matrix) and `hard_regions()` + (argmax grouping → `Vec>`). +- `min_cut_loss(graph, soft, k, λ)` — public, evaluates the loss for any + assignment (a quality metric for learned or hand-built assignments). +- Wired in as `CondenseMethod::DiffMinCut(DiffCutConfig)`: train the soft + assignment, harden to regions via argmax, then flow through ADR-196's existing + provenance-preserving super-node/super-edge construction. It is the only region + method whose structure is *trained* to preserve the cut. + +Vertices are sorted ascending for a deterministic row order; logit init is +seeded — same seed ⇒ identical result (tested). + +## Consequences + +**Positive** +- Fills the specific open gap: a differentiable min-cut term as the condensation + mechanism, integrated end-to-end and provenance-preserving. +- No new heavy dependency (no candle/burn/tch); pure Rust `f64` maths. +- Gradient-checked, deterministic, label-free (uses topology only; features are + applied later for centroids). +- Recovers planted structure at small *and* large K (barbell exactly; K=8/K=12 + recovered via Adam + warm-start), and drives the cut term toward −1. +- Fast: milliseconds per train (was tens of seconds under plain GD). + +**Negative / limitations** +- `K` (cluster count) is a fixed hyperparameter; empty clusters are dropped but + `K` must be chosen. +- Still slower than `WeakBoundary` (`O(restarts · iterations · nnz · K)`) and + non-convex with no formal convergence guarantee, so it is opt-in, not the + default. Large-K reliability leans on the warm-start prior; `InitStrategy:: + Random` at large K remains hard (documented, and what `warm_start_beats_random` + measures). `WeakBoundary` stays the default (ADR-196) for speed/simplicity. +- Topology-only objective: it optimises the structural cut, not feature/label + matching, so it is not a substitute for supervised GCond-style accuracy + matching. + +## Alternatives considered + +1. **Add an autodiff backend (candle/tch/burn) and a learned GNN condenser.** + Rejected: heavy dependency and build cost for a structural objective whose + gradients are short closed forms. +2. **Spectral objective (SGDD LED / GDEM eigenbasis) instead of min cut.** + Rejected for this ADR: those are already published; the min-cut term is the + unaddressed gap. (A spectral term remains possible future work.) +3. **Only expose the loss as a metric (no training).** Rejected: the request and + the novelty are the *trainable* loss; we expose both the metric + (`min_cut_loss`) and the optimiser (`DiffCutCondenser`). + +## References + +- Bianchi, Grattarola, Alippi — "Spectral Clustering with GNNs for Graph + Pooling" (MinCutPool), ICML 2020. +- SGDD (arXiv:2310.09192), GDEM (arXiv:2310.09202) — spectral condensation terms. +- Surveys: arXiv:2401.11720, arXiv:2402.03358 (open-problem framing). +- ADR-196 (structure-preserving graph condensation; method taxonomy & substrate). diff --git a/docs/adr/ADR-198-physical-perception-substrate.md b/docs/adr/ADR-198-physical-perception-substrate.md new file mode 100644 index 000000000..422bcea06 --- /dev/null +++ b/docs/adr/ADR-198-physical-perception-substrate.md @@ -0,0 +1,151 @@ +--- +adr: 198 +title: "Physical Perception Substrate — delta → boundary → coherence → proof → action" +status: accepted +date: 2026-06-08 +authors: [ruvnet, claude] +related: [ADR-196, ADR-197] +tags: [perception, sensing, coherence, min-cut, proof-gate, edge-ai, csi, ruview] +--- + +# ADR-198 — Physical Perception Substrate + +## Status + +**Accepted (initial vertical slice implemented).** Crate +`crates/ruvector-perception`. + +## Context + +WiFi/edge sensing SOTA is converging on better **classifiers**: CSI foundation +models, self-supervised CSI representations (CSI-JEPA-style), adaptive near-sensor +fusion (FusionSense-style), and dynamic-graph anomaly detection (which still +flags interpretability + scalability as open). All answer *"what is this?"* and +emit *confidence → alert*. + +The wedge is not a better classifier. It is the **layer underneath** one: a +trusted-physical-memory engine that answers *"what changed, where did the +boundary move, and is the change coherent enough to act on?"* and requires +**evidence, not confidence**, before exercising any authority. This reframes the +pipeline: + +``` +classification → confidence → alert (today) +delta → boundary → coherence → proof → action (this ADR) +``` + +It also removes the dependence on a fixed task label (fall / gesture / occupancy +/ leak / bearing-failure): it models **state transition itself**. + +## Decision + +Implement the pipeline as a standalone crate built on the dynamic min-cut engine. + +1. **Delta** (`state`, `engine`) — every reading becomes a delta against a + rolling per-(zone, modality) baseline (EWMA), plus a learned *responsiveness* + (how often that channel reacts in that zone). +2. **Boundary** (`coherence`) — zones are nodes in a coherence graph (edge weight + = delta-pattern agreement). Dynamic min-cut (`ruvector-mincut`) isolates the + side that broke away — the moved boundary, not a class. +3. **Contradiction as information** — a modality that *usually* reacts in a zone + but stayed silent is a first-class contradiction, weighted by the modality's + physical **spoof-resistance** (modalities are physically typed: RF ≠ thermal). + This is what flags an inert object-move (RF/vibration/acoustic respond, + thermal — which would respond to an animate source — does not). +4. **Proof** (`witness`) — a proof gate maps (novelty, coherence, contradiction) + to **bounded authority** `Ignore → Observe → Alert → Mutate`, and emits an + auditable SHA-256 evidence chain (raw hash, feature hash, scores, boundary, + policy, prior-witness hash). Contradicted evidence is **capped at Observe** — + it never escalates on confidence alone. +5. **Absence** (`absence`) — a *missing* expected continuation (e.g. + `bed_exit → bathroom_path → return_path` where the return never arrives) is + detected as structural incompleteness, a safety signal, not a threshold. + +The headline output is a `DeltaWitness` (changed_boundary, supporting / +contradicting modalities, novelty, coherence, contradiction, action, +evidence_hash, prev_hash) — a structured delta, not a label. + +## Consequences + +**Positive** +- Task-label-free: detects unknown physical changes without retraining. +- Auditable: every action is backed by a replayable evidence chain (matters for + elder care / industrial / civic / medical governance). +- Interpretable localisation: min-cut says *where* coherence broke and *why* + (which modalities support vs contradict) — addressing the open + interpretability gap in dynamic-graph anomaly work. +- Reuses existing min-cut machinery; small, dependency-light, `#![forbid(unsafe_code)]`. + +**Negative / honest scope** +- This is the **mechanism**, demonstrated on **synthetic** multi-modal deltas — + not validated on real CSI/hardware, and not benchmarked against CSI-JEPA / + FusionSense (different layer). No accuracy claims. +- Novelty (nearest-prior distance), contradiction (responsive-but-silent), and + coherence (cut cleanliness) are principled **heuristics**, not learned. +- Single-window; no temporal model of the delta beyond EWMA baselines and the + absence-sequence monitor. Boundary detection is O(zones²) edges + exact min cut + (fine for rooms/facilities, not yet city-scale). + +## Capability modules (built on the substrate) + +Five further beyond-classification capabilities from the brief are implemented as +self-contained modules (each emits structure, not a label): + +- **`captcha`** — Physical CAPTCHA: a learned per-stimulus multi-modal + challenge-response profile; a fresh response is verified within delay/magnitude + tolerance, weighted by spoof-resistance, yielding a `RealityProof`. Detects + replay/spoof (proof-of-real-physical-field). +- **`predict`** — Boundary-first world model: forecasts *where coherence breaks + next* (`instability = coherence·(1+contradiction)`, level + least-squares + trend) rather than full future states. +- **`identity`** — Resonant identity / continuity: per-object EWMA signature; + cosine-distance drift detection answers "is this still the same physical + thing?" (panel loosened, bearing worn, casing tampered). +- **`hypothesis`** — Multi-modal disagreement engine: contradictions produce + *ranked hypotheses* (RealEvent / SensorDrift / SensorRelocation / + AdversarialReplay / EnvironmentalArtifact), not forced agreement. +- **`topology`** — Self-healing sensor topology: an EWMA agreement graph + classifies each sensor Critical / Redundant / Noisy / Normal; Critical = + articulation point (removal fragments the graph — the extreme single-edge cut). +- **`swarm`** — Facility/swarm-scale fragility: rooms/machines/routers as a + coupling graph; global min-cut answers "where is the system structurally + closest to breaking?" Bottlenecks are derived from the weakest link (edge + weights), because the engine's min-cut *value* is reliable but its *partition* + is not. +- **`custody`** — Sensor chain of custody: a tamper-evident, replayable ledger + of witnesses (chain-linkage verification over the SHA-256 evidence hashes; + honest scope — link integrity, not raw-signal re-hash). +- **`reality`** — Reality-graph agent grounding: an agent *queries reality* + (presence / changed-since / which-untrusted / action-allowed) and gets answers + **backed by witness evidence hashes**, not prompt inference. +- **`node`** — `NervousSystemNode`: the appliance facade wiring engine + reality + graph + custody ledger + boundary forecaster. Ingests readings, emits + deltas/boundaries/coherence/witnesses/forecasts (never raw signal), and answers + grounded queries. + +## Future work (from the brief, not yet built) + +The remaining items are out of pure-software scope: the physical "ambient +nervous system" **hardware** node, and replacing the heuristic scorers +(novelty / contradiction / coherence) with **learned** models validated on real +CSI. Everything above is a mechanism demonstration on synthetic signals. + +Known limitation surfaced during testing: coherence boundary detection is +ambiguous with exactly **two** zones (a single-edge min cut splits symmetrically; +the minority side is arbitrary). Use ≥3 zones for a well-defined changed +boundary — documented and reflected in the tests. + +## Validation + +59 tests (54 unit + 2 integration + 3 doctest), deterministic across repeated +runs. Highlights: the brief's exact flagship scenario (inert object move → +RF/vibration/acoustic support, thermal contradicts, novelty high, action = +observe); the missing-routine-return absence signal; physical-CAPTCHA replay +rejection; boundary forecast of a destabilising zone; identity drift on a +tampered signature; ranked hypotheses (RealEvent / SensorDrift / AdversarialReplay +first under the right evidence); topology roles (bridge → Critical, near-duplicate +→ Redundant, lone-disagreer → Noisy); facility fragility (weakest link found); +custody chain verify + tamper detection; reality-graph grounded queries; and the +end-to-end `NervousSystemNode` (witness chain + grounded query). Built across two +parallel agent swarms, then integrated and validated. clippy clean; all source +files < 500 lines.