Executor-Tyrant-Framework Claude Opus 4.6 (1M context) commited on
Commit
26cf661
·
1 Parent(s): 487e5c0

Rust core: AccessGraph + RustPredictor with PyO3 bindings

Browse files

Phase 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 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
+ }