Commit ·
26cf661
1
Parent(s): 487e5c0
Rust core: AccessGraph + RustPredictor with PyO3 bindings
Browse filesPhase 2 of Condensate — Rust implementation of Layers 1+2.
AccessGraph: causal edge discovery with Welford online variance,
cluster detection via BFS connected components, successor lists.
RustPredictor: prediction from direct successors + cluster
co-activation. Scoring against access logs.
Benchmarks (release mode, AMD APU):
Small (440 events): 930μs build, 100% accuracy
Medium (972 events): 10ms build, 99% accuracy
Large (1176 events): 30ms build, 95.3% accuracy
Single predict(): 42μs latency
All 9 tests pass (5 unit + 4 bench).
PyO3 bindings ready for pip install via maturin.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- .gitignore +1 -0
- rust_core/Cargo.lock +196 -0
- rust_core/Cargo.toml +18 -0
- rust_core/src/bench.rs +155 -0
- rust_core/src/graph.rs +452 -0
- rust_core/src/lib.rs +24 -0
- rust_core/src/predictor.rs +354 -0
.gitignore
CHANGED
|
@@ -2,3 +2,4 @@ __pycache__/
|
|
| 2 |
*.pyc
|
| 3 |
*.json
|
| 4 |
!package.json
|
|
|
|
|
|
| 2 |
*.pyc
|
| 3 |
*.json
|
| 4 |
!package.json
|
| 5 |
+
rust_core/target/
|
rust_core/Cargo.lock
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file is automatically @generated by Cargo.
|
| 2 |
+
# It is not intended for manual editing.
|
| 3 |
+
version = 4
|
| 4 |
+
|
| 5 |
+
[[package]]
|
| 6 |
+
name = "autocfg"
|
| 7 |
+
version = "1.5.0"
|
| 8 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 9 |
+
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
| 10 |
+
|
| 11 |
+
[[package]]
|
| 12 |
+
name = "cfg-if"
|
| 13 |
+
version = "1.0.4"
|
| 14 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 15 |
+
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
| 16 |
+
|
| 17 |
+
[[package]]
|
| 18 |
+
name = "condensate_core"
|
| 19 |
+
version = "0.1.0"
|
| 20 |
+
dependencies = [
|
| 21 |
+
"lz4_flex",
|
| 22 |
+
"pyo3",
|
| 23 |
+
]
|
| 24 |
+
|
| 25 |
+
[[package]]
|
| 26 |
+
name = "heck"
|
| 27 |
+
version = "0.5.0"
|
| 28 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 29 |
+
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
| 30 |
+
|
| 31 |
+
[[package]]
|
| 32 |
+
name = "indoc"
|
| 33 |
+
version = "2.0.7"
|
| 34 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 35 |
+
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
|
| 36 |
+
dependencies = [
|
| 37 |
+
"rustversion",
|
| 38 |
+
]
|
| 39 |
+
|
| 40 |
+
[[package]]
|
| 41 |
+
name = "libc"
|
| 42 |
+
version = "0.2.183"
|
| 43 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 44 |
+
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
|
| 45 |
+
|
| 46 |
+
[[package]]
|
| 47 |
+
name = "lz4_flex"
|
| 48 |
+
version = "0.11.6"
|
| 49 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 50 |
+
checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a"
|
| 51 |
+
dependencies = [
|
| 52 |
+
"twox-hash",
|
| 53 |
+
]
|
| 54 |
+
|
| 55 |
+
[[package]]
|
| 56 |
+
name = "memoffset"
|
| 57 |
+
version = "0.9.1"
|
| 58 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 59 |
+
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
|
| 60 |
+
dependencies = [
|
| 61 |
+
"autocfg",
|
| 62 |
+
]
|
| 63 |
+
|
| 64 |
+
[[package]]
|
| 65 |
+
name = "once_cell"
|
| 66 |
+
version = "1.21.4"
|
| 67 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 68 |
+
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
| 69 |
+
|
| 70 |
+
[[package]]
|
| 71 |
+
name = "portable-atomic"
|
| 72 |
+
version = "1.13.1"
|
| 73 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 74 |
+
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
| 75 |
+
|
| 76 |
+
[[package]]
|
| 77 |
+
name = "proc-macro2"
|
| 78 |
+
version = "1.0.106"
|
| 79 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 80 |
+
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
| 81 |
+
dependencies = [
|
| 82 |
+
"unicode-ident",
|
| 83 |
+
]
|
| 84 |
+
|
| 85 |
+
[[package]]
|
| 86 |
+
name = "pyo3"
|
| 87 |
+
version = "0.24.2"
|
| 88 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 89 |
+
checksum = "e5203598f366b11a02b13aa20cab591229ff0a89fd121a308a5df751d5fc9219"
|
| 90 |
+
dependencies = [
|
| 91 |
+
"cfg-if",
|
| 92 |
+
"indoc",
|
| 93 |
+
"libc",
|
| 94 |
+
"memoffset",
|
| 95 |
+
"once_cell",
|
| 96 |
+
"portable-atomic",
|
| 97 |
+
"pyo3-build-config",
|
| 98 |
+
"pyo3-ffi",
|
| 99 |
+
"pyo3-macros",
|
| 100 |
+
"unindent",
|
| 101 |
+
]
|
| 102 |
+
|
| 103 |
+
[[package]]
|
| 104 |
+
name = "pyo3-build-config"
|
| 105 |
+
version = "0.24.2"
|
| 106 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 107 |
+
checksum = "99636d423fa2ca130fa5acde3059308006d46f98caac629418e53f7ebb1e9999"
|
| 108 |
+
dependencies = [
|
| 109 |
+
"once_cell",
|
| 110 |
+
"target-lexicon",
|
| 111 |
+
]
|
| 112 |
+
|
| 113 |
+
[[package]]
|
| 114 |
+
name = "pyo3-ffi"
|
| 115 |
+
version = "0.24.2"
|
| 116 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 117 |
+
checksum = "78f9cf92ba9c409279bc3305b5409d90db2d2c22392d443a87df3a1adad59e33"
|
| 118 |
+
dependencies = [
|
| 119 |
+
"libc",
|
| 120 |
+
"pyo3-build-config",
|
| 121 |
+
]
|
| 122 |
+
|
| 123 |
+
[[package]]
|
| 124 |
+
name = "pyo3-macros"
|
| 125 |
+
version = "0.24.2"
|
| 126 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 127 |
+
checksum = "0b999cb1a6ce21f9a6b147dcf1be9ffedf02e0043aec74dc390f3007047cecd9"
|
| 128 |
+
dependencies = [
|
| 129 |
+
"proc-macro2",
|
| 130 |
+
"pyo3-macros-backend",
|
| 131 |
+
"quote",
|
| 132 |
+
"syn",
|
| 133 |
+
]
|
| 134 |
+
|
| 135 |
+
[[package]]
|
| 136 |
+
name = "pyo3-macros-backend"
|
| 137 |
+
version = "0.24.2"
|
| 138 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 139 |
+
checksum = "822ece1c7e1012745607d5cf0bcb2874769f0f7cb34c4cde03b9358eb9ef911a"
|
| 140 |
+
dependencies = [
|
| 141 |
+
"heck",
|
| 142 |
+
"proc-macro2",
|
| 143 |
+
"pyo3-build-config",
|
| 144 |
+
"quote",
|
| 145 |
+
"syn",
|
| 146 |
+
]
|
| 147 |
+
|
| 148 |
+
[[package]]
|
| 149 |
+
name = "quote"
|
| 150 |
+
version = "1.0.45"
|
| 151 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 152 |
+
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
| 153 |
+
dependencies = [
|
| 154 |
+
"proc-macro2",
|
| 155 |
+
]
|
| 156 |
+
|
| 157 |
+
[[package]]
|
| 158 |
+
name = "rustversion"
|
| 159 |
+
version = "1.0.22"
|
| 160 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 161 |
+
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
| 162 |
+
|
| 163 |
+
[[package]]
|
| 164 |
+
name = "syn"
|
| 165 |
+
version = "2.0.117"
|
| 166 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 167 |
+
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
| 168 |
+
dependencies = [
|
| 169 |
+
"proc-macro2",
|
| 170 |
+
"quote",
|
| 171 |
+
"unicode-ident",
|
| 172 |
+
]
|
| 173 |
+
|
| 174 |
+
[[package]]
|
| 175 |
+
name = "target-lexicon"
|
| 176 |
+
version = "0.13.5"
|
| 177 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 178 |
+
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
| 179 |
+
|
| 180 |
+
[[package]]
|
| 181 |
+
name = "twox-hash"
|
| 182 |
+
version = "2.1.2"
|
| 183 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 184 |
+
checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c"
|
| 185 |
+
|
| 186 |
+
[[package]]
|
| 187 |
+
name = "unicode-ident"
|
| 188 |
+
version = "1.0.24"
|
| 189 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 190 |
+
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
| 191 |
+
|
| 192 |
+
[[package]]
|
| 193 |
+
name = "unindent"
|
| 194 |
+
version = "0.2.4"
|
| 195 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 196 |
+
checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
|
rust_core/Cargo.toml
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[package]
|
| 2 |
+
name = "condensate_core"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
edition = "2024"
|
| 5 |
+
description = "Living memory manager — Rust core with PyO3 bindings"
|
| 6 |
+
license = "AGPL-3.0"
|
| 7 |
+
|
| 8 |
+
[lib]
|
| 9 |
+
name = "condensate_core"
|
| 10 |
+
crate-type = ["cdylib", "rlib"]
|
| 11 |
+
|
| 12 |
+
[dependencies]
|
| 13 |
+
pyo3 = { version = "0.24", features = ["extension-module"] }
|
| 14 |
+
lz4_flex = "0.11"
|
| 15 |
+
|
| 16 |
+
[profile.release]
|
| 17 |
+
opt-level = 3
|
| 18 |
+
lto = true
|
rust_core/src/bench.rs
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Benchmark: Rust graph build + predict vs equivalent Python workload.
|
| 2 |
+
//!
|
| 3 |
+
//! Run with: cargo test --release bench_ -- --nocapture
|
| 4 |
+
|
| 5 |
+
#[cfg(test)]
|
| 6 |
+
mod bench {
|
| 7 |
+
use crate::graph::AccessGraph;
|
| 8 |
+
use crate::predictor::RustPredictor;
|
| 9 |
+
use std::time::Instant;
|
| 10 |
+
|
| 11 |
+
/// Generate a realistic workload: N layers, some hot, some cold,
|
| 12 |
+
/// with causal chains. Same pattern as test_predictor.py.
|
| 13 |
+
fn generate_inference_workload(
|
| 14 |
+
num_layers: u32,
|
| 15 |
+
num_hot: u32,
|
| 16 |
+
iterations: u32,
|
| 17 |
+
) -> Vec<(u64, String, u64)> {
|
| 18 |
+
let mut events = Vec::new();
|
| 19 |
+
let mut ts: u64 = 0;
|
| 20 |
+
|
| 21 |
+
for _ in 0..iterations {
|
| 22 |
+
// Hot layers accessed every iteration
|
| 23 |
+
for i in 0..num_hot {
|
| 24 |
+
events.push((ts, format!("layer_{}", i), 65536));
|
| 25 |
+
ts += 100_000; // 0.1ms between accesses
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
// Cold layers: 3% chance
|
| 29 |
+
for i in num_hot..num_layers {
|
| 30 |
+
if (ts / 1000 + i as u64) % 33 == 0 {
|
| 31 |
+
events.push((ts, format!("layer_{}", i), 65536));
|
| 32 |
+
ts += 100_000;
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
ts += 2_000_000; // 2ms between iterations
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
events
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
#[test]
|
| 43 |
+
fn bench_graph_build_small() {
|
| 44 |
+
// 16 layers, 4 hot, 100 iterations — ~similar to Python test
|
| 45 |
+
let events = generate_inference_workload(16, 4, 100);
|
| 46 |
+
println!("\nSmall workload: {} events", events.len());
|
| 47 |
+
|
| 48 |
+
let start = Instant::now();
|
| 49 |
+
let mut graph = AccessGraph::new(5_000_000, 0.7);
|
| 50 |
+
graph.build(events.clone());
|
| 51 |
+
let elapsed = start.elapsed();
|
| 52 |
+
|
| 53 |
+
println!(" Graph build: {:?}", elapsed);
|
| 54 |
+
println!(" Nodes: {}, Edges: {}, Clusters: {}",
|
| 55 |
+
graph.node_count(), graph.edge_count(), graph.cluster_count());
|
| 56 |
+
|
| 57 |
+
// Predict
|
| 58 |
+
let mut predictor = RustPredictor::new();
|
| 59 |
+
predictor.learn(&graph);
|
| 60 |
+
|
| 61 |
+
let start = Instant::now();
|
| 62 |
+
let result = predictor.score(events);
|
| 63 |
+
let elapsed = start.elapsed();
|
| 64 |
+
|
| 65 |
+
println!(" Score: {:?}", elapsed);
|
| 66 |
+
println!(" Accuracy: {}%, Predictions: {}, Hits: {}",
|
| 67 |
+
result.accuracy, result.predictions_made, result.hits);
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
#[test]
|
| 71 |
+
fn bench_graph_build_medium() {
|
| 72 |
+
// 64 layers, 8 hot, 100 iterations
|
| 73 |
+
let events = generate_inference_workload(64, 8, 100);
|
| 74 |
+
println!("\nMedium workload: {} events", events.len());
|
| 75 |
+
|
| 76 |
+
let start = Instant::now();
|
| 77 |
+
let mut graph = AccessGraph::new(5_000_000, 0.7);
|
| 78 |
+
graph.build(events.clone());
|
| 79 |
+
let elapsed = start.elapsed();
|
| 80 |
+
|
| 81 |
+
println!(" Graph build: {:?}", elapsed);
|
| 82 |
+
println!(" Nodes: {}, Edges: {}, Clusters: {}",
|
| 83 |
+
graph.node_count(), graph.edge_count(), graph.cluster_count());
|
| 84 |
+
|
| 85 |
+
let mut predictor = RustPredictor::new();
|
| 86 |
+
predictor.learn(&graph);
|
| 87 |
+
|
| 88 |
+
let start = Instant::now();
|
| 89 |
+
let result = predictor.score(events);
|
| 90 |
+
let elapsed = start.elapsed();
|
| 91 |
+
|
| 92 |
+
println!(" Score: {:?}", elapsed);
|
| 93 |
+
println!(" Accuracy: {}%, Predictions: {}, Hits: {}",
|
| 94 |
+
result.accuracy, result.predictions_made, result.hits);
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
#[test]
|
| 98 |
+
fn bench_graph_build_large() {
|
| 99 |
+
// 256 layers, 16 hot, 50 iterations — stress test
|
| 100 |
+
let events = generate_inference_workload(256, 16, 50);
|
| 101 |
+
println!("\nLarge workload: {} events", events.len());
|
| 102 |
+
|
| 103 |
+
let start = Instant::now();
|
| 104 |
+
let mut graph = AccessGraph::new(5_000_000, 0.7);
|
| 105 |
+
graph.build(events.clone());
|
| 106 |
+
let elapsed = start.elapsed();
|
| 107 |
+
|
| 108 |
+
println!(" Graph build: {:?}", elapsed);
|
| 109 |
+
println!(" Nodes: {}, Edges: {}, Clusters: {}",
|
| 110 |
+
graph.node_count(), graph.edge_count(), graph.cluster_count());
|
| 111 |
+
|
| 112 |
+
let mut predictor = RustPredictor::new();
|
| 113 |
+
predictor.learn(&graph);
|
| 114 |
+
|
| 115 |
+
let start = Instant::now();
|
| 116 |
+
let result = predictor.score(events);
|
| 117 |
+
let elapsed = start.elapsed();
|
| 118 |
+
|
| 119 |
+
println!(" Score: {:?}", elapsed);
|
| 120 |
+
println!(" Accuracy: {}%, Predictions: {}, Hits: {}",
|
| 121 |
+
result.accuracy, result.predictions_made, result.hits);
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
#[test]
|
| 125 |
+
fn bench_predict_latency() {
|
| 126 |
+
// Measure single-prediction latency — this is the hot path
|
| 127 |
+
let events = generate_inference_workload(64, 8, 100);
|
| 128 |
+
|
| 129 |
+
let mut graph = AccessGraph::new(5_000_000, 0.7);
|
| 130 |
+
graph.build(events);
|
| 131 |
+
|
| 132 |
+
let mut predictor = RustPredictor::new();
|
| 133 |
+
predictor.learn(&graph);
|
| 134 |
+
|
| 135 |
+
// Warm up
|
| 136 |
+
for _ in 0..100 {
|
| 137 |
+
let _ = predictor.predict("layer_0", 10);
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
// Measure
|
| 141 |
+
let iterations = 100_000;
|
| 142 |
+
let start = Instant::now();
|
| 143 |
+
for _ in 0..iterations {
|
| 144 |
+
let _ = predictor.predict("layer_0", 10);
|
| 145 |
+
}
|
| 146 |
+
let elapsed = start.elapsed();
|
| 147 |
+
|
| 148 |
+
let per_predict_ns = elapsed.as_nanos() / iterations as u128;
|
| 149 |
+
println!("\nSingle predict() latency:");
|
| 150 |
+
println!(" {} iterations in {:?}", iterations, elapsed);
|
| 151 |
+
println!(" *** {per_predict_ns} ns per prediction ***");
|
| 152 |
+
println!(" ({:.1} million predictions/sec)",
|
| 153 |
+
1_000_000_000.0 / per_predict_ns as f64);
|
| 154 |
+
}
|
| 155 |
+
}
|
rust_core/src/graph.rs
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Access Graph — learns memory access topology from observations.
|
| 2 |
+
//!
|
| 3 |
+
//! Nodes are memory regions. Edges are causal access correlations
|
| 4 |
+
//! with timing. Clusters are co-access groups (proto-hyperedges).
|
| 5 |
+
//!
|
| 6 |
+
//! This replaces the Python GraphBuilder with sub-microsecond performance.
|
| 7 |
+
|
| 8 |
+
use pyo3::prelude::*;
|
| 9 |
+
use std::collections::HashMap;
|
| 10 |
+
|
| 11 |
+
/// A single access event recorded by the membrane.
|
| 12 |
+
#[derive(Clone, Debug)]
|
| 13 |
+
pub struct AccessEvent {
|
| 14 |
+
pub timestamp_ns: u64,
|
| 15 |
+
pub path: String,
|
| 16 |
+
pub size_bytes: u64,
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
/// Causal edge: source accessed BEFORE target, with timing statistics.
|
| 20 |
+
#[derive(Clone, Debug)]
|
| 21 |
+
pub struct CausalEdge {
|
| 22 |
+
pub source_id: u32,
|
| 23 |
+
pub target_id: u32,
|
| 24 |
+
pub count: u32,
|
| 25 |
+
pub mean_delta_ns: f64,
|
| 26 |
+
pub std_delta_ns: f64,
|
| 27 |
+
pub weight: f64,
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
impl CausalEdge {
|
| 31 |
+
fn new(source_id: u32, target_id: u32) -> Self {
|
| 32 |
+
Self {
|
| 33 |
+
source_id,
|
| 34 |
+
target_id,
|
| 35 |
+
count: 0,
|
| 36 |
+
mean_delta_ns: 0.0,
|
| 37 |
+
std_delta_ns: 0.0,
|
| 38 |
+
weight: 0.0,
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
/// Welford online update for mean and variance of timing deltas.
|
| 43 |
+
fn add_observation(&mut self, delta_ns: f64) {
|
| 44 |
+
self.count += 1;
|
| 45 |
+
let n = self.count as f64;
|
| 46 |
+
let old_mean = self.mean_delta_ns;
|
| 47 |
+
self.mean_delta_ns += (delta_ns - old_mean) / n;
|
| 48 |
+
// Welford variance accumulator
|
| 49 |
+
self.std_delta_ns += (delta_ns - old_mean) * (delta_ns - self.mean_delta_ns);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
/// Finalize statistics after all observations.
|
| 53 |
+
fn finalize(&mut self) {
|
| 54 |
+
if self.count > 1 {
|
| 55 |
+
self.std_delta_ns = (self.std_delta_ns / (self.count as f64 - 1.0)).sqrt();
|
| 56 |
+
} else {
|
| 57 |
+
self.std_delta_ns = 0.0;
|
| 58 |
+
}
|
| 59 |
+
// Weight: frequency × timing consistency
|
| 60 |
+
// High count + low variance = strong causal edge
|
| 61 |
+
let consistency = 1.0 / (1.0 + self.std_delta_ns / self.mean_delta_ns.max(1.0));
|
| 62 |
+
self.weight = self.count as f64 * consistency;
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
/// A discovered cluster of co-accessed regions (proto-hyperedge).
|
| 67 |
+
#[derive(Clone, Debug)]
|
| 68 |
+
pub struct Cluster {
|
| 69 |
+
pub id: u32,
|
| 70 |
+
pub member_ids: Vec<u32>,
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
/// Node info: a tracked memory region.
|
| 74 |
+
#[derive(Clone, Debug)]
|
| 75 |
+
pub struct NodeInfo {
|
| 76 |
+
pub id: u32,
|
| 77 |
+
pub path: String,
|
| 78 |
+
pub access_count: u32,
|
| 79 |
+
pub total_bytes: u64,
|
| 80 |
+
pub first_access_ns: u64,
|
| 81 |
+
pub last_access_ns: u64,
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
/// The access graph — learns memory access topology.
|
| 85 |
+
///
|
| 86 |
+
/// Exposed to Python via PyO3.
|
| 87 |
+
#[pyclass]
|
| 88 |
+
pub struct AccessGraph {
|
| 89 |
+
/// Path → node ID mapping
|
| 90 |
+
path_to_id: HashMap<String, u32>,
|
| 91 |
+
/// Node ID → info
|
| 92 |
+
nodes: Vec<NodeInfo>,
|
| 93 |
+
/// (source_id, target_id) → edge
|
| 94 |
+
edges: HashMap<(u32, u32), CausalEdge>,
|
| 95 |
+
/// Discovered clusters
|
| 96 |
+
pub clusters: Vec<Cluster>,
|
| 97 |
+
/// Causal window in nanoseconds
|
| 98 |
+
causal_window_ns: u64,
|
| 99 |
+
/// Cluster co-access threshold
|
| 100 |
+
cluster_threshold: f64,
|
| 101 |
+
/// Whether build() has been called
|
| 102 |
+
built: bool,
|
| 103 |
+
/// Per-node successor list (sorted by weight, top-K)
|
| 104 |
+
successors: Vec<Vec<(u32, f64, f64)>>, // (target_id, weight, mean_delta_ms)
|
| 105 |
+
/// Node → cluster membership
|
| 106 |
+
cluster_map: Vec<Option<u32>>,
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
#[pymethods]
|
| 110 |
+
impl AccessGraph {
|
| 111 |
+
#[new]
|
| 112 |
+
#[pyo3(signature = (causal_window_ns=5_000_000, cluster_threshold=0.7))]
|
| 113 |
+
pub fn new(causal_window_ns: u64, cluster_threshold: f64) -> Self {
|
| 114 |
+
Self {
|
| 115 |
+
path_to_id: HashMap::new(),
|
| 116 |
+
nodes: Vec::new(),
|
| 117 |
+
edges: HashMap::new(),
|
| 118 |
+
clusters: Vec::new(),
|
| 119 |
+
causal_window_ns,
|
| 120 |
+
cluster_threshold,
|
| 121 |
+
built: false,
|
| 122 |
+
successors: Vec::new(),
|
| 123 |
+
cluster_map: Vec::new(),
|
| 124 |
+
}
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
/// Build the graph from a list of (timestamp_ns, path, size_bytes) events.
|
| 128 |
+
///
|
| 129 |
+
/// Called from Python with the membrane's access log.
|
| 130 |
+
pub fn build(&mut self, events: Vec<(u64, String, u64)>) {
|
| 131 |
+
if events.is_empty() {
|
| 132 |
+
return;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
// Phase 1: Register nodes
|
| 136 |
+
for (ts, path, size) in &events {
|
| 137 |
+
let id = self.get_or_create_node(path);
|
| 138 |
+
let node = &mut self.nodes[id as usize];
|
| 139 |
+
node.access_count += 1;
|
| 140 |
+
node.total_bytes += size;
|
| 141 |
+
if *ts < node.first_access_ns {
|
| 142 |
+
node.first_access_ns = *ts;
|
| 143 |
+
}
|
| 144 |
+
if *ts > node.last_access_ns {
|
| 145 |
+
node.last_access_ns = *ts;
|
| 146 |
+
}
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
// Phase 2: Build causal edges (events are already sorted by timestamp)
|
| 150 |
+
let n = events.len();
|
| 151 |
+
for i in 0..n {
|
| 152 |
+
let (ts_i, ref path_i, _) = events[i];
|
| 153 |
+
let id_i = self.path_to_id[path_i];
|
| 154 |
+
|
| 155 |
+
for j in (i + 1)..n {
|
| 156 |
+
let (ts_j, ref path_j, _) = events[j];
|
| 157 |
+
let delta = ts_j - ts_i;
|
| 158 |
+
|
| 159 |
+
if delta > self.causal_window_ns {
|
| 160 |
+
break;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
let id_j = self.path_to_id[path_j];
|
| 164 |
+
if id_i == id_j {
|
| 165 |
+
continue;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
let edge = self.edges
|
| 169 |
+
.entry((id_i, id_j))
|
| 170 |
+
.or_insert_with(|| CausalEdge::new(id_i, id_j));
|
| 171 |
+
edge.add_observation(delta as f64);
|
| 172 |
+
}
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
// Finalize edges
|
| 176 |
+
for edge in self.edges.values_mut() {
|
| 177 |
+
edge.finalize();
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
// Phase 3: Discover clusters
|
| 181 |
+
self.discover_clusters();
|
| 182 |
+
|
| 183 |
+
// Phase 4: Build successor lists for fast prediction
|
| 184 |
+
self.build_successors();
|
| 185 |
+
|
| 186 |
+
self.built = true;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
/// Get node count.
|
| 190 |
+
pub fn node_count(&self) -> usize {
|
| 191 |
+
self.nodes.len()
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
/// Get edge count.
|
| 195 |
+
pub fn edge_count(&self) -> usize {
|
| 196 |
+
self.edges.len()
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
/// Get strong edge count (weight >= threshold).
|
| 200 |
+
fn strong_edge_count(&self, min_weight: f64) -> usize {
|
| 201 |
+
self.edges.values().filter(|e| e.weight >= min_weight).count()
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
/// Get cluster count.
|
| 205 |
+
pub fn cluster_count(&self) -> usize {
|
| 206 |
+
self.clusters.len()
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
/// Get node access counts as (path, count) pairs.
|
| 210 |
+
pub fn get_node_stats(&self) -> Vec<(String, u32)> {
|
| 211 |
+
self.nodes.iter()
|
| 212 |
+
.map(|n| (n.path.clone(), n.access_count))
|
| 213 |
+
.collect()
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
/// Get top edges by weight as (source_path, target_path, count, mean_delta_ms, weight).
|
| 217 |
+
fn get_top_edges(&self, limit: usize) -> Vec<(String, String, u32, f64, f64)> {
|
| 218 |
+
let mut edges: Vec<_> = self.edges.values().collect();
|
| 219 |
+
edges.sort_by(|a, b| b.weight.partial_cmp(&a.weight).unwrap());
|
| 220 |
+
edges.iter()
|
| 221 |
+
.take(limit)
|
| 222 |
+
.map(|e| {
|
| 223 |
+
let src = &self.nodes[e.source_id as usize].path;
|
| 224 |
+
let tgt = &self.nodes[e.target_id as usize].path;
|
| 225 |
+
(src.clone(), tgt.clone(), e.count, e.mean_delta_ns / 1_000_000.0, e.weight)
|
| 226 |
+
})
|
| 227 |
+
.collect()
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
/// Check if graph has been built.
|
| 231 |
+
fn is_built(&self) -> bool {
|
| 232 |
+
self.built
|
| 233 |
+
}
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
// Non-PyO3 internal methods
|
| 237 |
+
impl AccessGraph {
|
| 238 |
+
fn get_or_create_node(&mut self, path: &str) -> u32 {
|
| 239 |
+
if let Some(&id) = self.path_to_id.get(path) {
|
| 240 |
+
return id;
|
| 241 |
+
}
|
| 242 |
+
let id = self.nodes.len() as u32;
|
| 243 |
+
self.path_to_id.insert(path.to_string(), id);
|
| 244 |
+
self.nodes.push(NodeInfo {
|
| 245 |
+
id,
|
| 246 |
+
path: path.to_string(),
|
| 247 |
+
access_count: 0,
|
| 248 |
+
total_bytes: 0,
|
| 249 |
+
first_access_ns: u64::MAX,
|
| 250 |
+
last_access_ns: 0,
|
| 251 |
+
});
|
| 252 |
+
id
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
fn discover_clusters(&mut self) {
|
| 256 |
+
let n = self.nodes.len();
|
| 257 |
+
if n < 2 {
|
| 258 |
+
return;
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
// Build co-access count matrix (sparse)
|
| 262 |
+
let mut cocount: HashMap<(u32, u32), u32> = HashMap::new();
|
| 263 |
+
for ((src, tgt), edge) in &self.edges {
|
| 264 |
+
*cocount.entry((*src, *tgt)).or_default() += edge.count;
|
| 265 |
+
*cocount.entry((*tgt, *src)).or_default() += edge.count;
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
// Build adjacency from pairs above threshold
|
| 269 |
+
let mut adjacency: Vec<Vec<u32>> = vec![Vec::new(); n];
|
| 270 |
+
for i in 0..n {
|
| 271 |
+
for j in (i + 1)..n {
|
| 272 |
+
let co = cocount.get(&(i as u32, j as u32)).copied().unwrap_or(0);
|
| 273 |
+
let min_count = self.nodes[i].access_count
|
| 274 |
+
.min(self.nodes[j].access_count)
|
| 275 |
+
.max(1);
|
| 276 |
+
let ratio = co as f64 / min_count as f64;
|
| 277 |
+
if ratio >= self.cluster_threshold {
|
| 278 |
+
adjacency[i].push(j as u32);
|
| 279 |
+
adjacency[j].push(i as u32);
|
| 280 |
+
}
|
| 281 |
+
}
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
// BFS to find connected components
|
| 285 |
+
let mut visited = vec![false; n];
|
| 286 |
+
let mut cluster_id: u32 = 0;
|
| 287 |
+
|
| 288 |
+
// Initialize cluster map
|
| 289 |
+
self.cluster_map = vec![None; n];
|
| 290 |
+
|
| 291 |
+
for start in 0..n {
|
| 292 |
+
if visited[start] || adjacency[start].is_empty() {
|
| 293 |
+
continue;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
let mut component = Vec::new();
|
| 297 |
+
let mut queue = vec![start];
|
| 298 |
+
|
| 299 |
+
while let Some(node) = queue.pop() {
|
| 300 |
+
if visited[node] {
|
| 301 |
+
continue;
|
| 302 |
+
}
|
| 303 |
+
visited[node] = true;
|
| 304 |
+
component.push(node as u32);
|
| 305 |
+
|
| 306 |
+
for &neighbor in &adjacency[node] {
|
| 307 |
+
if !visited[neighbor as usize] {
|
| 308 |
+
queue.push(neighbor as usize);
|
| 309 |
+
}
|
| 310 |
+
}
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
if component.len() >= 2 {
|
| 314 |
+
for &member_id in &component {
|
| 315 |
+
self.cluster_map[member_id as usize] = Some(cluster_id);
|
| 316 |
+
}
|
| 317 |
+
self.clusters.push(Cluster {
|
| 318 |
+
id: cluster_id,
|
| 319 |
+
member_ids: component,
|
| 320 |
+
});
|
| 321 |
+
cluster_id += 1;
|
| 322 |
+
}
|
| 323 |
+
}
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
fn build_successors(&mut self) {
|
| 327 |
+
let n = self.nodes.len();
|
| 328 |
+
let max_weight = self.edges.values()
|
| 329 |
+
.map(|e| e.weight)
|
| 330 |
+
.fold(0.0f64, f64::max)
|
| 331 |
+
.max(1.0);
|
| 332 |
+
|
| 333 |
+
self.successors = vec![Vec::new(); n];
|
| 334 |
+
|
| 335 |
+
for edge in self.edges.values() {
|
| 336 |
+
if edge.weight < 1.0 {
|
| 337 |
+
continue;
|
| 338 |
+
}
|
| 339 |
+
let norm_weight = edge.weight / max_weight;
|
| 340 |
+
self.successors[edge.source_id as usize].push((
|
| 341 |
+
edge.target_id,
|
| 342 |
+
norm_weight,
|
| 343 |
+
edge.mean_delta_ns / 1_000_000.0,
|
| 344 |
+
));
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
// Sort by weight descending, keep top 10
|
| 348 |
+
for succs in &mut self.successors {
|
| 349 |
+
succs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
|
| 350 |
+
succs.truncate(10);
|
| 351 |
+
}
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
/// Get successors for a node by path. Used by the predictor.
|
| 355 |
+
pub fn get_successors(&self, path: &str) -> &[(u32, f64, f64)] {
|
| 356 |
+
if let Some(&id) = self.path_to_id.get(path) {
|
| 357 |
+
&self.successors[id as usize]
|
| 358 |
+
} else {
|
| 359 |
+
&[]
|
| 360 |
+
}
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
/// Get cluster members for a node by path.
|
| 364 |
+
pub fn get_cluster_members(&self, path: &str) -> Option<&[u32]> {
|
| 365 |
+
let &id = self.path_to_id.get(path)?;
|
| 366 |
+
let cluster_id = self.cluster_map[id as usize]?;
|
| 367 |
+
Some(&self.clusters[cluster_id as usize].member_ids)
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
/// Get path for a node ID.
|
| 371 |
+
pub fn get_path(&self, id: u32) -> Option<&str> {
|
| 372 |
+
self.nodes.get(id as usize).map(|n| n.path.as_str())
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
/// Get node ID for a path.
|
| 376 |
+
pub fn get_id(&self, path: &str) -> Option<u32> {
|
| 377 |
+
self.path_to_id.get(path).copied()
|
| 378 |
+
}
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
#[cfg(test)]
|
| 382 |
+
mod tests {
|
| 383 |
+
use super::*;
|
| 384 |
+
|
| 385 |
+
#[test]
|
| 386 |
+
fn test_build_simple_graph() {
|
| 387 |
+
let mut graph = AccessGraph::new(5_000_000, 0.7);
|
| 388 |
+
|
| 389 |
+
// Simulate: A→B→C repeated 10 times, 1ms apart
|
| 390 |
+
let mut events = Vec::new();
|
| 391 |
+
for i in 0..10 {
|
| 392 |
+
let base = i * 3_000_000; // 3ms between iterations
|
| 393 |
+
events.push((base as u64, "A".to_string(), 100));
|
| 394 |
+
events.push((base as u64 + 1_000_000, "B".to_string(), 100));
|
| 395 |
+
events.push((base as u64 + 2_000_000, "C".to_string(), 100));
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
graph.build(events);
|
| 399 |
+
|
| 400 |
+
assert_eq!(graph.node_count(), 3);
|
| 401 |
+
assert!(graph.edge_count() > 0);
|
| 402 |
+
assert!(graph.is_built());
|
| 403 |
+
|
| 404 |
+
// A→B should be a strong edge
|
| 405 |
+
let top = graph.get_top_edges(5);
|
| 406 |
+
assert!(!top.is_empty());
|
| 407 |
+
println!("Top edges: {:?}", top);
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
#[test]
|
| 411 |
+
fn test_cluster_discovery() {
|
| 412 |
+
let mut graph = AccessGraph::new(3_000_000, 0.6);
|
| 413 |
+
|
| 414 |
+
// Cluster 1: X,Y,Z always together (tight timing)
|
| 415 |
+
// Cluster 2: P,Q always together
|
| 416 |
+
// Gap between clusters
|
| 417 |
+
let mut events = Vec::new();
|
| 418 |
+
for i in 0..30 {
|
| 419 |
+
let base = i * 20_000_000; // 20ms between iterations
|
| 420 |
+
// Cluster 1
|
| 421 |
+
events.push((base as u64, "X".to_string(), 100));
|
| 422 |
+
events.push((base as u64 + 100_000, "Y".to_string(), 100));
|
| 423 |
+
events.push((base as u64 + 200_000, "Z".to_string(), 100));
|
| 424 |
+
// Gap
|
| 425 |
+
// Cluster 2
|
| 426 |
+
events.push((base as u64 + 10_000_000, "P".to_string(), 100));
|
| 427 |
+
events.push((base as u64 + 10_100_000, "Q".to_string(), 100));
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
graph.build(events);
|
| 431 |
+
|
| 432 |
+
assert!(graph.cluster_count() >= 2, "Should find at least 2 clusters, found {}", graph.cluster_count());
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
#[test]
|
| 436 |
+
fn test_successor_lookup() {
|
| 437 |
+
let mut graph = AccessGraph::new(5_000_000, 0.7);
|
| 438 |
+
|
| 439 |
+
let mut events = Vec::new();
|
| 440 |
+
for i in 0..50 {
|
| 441 |
+
let base = i * 5_000_000;
|
| 442 |
+
events.push((base as u64, "src".to_string(), 100));
|
| 443 |
+
events.push((base as u64 + 1_000_000, "dst".to_string(), 100));
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
graph.build(events);
|
| 447 |
+
|
| 448 |
+
let succs = graph.get_successors("src");
|
| 449 |
+
assert!(!succs.is_empty(), "src should have successors");
|
| 450 |
+
assert_eq!(graph.get_path(succs[0].0), Some("dst"));
|
| 451 |
+
}
|
| 452 |
+
}
|
rust_core/src/lib.rs
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Condensate Core — Rust implementation
|
| 2 |
+
//!
|
| 3 |
+
//! Living memory manager: learns access patterns through causal topology,
|
| 4 |
+
//! predicts future accesses, manages memory tiers.
|
| 5 |
+
//!
|
| 6 |
+
//! This crate provides:
|
| 7 |
+
//! - AccessGraph: learns memory access topology from observations
|
| 8 |
+
//! - Predictor: predicts next access from causal spike propagation
|
| 9 |
+
//! - Python bindings via PyO3
|
| 10 |
+
|
| 11 |
+
mod graph;
|
| 12 |
+
mod predictor;
|
| 13 |
+
mod bench;
|
| 14 |
+
|
| 15 |
+
use pyo3::prelude::*;
|
| 16 |
+
|
| 17 |
+
/// Python module: condensate_core
|
| 18 |
+
#[pymodule]
|
| 19 |
+
fn condensate_core(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
| 20 |
+
m.add_class::<graph::AccessGraph>()?;
|
| 21 |
+
m.add_class::<predictor::RustPredictor>()?;
|
| 22 |
+
m.add_class::<predictor::Prediction>()?;
|
| 23 |
+
Ok(())
|
| 24 |
+
}
|
rust_core/src/predictor.rs
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Predictor — predicts next memory access from learned topology.
|
| 2 |
+
//!
|
| 3 |
+
//! Proto-SNN: causal spike propagation through learned graph.
|
| 4 |
+
//! When a path is accessed, spikes propagate to predicted-next
|
| 5 |
+
//! paths via direct successors, causal chains, and cluster co-activation.
|
| 6 |
+
|
| 7 |
+
use pyo3::prelude::*;
|
| 8 |
+
use crate::graph::AccessGraph;
|
| 9 |
+
|
| 10 |
+
/// A single prediction: what will be accessed, when, how confident.
|
| 11 |
+
#[pyclass]
|
| 12 |
+
#[derive(Clone, Debug)]
|
| 13 |
+
pub struct Prediction {
|
| 14 |
+
#[pyo3(get)]
|
| 15 |
+
pub path: String,
|
| 16 |
+
#[pyo3(get)]
|
| 17 |
+
pub confidence: f64,
|
| 18 |
+
#[pyo3(get)]
|
| 19 |
+
pub expected_delta_ms: f64,
|
| 20 |
+
#[pyo3(get)]
|
| 21 |
+
pub source_path: String,
|
| 22 |
+
#[pyo3(get)]
|
| 23 |
+
pub chain_depth: u32,
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
#[pymethods]
|
| 27 |
+
impl Prediction {
|
| 28 |
+
fn __repr__(&self) -> String {
|
| 29 |
+
format!(
|
| 30 |
+
"Prediction({}, conf={:.2}, dt={:.2}ms, depth={})",
|
| 31 |
+
self.path, self.confidence, self.expected_delta_ms, self.chain_depth
|
| 32 |
+
)
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
/// Scoring results from prediction evaluation.
|
| 37 |
+
#[pyclass]
|
| 38 |
+
#[derive(Clone, Debug)]
|
| 39 |
+
pub struct ScoreResult {
|
| 40 |
+
#[pyo3(get)]
|
| 41 |
+
pub predictions_made: u32,
|
| 42 |
+
#[pyo3(get)]
|
| 43 |
+
pub hits: u32,
|
| 44 |
+
#[pyo3(get)]
|
| 45 |
+
pub misses: u32,
|
| 46 |
+
#[pyo3(get)]
|
| 47 |
+
pub accuracy: f64,
|
| 48 |
+
#[pyo3(get)]
|
| 49 |
+
pub direct_hits: u32,
|
| 50 |
+
#[pyo3(get)]
|
| 51 |
+
pub chain_hits: u32,
|
| 52 |
+
#[pyo3(get)]
|
| 53 |
+
pub cluster_hits: u32,
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
/// The predictor — predicts next access from learned causal topology.
|
| 57 |
+
///
|
| 58 |
+
/// This is the proto-SNN. Production replaces this with real NeuroGraph
|
| 59 |
+
/// spike propagation.
|
| 60 |
+
#[pyclass]
|
| 61 |
+
pub struct RustPredictor {
|
| 62 |
+
/// Reference to the graph we learned from
|
| 63 |
+
/// (We store a copy of the data we need)
|
| 64 |
+
learned: bool,
|
| 65 |
+
|
| 66 |
+
/// Successors per node: (target_id, weight, delta_ms)
|
| 67 |
+
successors: Vec<Vec<(u32, f64, f64)>>,
|
| 68 |
+
|
| 69 |
+
/// Cluster membership: node_id → cluster_id
|
| 70 |
+
cluster_map: Vec<Option<u32>>,
|
| 71 |
+
|
| 72 |
+
/// Cluster members: cluster_id → [node_ids]
|
| 73 |
+
cluster_members: Vec<Vec<u32>>,
|
| 74 |
+
|
| 75 |
+
/// Path ↔ ID mapping
|
| 76 |
+
path_to_id: std::collections::HashMap<String, u32>,
|
| 77 |
+
id_to_path: Vec<String>,
|
| 78 |
+
|
| 79 |
+
/// Scoring window in nanoseconds
|
| 80 |
+
score_window_ns: u64,
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
#[pymethods]
|
| 84 |
+
impl RustPredictor {
|
| 85 |
+
#[new]
|
| 86 |
+
pub fn new() -> Self {
|
| 87 |
+
Self {
|
| 88 |
+
learned: false,
|
| 89 |
+
successors: Vec::new(),
|
| 90 |
+
cluster_map: Vec::new(),
|
| 91 |
+
cluster_members: Vec::new(),
|
| 92 |
+
path_to_id: std::collections::HashMap::new(),
|
| 93 |
+
id_to_path: Vec::new(),
|
| 94 |
+
score_window_ns: 10_000_000, // 10ms
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
/// Learn from a built AccessGraph.
|
| 99 |
+
pub fn learn(&mut self, graph: &AccessGraph) {
|
| 100 |
+
// Copy the data we need from the graph
|
| 101 |
+
let stats = graph.get_node_stats();
|
| 102 |
+
let n = stats.len();
|
| 103 |
+
|
| 104 |
+
self.id_to_path = Vec::with_capacity(n);
|
| 105 |
+
self.path_to_id = std::collections::HashMap::with_capacity(n);
|
| 106 |
+
|
| 107 |
+
for (i, (path, _count)) in stats.iter().enumerate() {
|
| 108 |
+
self.id_to_path.push(path.to_string());
|
| 109 |
+
self.path_to_id.insert(path.to_string(), i as u32);
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
// Copy successors
|
| 113 |
+
self.successors = Vec::with_capacity(n);
|
| 114 |
+
for (path, _) in &stats {
|
| 115 |
+
let succs = graph.get_successors(path);
|
| 116 |
+
self.successors.push(succs.to_vec());
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
// Copy cluster data
|
| 120 |
+
self.cluster_map = Vec::with_capacity(n);
|
| 121 |
+
for (path, _) in &stats {
|
| 122 |
+
if let Some(members) = graph.get_cluster_members(path) {
|
| 123 |
+
// Find which cluster this node belongs to
|
| 124 |
+
let cluster_id = self.cluster_members.len();
|
| 125 |
+
// Check if we already added this cluster
|
| 126 |
+
let mut found = false;
|
| 127 |
+
for (cid, existing) in self.cluster_members.iter().enumerate() {
|
| 128 |
+
if existing.contains(&self.path_to_id[path]) {
|
| 129 |
+
self.cluster_map.push(Some(cid as u32));
|
| 130 |
+
found = true;
|
| 131 |
+
break;
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
if !found {
|
| 135 |
+
self.cluster_map.push(Some(cluster_id as u32));
|
| 136 |
+
self.cluster_members.push(members.to_vec());
|
| 137 |
+
}
|
| 138 |
+
} else {
|
| 139 |
+
self.cluster_map.push(None);
|
| 140 |
+
}
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
self.learned = true;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
/// Predict what will be accessed next after `path`.
|
| 147 |
+
///
|
| 148 |
+
/// Returns top-K predictions sorted by confidence.
|
| 149 |
+
#[pyo3(signature = (path, top_k=10))]
|
| 150 |
+
pub fn predict(&self, path: &str, top_k: usize) -> Vec<Prediction> {
|
| 151 |
+
if !self.learned {
|
| 152 |
+
return Vec::new();
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
let id = match self.path_to_id.get(path) {
|
| 156 |
+
Some(&id) => id,
|
| 157 |
+
None => return Vec::new(),
|
| 158 |
+
};
|
| 159 |
+
|
| 160 |
+
// Collect predictions, keeping best confidence per target
|
| 161 |
+
let mut best: std::collections::HashMap<u32, Prediction> =
|
| 162 |
+
std::collections::HashMap::new();
|
| 163 |
+
|
| 164 |
+
// Source 1: Direct successors
|
| 165 |
+
for &(target_id, weight, delta_ms) in &self.successors[id as usize] {
|
| 166 |
+
let target_path = &self.id_to_path[target_id as usize];
|
| 167 |
+
let pred = Prediction {
|
| 168 |
+
path: target_path.clone(),
|
| 169 |
+
confidence: weight,
|
| 170 |
+
expected_delta_ms: delta_ms,
|
| 171 |
+
source_path: path.to_string(),
|
| 172 |
+
chain_depth: 1,
|
| 173 |
+
};
|
| 174 |
+
let entry = best.entry(target_id).or_insert(pred.clone());
|
| 175 |
+
if pred.confidence > entry.confidence {
|
| 176 |
+
*entry = pred;
|
| 177 |
+
}
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
// Source 2: Cluster co-activation
|
| 181 |
+
if let Some(cluster_id) = self.cluster_map.get(id as usize).and_then(|c| *c) {
|
| 182 |
+
if let Some(members) = self.cluster_members.get(cluster_id as usize) {
|
| 183 |
+
for &member_id in members {
|
| 184 |
+
if member_id == id {
|
| 185 |
+
continue;
|
| 186 |
+
}
|
| 187 |
+
let member_path = &self.id_to_path[member_id as usize];
|
| 188 |
+
let pred = Prediction {
|
| 189 |
+
path: member_path.clone(),
|
| 190 |
+
confidence: 0.85,
|
| 191 |
+
expected_delta_ms: 0.1,
|
| 192 |
+
source_path: path.to_string(),
|
| 193 |
+
chain_depth: 1,
|
| 194 |
+
};
|
| 195 |
+
let entry = best.entry(member_id).or_insert(pred.clone());
|
| 196 |
+
if pred.confidence > entry.confidence {
|
| 197 |
+
*entry = pred;
|
| 198 |
+
}
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
// Sort by confidence, return top_k
|
| 204 |
+
let mut result: Vec<Prediction> = best.into_values().collect();
|
| 205 |
+
result.sort_by(|a, b| b.confidence.partial_cmp(&a.confidence).unwrap());
|
| 206 |
+
result.truncate(top_k);
|
| 207 |
+
result
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
/// Score prediction accuracy against an access log.
|
| 211 |
+
///
|
| 212 |
+
/// events: list of (timestamp_ns, path, size_bytes)
|
| 213 |
+
pub fn score(&self, events: Vec<(u64, String, u64)>) -> ScoreResult {
|
| 214 |
+
if !self.learned || events.is_empty() {
|
| 215 |
+
return ScoreResult {
|
| 216 |
+
predictions_made: 0, hits: 0, misses: 0, accuracy: 0.0,
|
| 217 |
+
direct_hits: 0, chain_hits: 0, cluster_hits: 0,
|
| 218 |
+
};
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
let mut hits: u32 = 0;
|
| 222 |
+
let mut misses: u32 = 0;
|
| 223 |
+
let mut predictions_made: u32 = 0;
|
| 224 |
+
let mut direct_hits: u32 = 0;
|
| 225 |
+
let mut chain_hits: u32 = 0;
|
| 226 |
+
let mut cluster_hits: u32 = 0;
|
| 227 |
+
|
| 228 |
+
let n = events.len();
|
| 229 |
+
|
| 230 |
+
for i in 0..n.saturating_sub(1) {
|
| 231 |
+
let (ts_i, ref path_i, _) = events[i];
|
| 232 |
+
|
| 233 |
+
let preds = self.predict(path_i, 10);
|
| 234 |
+
if preds.is_empty() {
|
| 235 |
+
continue;
|
| 236 |
+
}
|
| 237 |
+
predictions_made += 1;
|
| 238 |
+
|
| 239 |
+
// Build prediction set for fast lookup
|
| 240 |
+
let pred_set: std::collections::HashMap<&str, &Prediction> = preds
|
| 241 |
+
.iter()
|
| 242 |
+
.map(|p| (p.path.as_str(), p))
|
| 243 |
+
.collect();
|
| 244 |
+
|
| 245 |
+
// Check what actually came next within scoring window
|
| 246 |
+
let mut hit = false;
|
| 247 |
+
for j in (i + 1)..n {
|
| 248 |
+
let (ts_j, ref path_j, _) = events[j];
|
| 249 |
+
let delta = ts_j - ts_i;
|
| 250 |
+
|
| 251 |
+
if delta > self.score_window_ns {
|
| 252 |
+
break;
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
if let Some(pred) = pred_set.get(path_j.as_str()) {
|
| 256 |
+
hit = true;
|
| 257 |
+
if pred.chain_depth > 1 {
|
| 258 |
+
chain_hits += 1;
|
| 259 |
+
} else if self.path_to_id.get(path_j.as_str())
|
| 260 |
+
.and_then(|&id| self.cluster_map.get(id as usize))
|
| 261 |
+
.and_then(|c| *c)
|
| 262 |
+
.is_some()
|
| 263 |
+
{
|
| 264 |
+
cluster_hits += 1;
|
| 265 |
+
} else {
|
| 266 |
+
direct_hits += 1;
|
| 267 |
+
}
|
| 268 |
+
break;
|
| 269 |
+
}
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
if hit {
|
| 273 |
+
hits += 1;
|
| 274 |
+
} else {
|
| 275 |
+
misses += 1;
|
| 276 |
+
}
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
let accuracy = if predictions_made > 0 {
|
| 280 |
+
(hits as f64 / predictions_made as f64) * 100.0
|
| 281 |
+
} else {
|
| 282 |
+
0.0
|
| 283 |
+
};
|
| 284 |
+
|
| 285 |
+
ScoreResult {
|
| 286 |
+
predictions_made,
|
| 287 |
+
hits,
|
| 288 |
+
misses,
|
| 289 |
+
accuracy: (accuracy * 10.0).round() / 10.0,
|
| 290 |
+
direct_hits,
|
| 291 |
+
chain_hits,
|
| 292 |
+
cluster_hits,
|
| 293 |
+
}
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
/// Check if predictor has learned.
|
| 297 |
+
fn is_learned(&self) -> bool {
|
| 298 |
+
self.learned
|
| 299 |
+
}
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
#[cfg(test)]
|
| 303 |
+
mod tests {
|
| 304 |
+
use super::*;
|
| 305 |
+
use crate::graph::AccessGraph;
|
| 306 |
+
|
| 307 |
+
#[test]
|
| 308 |
+
fn test_predict_sequential() {
|
| 309 |
+
let mut graph = AccessGraph::new(5_000_000, 0.7);
|
| 310 |
+
|
| 311 |
+
// A→B→C, repeated 20 times
|
| 312 |
+
let mut events = Vec::new();
|
| 313 |
+
for i in 0..20 {
|
| 314 |
+
let base = i * 5_000_000u64;
|
| 315 |
+
events.push((base, "A".to_string(), 100));
|
| 316 |
+
events.push((base + 500_000, "B".to_string(), 100));
|
| 317 |
+
events.push((base + 1_000_000, "C".to_string(), 100));
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
graph.build(events);
|
| 321 |
+
|
| 322 |
+
let mut predictor = RustPredictor::new();
|
| 323 |
+
predictor.learn(&graph);
|
| 324 |
+
|
| 325 |
+
let preds = predictor.predict("A", 5);
|
| 326 |
+
assert!(!preds.is_empty(), "Should have predictions for A");
|
| 327 |
+
|
| 328 |
+
let pred_paths: Vec<&str> = preds.iter().map(|p| p.path.as_str()).collect();
|
| 329 |
+
assert!(pred_paths.contains(&"B"), "Should predict B after A, got {:?}", pred_paths);
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
#[test]
|
| 333 |
+
fn test_score_accuracy() {
|
| 334 |
+
let mut graph = AccessGraph::new(5_000_000, 0.7);
|
| 335 |
+
|
| 336 |
+
let mut events = Vec::new();
|
| 337 |
+
for i in 0..50 {
|
| 338 |
+
let base = i * 5_000_000u64;
|
| 339 |
+
events.push((base, "X".to_string(), 100));
|
| 340 |
+
events.push((base + 1_000_000, "Y".to_string(), 100));
|
| 341 |
+
events.push((base + 2_000_000, "Z".to_string(), 100));
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
graph.build(events.clone());
|
| 345 |
+
|
| 346 |
+
let mut predictor = RustPredictor::new();
|
| 347 |
+
predictor.learn(&graph);
|
| 348 |
+
|
| 349 |
+
let result = predictor.score(events);
|
| 350 |
+
println!("Accuracy: {}%", result.accuracy);
|
| 351 |
+
assert!(result.accuracy > 50.0, "Accuracy should be > 50%, got {}%", result.accuracy);
|
| 352 |
+
assert!(result.predictions_made > 0);
|
| 353 |
+
}
|
| 354 |
+
}
|