From 1d6c44364cbd29a33672518f10bd0b8d4013eefe Mon Sep 17 00:00:00 2001 From: web3guru888 Date: Sun, 26 Apr 2026 04:18:44 +0000 Subject: [PATCH 1/6] fix(gp-python): add missing chrono dep + explicit search import Closes #10, closes #13 --- rust/Cargo.lock | 110 +++++++++++++++++++++++++++++++++++ rust/gp-cli/Cargo.toml | 1 + rust/gp-palace/src/palace.rs | 1 + rust/gp-python/Cargo.toml | 1 + 4 files changed, 113 insertions(+) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 98cf169b1ea..54761e08bb8 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -631,6 +631,7 @@ name = "gp-cli" version = "0.1.0" dependencies = [ "anyhow", + "chrono", "clap", "gp-agents", "gp-core", @@ -644,6 +645,7 @@ dependencies = [ "rand 0.8.5", "serde", "serde_json", + "tempfile", "toml", ] @@ -715,6 +717,21 @@ dependencies = [ "thiserror", ] +[[package]] +name = "gp-python" +version = "0.1.0" +dependencies = [ + "chrono", + "gp-core", + "gp-embeddings", + "gp-palace", + "gp-pathfinding", + "gp-storage", + "pyo3", + "serde", + "serde_json", +] + [[package]] name = "gp-stigmergy" version = "0.1.0" @@ -979,6 +996,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "is-terminal" version = "0.4.17" @@ -1098,6 +1124,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1452,6 +1487,69 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pyo3" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f402062616ab18202ae8319da13fa4279883a2b8a9d9f83f20dbade813ce1884" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b14b5775b5ff446dd1056212d778012cbe8a0fbffd368029fd9e25b514479c38" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ab5bcf04a2cdcbb50c7d6105de943f543f9ed92af55818fd17b660390fc8636" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fd24d897903a9e6d80b968368a34e1525aeb719d568dba8b3d4bfa5dc67d453" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c011a03ba1e50152b4b394b479826cad97e7a21eb52df179cd91ac411cbfbe" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + [[package]] name = "quote" version = "1.0.45" @@ -1869,6 +1967,12 @@ dependencies = [ "syn", ] +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + [[package]] name = "tempfile" version = "3.27.0" @@ -2048,6 +2152,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/rust/gp-cli/Cargo.toml b/rust/gp-cli/Cargo.toml index 48f1b8448af..aff718224a7 100644 --- a/rust/gp-cli/Cargo.toml +++ b/rust/gp-cli/Cargo.toml @@ -29,6 +29,7 @@ gp-swarm = { path = "../gp-swarm" } clap = { version = "4", features = ["derive", "env"] } serde.workspace = true serde_json.workspace = true +chrono = { workspace = true } anyhow.workspace = true toml = "0.8" rand = { workspace = true } diff --git a/rust/gp-palace/src/palace.rs b/rust/gp-palace/src/palace.rs index 1b0c09a59b8..d7c694e5439 100644 --- a/rust/gp-palace/src/palace.rs +++ b/rust/gp-palace/src/palace.rs @@ -18,6 +18,7 @@ use gp_storage::memory::InMemoryBackend; use crate::export::{ImportMode, ImportStats, PalaceExport}; use crate::lifecycle::{ColdSpot, HotPath, KgRelationship, PalaceStatus}; +use crate::search; use crate::search::{PheromoneBooster, SearchResult}; // --------------------------------------------------------------------------- diff --git a/rust/gp-python/Cargo.toml b/rust/gp-python/Cargo.toml index 3d2b4fe34a4..97b22f6dde0 100644 --- a/rust/gp-python/Cargo.toml +++ b/rust/gp-python/Cargo.toml @@ -21,3 +21,4 @@ gp-embeddings = { path = "../gp-embeddings", features = ["tfidf"] } gp-pathfinding = { path = "../gp-pathfinding" } serde.workspace = true serde_json.workspace = true +chrono = { workspace = true } From 7f09b345731c79bbf502f45229aa3e69c200df1e Mon Sep 17 00:00:00 2001 From: web3guru888 Date: Sun, 26 Apr 2026 04:21:58 +0000 Subject: [PATCH 2/6] =?UTF-8?q?feat(gp-stigmergy):=20add=20=CF=84=5Fmax=20?= =?UTF-8?q?pheromone=20saturation=20ceiling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevents uniform convergence by clamping deposits to configurable per-type maximums. Addresses DPPN failure mode analysis. Closes #11 --- rust/gp-core/src/config.rs | 16 ++++++ rust/gp-stigmergy/src/cypher.rs | 1 + rust/gp-stigmergy/src/rewards.rs | 89 ++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+) diff --git a/rust/gp-core/src/config.rs b/rust/gp-core/src/config.rs index f25ac3de9cd..e739229e418 100644 --- a/rust/gp-core/src/config.rs +++ b/rust/gp-core/src/config.rs @@ -20,6 +20,16 @@ pub struct PheromoneConfig { pub recency_decay: f64, /// How many cycles between bulk decay operations. pub decay_interval_cycles: usize, + /// Maximum exploitation pheromone value (τ_max). Default 5.0. + pub exploitation_max: f64, + /// Maximum exploration pheromone value (τ_max). Default 3.0. + pub exploration_max: f64, + /// Maximum success pheromone value (τ_max). Default 5.0. + pub success_max: f64, + /// Maximum traversal pheromone value (τ_max). Default 2.0. + pub traversal_max: f64, + /// Maximum recency pheromone value (τ_max). Default 1.0. + pub recency_max: f64, } impl Default for PheromoneConfig { @@ -31,6 +41,12 @@ impl Default for PheromoneConfig { traversal_decay: 0.03, recency_decay: 0.10, decay_interval_cycles: 10, + // Saturation ceilings (τ_max) + exploitation_max: 5.0, + exploration_max: 3.0, + success_max: 5.0, + traversal_max: 2.0, + recency_max: 1.0, } } } diff --git a/rust/gp-stigmergy/src/cypher.rs b/rust/gp-stigmergy/src/cypher.rs index 36164a47bb6..9d43e3e3e6e 100644 --- a/rust/gp-stigmergy/src/cypher.rs +++ b/rust/gp-stigmergy/src/cypher.rs @@ -337,6 +337,7 @@ mod tests { traversal_decay: 0.04, recency_decay: 0.20, decay_interval_cycles: 5, + ..PheromoneConfig::default() }; let q = decay_node_pheromones_cypher(&config); assert_eq!(q.params["exploitation_rate"], CypherValue::Float(0.05)); diff --git a/rust/gp-stigmergy/src/rewards.rs b/rust/gp-stigmergy/src/rewards.rs index fd7e7832a12..55337a6a74a 100644 --- a/rust/gp-stigmergy/src/rewards.rs +++ b/rust/gp-stigmergy/src/rewards.rs @@ -69,6 +69,43 @@ pub fn deposit_exploration(pheromones: &mut NodePheromones) { pheromones.exploration += EXPLORATION_INCREMENT; } +/// Clamp a value to a ceiling. Returns the ceiling if value exceeds it. +#[inline] +pub fn clamp_to_ceiling(value: f64, ceiling: f64) -> f64 { + if value > ceiling { ceiling } else { value } +} + +/// Clamp edge pheromones to their saturation ceilings. +pub fn apply_edge_saturation(edge: &mut EdgePheromones, config: &gp_core::config::PheromoneConfig) { + edge.success = clamp_to_ceiling(edge.success, config.success_max); + edge.traversal = clamp_to_ceiling(edge.traversal, config.traversal_max); + edge.recency = clamp_to_ceiling(edge.recency, config.recency_max); +} + +/// Clamp node pheromones to their saturation ceilings. +pub fn apply_node_saturation(node: &mut NodePheromones, config: &gp_core::config::PheromoneConfig) { + node.exploitation = clamp_to_ceiling(node.exploitation, config.exploitation_max); + node.exploration = clamp_to_ceiling(node.exploration, config.exploration_max); +} + +/// Deposit pheromones along a successful search path with saturation clamping. +/// +/// Same as [`deposit_path_success`] but enforces τ_max ceilings from config. +pub fn deposit_path_success_clamped( + edges: &mut [EdgePheromones], + nodes: &mut [NodePheromones], + base_reward: f64, + config: &gp_core::config::PheromoneConfig, +) { + deposit_path_success(edges, nodes, base_reward); + for edge in edges.iter_mut() { + apply_edge_saturation(edge, config); + } + for node in nodes.iter_mut() { + apply_node_saturation(node, config); + } +} + #[cfg(test)] mod tests { use super::*; @@ -225,4 +262,56 @@ mod tests { assert!((p.exploitation - 0.7).abs() < 1e-12); assert!((p.exploration - EXPLORATION_INCREMENT).abs() < 1e-12); } + + // ─── Saturation ceiling (τ_max) ────────────────────────────────────── + + #[test] + fn test_clamp_to_ceiling() { + assert_eq!(clamp_to_ceiling(3.0, 5.0), 3.0); + assert_eq!(clamp_to_ceiling(7.0, 5.0), 5.0); + assert_eq!(clamp_to_ceiling(5.0, 5.0), 5.0); + assert_eq!(clamp_to_ceiling(0.0, 5.0), 0.0); + } + + #[test] + fn test_apply_edge_saturation() { + let config = gp_core::config::PheromoneConfig::default(); + let mut edge = EdgePheromones { success: 10.0, traversal: 5.0, recency: 3.0 }; + apply_edge_saturation(&mut edge, &config); + assert_eq!(edge.success, 5.0); // clamped from 10.0 + assert_eq!(edge.traversal, 2.0); // clamped from 5.0 + assert_eq!(edge.recency, 1.0); // clamped from 3.0 + } + + #[test] + fn test_apply_node_saturation() { + let config = gp_core::config::PheromoneConfig::default(); + let mut node = NodePheromones { exploitation: 8.0, exploration: 6.0 }; + apply_node_saturation(&mut node, &config); + assert_eq!(node.exploitation, 5.0); // clamped from 8.0 + assert_eq!(node.exploration, 3.0); // clamped from 6.0 + } + + #[test] + fn test_deposit_path_success_clamped() { + let config = gp_core::config::PheromoneConfig::default(); + // Start with values near ceiling + let mut edges = vec![EdgePheromones { success: 4.5, traversal: 1.9, recency: 0.5 }]; + let mut nodes = vec![NodePheromones { exploitation: 4.9, exploration: 0.0 }]; + deposit_path_success_clamped(&mut edges, &mut nodes, 2.0, &config); + // success would be 4.5 + 2.0 = 6.5, clamped to 5.0 + assert!((edges[0].success - 5.0).abs() < 1e-12); + // exploitation would be 4.9 + 0.2 = 5.1, clamped to 5.0 + assert!((nodes[0].exploitation - 5.0).abs() < 1e-12); + } + + #[test] + fn test_saturation_below_ceiling_unchanged() { + let config = gp_core::config::PheromoneConfig::default(); + let mut edges = vec![EdgePheromones { success: 0.5, traversal: 0.1, recency: 0.1 }]; + let mut nodes = vec![NodePheromones { exploitation: 0.1, exploration: 0.0 }]; + deposit_path_success_clamped(&mut edges, &mut nodes, 1.0, &config); + // All below ceiling, should be same as unclamped + assert!((edges[0].success - 1.5).abs() < 1e-12); + } } From 1e8887a7f3ca7be2cc45aaa07af77bca34d5c103 Mon Sep 17 00:00:00 2001 From: web3guru888 Date: Sun, 26 Apr 2026 04:25:32 +0000 Subject: [PATCH 3/6] feat(gp-stigmergy): add deposit_path_failure() for contrastive deposits Negative pheromone deposits for dead-end/failed paths. Subtracts from success (edges) and exploitation (nodes), floored at zero. Traversal and recency are preserved as factual signals. Exposed via Palace API and PyO3 bindings. Closes #12 --- rust/gp-palace/src/palace.rs | 26 ++++++++ rust/gp-python/src/lib.rs | 17 ++++++ rust/gp-stigmergy/src/rewards.rs | 102 +++++++++++++++++++++++++++++++ rust/gp-storage/src/memory.rs | 42 +++++++++++++ 4 files changed, 187 insertions(+) diff --git a/rust/gp-palace/src/palace.rs b/rust/gp-palace/src/palace.rs index d7c694e5439..ed74c2e8805 100644 --- a/rust/gp-palace/src/palace.rs +++ b/rust/gp-palace/src/palace.rs @@ -582,6 +582,32 @@ impl GraphPalace { Ok(()) } + /// Deposit negative pheromones along a failed/dead-end path. + /// + /// Subtracts from success pheromone on edges and exploitation on nodes. + /// All values are floored at 0.0 — pheromones cannot go negative. + /// Traversal and recency are not modified (they record factual usage). + pub fn deposit_failure_pheromones(&self, path: &[String], penalty: f64) -> Result<()> { + if path.len() < 2 { + return Ok(()); + } + for window in path.windows(2) { + self.storage.deposit_failure_edge_pheromones( + &window[0], + &window[1], + penalty, + ); + } + let exploitation_penalty = + gp_stigmergy::rewards::EXPLOITATION_INCREMENT + * gp_stigmergy::rewards::FAILURE_EXPLOITATION_FACTOR; + for node_id in path { + self.storage + .deposit_failure_node_pheromones(node_id, exploitation_penalty); + } + Ok(()) + } + /// Apply one cycle of pheromone decay across the entire palace. pub fn decay_pheromones(&mut self) -> Result<()> { self.storage diff --git a/rust/gp-python/src/lib.rs b/rust/gp-python/src/lib.rs index ec501e8e4c6..5ae5bf2f27e 100644 --- a/rust/gp-python/src/lib.rs +++ b/rust/gp-python/src/lib.rs @@ -784,6 +784,23 @@ impl PyPalace { Ok(()) } + /// Deposit negative pheromones along a failed path. + /// + /// Decreases success pheromone on edges and exploitation on nodes along + /// the path. Values are floored at zero. + /// + /// Args: + /// path (list[str]): Ordered node IDs forming the failed path. + /// penalty (float): Penalty magnitude. Defaults to ``1.0``. + #[pyo3(signature = (path, penalty=1.0))] + fn deposit_failure_pheromones(&self, path: Vec, penalty: f64) -> PyResult<()> { + let guard = self.inner.lock().map_err(gp_err)?; + let palace = &guard.0; + palace.deposit_failure_pheromones(&path, penalty).map_err(gp_err)?; + if self.auto_save { save_palace(palace, &self.path)?; } + Ok(()) + } + /// Invalidate a knowledge-graph triple. /// /// Args: diff --git a/rust/gp-stigmergy/src/rewards.rs b/rust/gp-stigmergy/src/rewards.rs index 55337a6a74a..17ea2e28f60 100644 --- a/rust/gp-stigmergy/src/rewards.rs +++ b/rust/gp-stigmergy/src/rewards.rs @@ -106,6 +106,58 @@ pub fn deposit_path_success_clamped( } } +/// Failure penalty as a fraction of the exploitation increment. +pub const FAILURE_EXPLOITATION_FACTOR: f64 = 0.5; + +/// Deposit negative pheromones along a failed/dead-end path. +/// +/// Mirror of [`deposit_path_success`] with subtraction instead of addition. +/// - Each **edge** at position `i` gets: `success -= penalty × (1.0 - i/n)` +/// - Each **node** gets: `exploitation -= EXPLOITATION_INCREMENT × 0.5` +/// - All values are floored at 0.0 (pheromones cannot go negative). +/// - Traversal and recency are NOT modified (they record factual usage). +/// +/// # Arguments +/// - `edges`: mutable slice of edge pheromones along the failed path +/// - `nodes`: mutable slice of node pheromones along the failed path +/// - `penalty`: base penalty amount (positive value; will be subtracted) +pub fn deposit_path_failure( + edges: &mut [EdgePheromones], + nodes: &mut [NodePheromones], + penalty: f64, +) { + let path_len = edges.len() as f64; + + if path_len > 0.0 { + for (i, edge) in edges.iter_mut().enumerate() { + let position_weight = 1.0 - (i as f64 / path_len); + edge.success = (edge.success - penalty * position_weight).max(0.0); + } + } + + for node in nodes.iter_mut() { + node.exploitation = + (node.exploitation - EXPLOITATION_INCREMENT * FAILURE_EXPLOITATION_FACTOR).max(0.0); + } +} + +/// Deposit negative pheromones with saturation-aware clamping. +pub fn deposit_path_failure_clamped( + edges: &mut [EdgePheromones], + nodes: &mut [NodePheromones], + penalty: f64, + config: &gp_core::config::PheromoneConfig, +) { + deposit_path_failure(edges, nodes, penalty); + // Floor is already enforced in deposit_path_failure, but apply ceiling too + for edge in edges.iter_mut() { + apply_edge_saturation(edge, config); + } + for node in nodes.iter_mut() { + apply_node_saturation(node, config); + } +} + #[cfg(test)] mod tests { use super::*; @@ -314,4 +366,54 @@ mod tests { // All below ceiling, should be same as unclamped assert!((edges[0].success - 1.5).abs() < 1e-12); } + + // ─── Failure deposit ────────────────────────────────────────────────── + + #[test] + fn test_deposit_path_failure_single_edge() { + let mut edges = vec![EdgePheromones { success: 1.0, traversal: 0.5, recency: 0.3 }]; + let mut nodes = vec![NodePheromones { exploitation: 1.0, exploration: 0.5 }]; + deposit_path_failure(&mut edges, &mut nodes, 0.5); + // success: 1.0 - 0.5 × 1.0 = 0.5 + assert!((edges[0].success - 0.5).abs() < 1e-12); + // traversal unchanged + assert!((edges[0].traversal - 0.5).abs() < 1e-12); + // recency unchanged + assert!((edges[0].recency - 0.3).abs() < 1e-12); + // exploitation: 1.0 - 0.2 × 0.5 = 0.9 + assert!((nodes[0].exploitation - 0.9).abs() < 1e-12); + // exploration unchanged + assert!((nodes[0].exploration - 0.5).abs() < 1e-12); + } + + #[test] + fn test_deposit_path_failure_floors_at_zero() { + let mut edges = vec![EdgePheromones { success: 0.1, traversal: 0.5, recency: 0.3 }]; + let mut nodes = vec![NodePheromones { exploitation: 0.05, exploration: 0.5 }]; + deposit_path_failure(&mut edges, &mut nodes, 5.0); + assert_eq!(edges[0].success, 0.0); + assert_eq!(nodes[0].exploitation, 0.0); + } + + #[test] + fn test_deposit_path_failure_position_weighting() { + let mut edges = vec![ + EdgePheromones { success: 2.0, ..EdgePheromones::default() }, + EdgePheromones { success: 2.0, ..EdgePheromones::default() }, + ]; + let mut nodes = vec![NodePheromones::default(); 3]; + deposit_path_failure(&mut edges, &mut nodes, 1.0); + // Edge 0: 2.0 - 1.0 × (1 - 0/2) = 2.0 - 1.0 = 1.0 + assert!((edges[0].success - 1.0).abs() < 1e-12); + // Edge 1: 2.0 - 1.0 × (1 - 1/2) = 2.0 - 0.5 = 1.5 + assert!((edges[1].success - 1.5).abs() < 1e-12); + } + + #[test] + fn test_deposit_path_failure_empty() { + let mut edges: Vec = vec![]; + let mut nodes: Vec = vec![]; + deposit_path_failure(&mut edges, &mut nodes, 1.0); + // No panic + } } diff --git a/rust/gp-storage/src/memory.rs b/rust/gp-storage/src/memory.rs index a4f7f7996ae..f26fee34984 100644 --- a/rust/gp-storage/src/memory.rs +++ b/rust/gp-storage/src/memory.rs @@ -948,6 +948,48 @@ impl InMemoryBackend { } total } + + // -- Failure deposits ---------------------------------------------------- + + /// Deposit negative (failure) pheromones on an edge. Subtracts from success, + /// floors at 0.0. Does not modify traversal or recency. + pub fn deposit_failure_edge_pheromones( + &self, + from: &str, + to: &str, + penalty: f64, + ) { + let mut d = self.write_data(); + let key = format!("{from}:{to}"); + let rev_key = format!("{to}:{from}"); + if let Some(ep) = d.edge_pheromones.get_mut(&key) { + ep.success = (ep.success - penalty).max(0.0); + } else if let Some(ep) = d.edge_pheromones.get_mut(&rev_key) { + ep.success = (ep.success - penalty).max(0.0); + } + } + + /// Deposit negative (failure) pheromones on a node. Subtracts from exploitation, + /// floors at 0.0. Does not modify exploration. + pub fn deposit_failure_node_pheromones( + &self, + node_id: &str, + exploitation_penalty: f64, + ) { + let mut d = self.write_data(); + // Check all node types + if let Some(w) = d.wings.get_mut(node_id) { + w.pheromones.exploitation = (w.pheromones.exploitation - exploitation_penalty).max(0.0); + } else if let Some(r) = d.rooms.get_mut(node_id) { + r.pheromones.exploitation = (r.pheromones.exploitation - exploitation_penalty).max(0.0); + } else if let Some(c) = d.closets.get_mut(node_id) { + c.pheromones.exploitation = (c.pheromones.exploitation - exploitation_penalty).max(0.0); + } else if let Some(dr) = d.drawers.get_mut(node_id) { + dr.pheromones.exploitation = (dr.pheromones.exploitation - exploitation_penalty).max(0.0); + } else if let Some(e) = d.entities.get_mut(node_id) { + e.pheromones.exploitation = (e.pheromones.exploitation - exploitation_penalty).max(0.0); + } + } } // --------------------------------------------------------------------------- From 0e685742fad91afe74edc5442917f3f5a0d02ac5 Mon Sep 17 00:00:00 2001 From: web3guru888 Date: Sun, 26 Apr 2026 04:27:42 +0000 Subject: [PATCH 4/6] feat(gp-core): add SchemaDialect for backend-aware DDL generation Adds LadybugDb dialect with stored-procedure syntax for VECTOR and FTS index creation. Original Cypher syntax preserved unchanged. Node and relationship table DDL is shared across dialects. Closes #14 --- rust/gp-core/src/schema.rs | 101 +++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/rust/gp-core/src/schema.rs b/rust/gp-core/src/schema.rs index def5729e610..5a11a22d3a8 100644 --- a/rust/gp-core/src/schema.rs +++ b/rust/gp-core/src/schema.rs @@ -3,6 +3,24 @@ //! These statements initialize the GraphPalace schema in Kuzu. //! All definitions map to the types in [`crate::types`]. +/// Schema dialect for index creation syntax. +/// +/// Different backends require different DDL syntax for index creation. +/// The core node/rel table DDL is shared, but index creation varies. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SchemaDialect { + /// Standard Cypher DDL (used by InMemoryBackend and Kuzu). + Cypher, + /// LadybugDB stored-procedure syntax for index creation. + LadybugDb, +} + +impl Default for SchemaDialect { + fn default() -> Self { + Self::Cypher + } +} + /// All Cypher DDL statements for creating the GraphPalace schema. /// /// Returns statements in the correct execution order: @@ -207,6 +225,44 @@ pub fn property_indexes() -> Vec<&'static str> { ] } +/// Schema DDL for a specific backend dialect. +/// +/// Node/rel table DDL is shared; only index creation syntax varies. +pub fn schema_ddl_for(dialect: SchemaDialect) -> Vec { + let mut stmts: Vec = Vec::new(); + // Node and rel tables are dialect-independent + stmts.extend(node_tables().iter().map(|s| s.to_string())); + stmts.extend(rel_tables().iter().map(|s| s.to_string())); + // Index syntax is dialect-dependent + stmts.extend(vector_indexes_for(dialect)); + stmts.extend(fts_indexes_for(dialect)); + stmts.extend(property_indexes().iter().map(|s| s.to_string())); + stmts +} + +/// Vector index creation DDL for a specific dialect. +pub fn vector_indexes_for(dialect: SchemaDialect) -> Vec { + match dialect { + SchemaDialect::Cypher => vector_indexes().iter().map(|s| s.to_string()).collect(), + SchemaDialect::LadybugDb => vec![ + "CALL CREATE_VECTOR_INDEX('drawer_embedding_idx', 'Drawer', 'embedding', 'cosine', 16, 200)".to_string(), + "CALL CREATE_VECTOR_INDEX('entity_embedding_idx', 'Entity', 'embedding', 'cosine', 16, 200)".to_string(), + "CALL CREATE_VECTOR_INDEX('room_embedding_idx', 'Room', 'embedding', 'cosine', 16, 200)".to_string(), + ], + } +} + +/// FTS index creation DDL for a specific dialect. +pub fn fts_indexes_for(dialect: SchemaDialect) -> Vec { + match dialect { + SchemaDialect::Cypher => fts_indexes().iter().map(|s| s.to_string()).collect(), + SchemaDialect::LadybugDb => vec![ + "CALL CREATE_FTS_INDEX('drawer_content_idx', 'Drawer', ['content'])".to_string(), + "CALL CREATE_FTS_INDEX('entity_name_idx', 'Entity', ['name', 'description'])".to_string(), + ], + } +} + /// Cypher statements for bulk pheromone decay operations. pub fn decay_statements() -> Vec<&'static str> { vec![ @@ -286,4 +342,49 @@ mod tests { assert!(stmts[1].contains("traversal_pheromone")); assert!(stmts[1].contains("recency_pheromone")); } + + // ─── Schema dialect ────────────────────────────────────────────────── + + #[test] + fn test_schema_dialect_default_is_cypher() { + assert_eq!(SchemaDialect::default(), SchemaDialect::Cypher); + } + + #[test] + fn test_vector_indexes_cypher_matches_original() { + let original: Vec = vector_indexes().iter().map(|s| s.to_string()).collect(); + let dialect = vector_indexes_for(SchemaDialect::Cypher); + assert_eq!(original, dialect); + } + + #[test] + fn test_vector_indexes_ladybugdb() { + let stmts = vector_indexes_for(SchemaDialect::LadybugDb); + assert_eq!(stmts.len(), 3); + assert!(stmts[0].starts_with("CALL CREATE_VECTOR_INDEX")); + assert!(stmts[0].contains("'Drawer'")); + assert!(stmts[0].contains("'cosine'")); + } + + #[test] + fn test_fts_indexes_ladybugdb() { + let stmts = fts_indexes_for(SchemaDialect::LadybugDb); + assert_eq!(stmts.len(), 2); + assert!(stmts[0].starts_with("CALL CREATE_FTS_INDEX")); + assert!(stmts[1].contains("['name', 'description']")); + } + + #[test] + fn test_schema_ddl_for_cypher_count() { + let stmts = schema_ddl_for(SchemaDialect::Cypher); + // Same count as original: 7 + 11 + 3 + 2 + 4 = 27 + assert_eq!(stmts.len(), 27); + } + + #[test] + fn test_schema_ddl_for_ladybugdb_count() { + let stmts = schema_ddl_for(SchemaDialect::LadybugDb); + // Same count: 7 + 11 + 3 + 2 + 4 = 27 + assert_eq!(stmts.len(), 27); + } } From 2e25bffaeeb8eaf83f2aae995305b24c93db75b4 Mon Sep 17 00:00:00 2001 From: web3guru888 Date: Sun, 26 Apr 2026 04:30:12 +0000 Subject: [PATCH 5/6] refactor(gp-storage): extract PalaceBackend trait for pluggable storage Defines 44-method PalaceBackend trait covering all palace operations. InMemoryBackend implements it via delegation. Opens path to LadybugDB disk backend. Closes #15 --- rust/gp-storage/src/lib.rs | 2 + rust/gp-storage/src/memory.rs | 163 ++++++++++++++++++++++ rust/gp-storage/src/palace_backend.rs | 187 ++++++++++++++++++++++++++ 3 files changed, 352 insertions(+) create mode 100644 rust/gp-storage/src/palace_backend.rs diff --git a/rust/gp-storage/src/lib.rs b/rust/gp-storage/src/lib.rs index eef307c73f3..3ac73df53cf 100644 --- a/rust/gp-storage/src/lib.rs +++ b/rust/gp-storage/src/lib.rs @@ -12,11 +12,13 @@ pub mod safe; pub mod backend; pub mod hnsw; pub mod memory; +pub mod palace_backend; pub mod palace_ops; pub mod schema_init; // Re-exports pub use backend::{StorageBackend, Value}; pub use memory::InMemoryBackend; +pub use palace_backend::PalaceBackend; pub use palace_ops::*; pub use schema_init::init_schema; diff --git a/rust/gp-storage/src/memory.rs b/rust/gp-storage/src/memory.rs index f26fee34984..918dfc4336c 100644 --- a/rust/gp-storage/src/memory.rs +++ b/rust/gp-storage/src/memory.rs @@ -1083,6 +1083,169 @@ impl StorageBackend for InMemoryBackend { } } +// --------------------------------------------------------------------------- +// PalaceBackend implementation +// --------------------------------------------------------------------------- + +impl crate::palace_backend::PalaceBackend for InMemoryBackend { + fn create_wing(&self, name: &str, wing_type: WingType, description: &str, embedding: Embedding) -> Result { + InMemoryBackend::create_wing(self, name, wing_type, description, embedding) + } + fn get_wing(&self, id: &str) -> Result { + InMemoryBackend::get_wing(self, id) + } + fn list_wings(&self) -> Vec { + InMemoryBackend::list_wings(self) + } + fn find_wing_by_name(&self, name: &str) -> Option { + InMemoryBackend::find_wing_by_name(self, name) + } + fn create_room(&self, wing_id: &str, name: &str, hall_type: HallType, description: &str, embedding: Embedding) -> Result { + InMemoryBackend::create_room(self, wing_id, name, hall_type, description, embedding) + } + fn get_room(&self, id: &str) -> Result { + InMemoryBackend::get_room(self, id) + } + fn list_rooms(&self, wing_id: &str) -> Vec { + InMemoryBackend::list_rooms(self, wing_id) + } + fn create_closet(&self, room_id: &str, name: &str, summary: &str, embedding: Embedding) -> Result { + InMemoryBackend::create_closet(self, room_id, name, summary, embedding) + } + fn get_closet(&self, id: &str) -> Result { + InMemoryBackend::get_closet(self, id) + } + fn create_drawer(&self, closet_id: &str, content: &str, embedding: Embedding, source: DrawerSource, source_file: Option<&str>, importance: f64) -> Result { + InMemoryBackend::create_drawer(self, closet_id, content, embedding, source, source_file, importance) + } + fn get_drawer(&self, id: &str) -> Result { + InMemoryBackend::get_drawer(self, id) + } + fn delete_drawer(&self, id: &str) -> Result<()> { + InMemoryBackend::delete_drawer(self, id) + } + fn search_drawers(&self, query_embedding: &Embedding, k: usize, threshold: f32) -> Vec<(Drawer, f32)> { + InMemoryBackend::search_drawers(self, query_embedding, k, threshold) + } + fn rebuild_hnsw_index(&self) { + InMemoryBackend::rebuild_hnsw_index(self) + } + fn create_entity(&self, name: &str, entity_type: EntityType, description: &str, embedding: Embedding) -> Result { + InMemoryBackend::create_entity(self, name, entity_type, description, embedding) + } + fn get_entity(&self, id: &str) -> Result { + InMemoryBackend::get_entity(self, id) + } + fn find_entity_by_name(&self, name: &str) -> Option { + InMemoryBackend::find_entity_by_name(self, name) + } + fn add_relationship(&self, subject: &str, predicate: &str, object: &str, confidence: f64) -> Result { + InMemoryBackend::add_relationship(self, subject, predicate, object, confidence) + } + fn add_relationship_temporal(&self, subject: &str, predicate: &str, object: &str, confidence: f64, valid_from: Option, valid_to: Option, statement_type: StatementType) -> Result { + InMemoryBackend::add_relationship_temporal(self, subject, predicate, object, confidence, valid_from, valid_to, statement_type) + } + fn query_relationships(&self, entity: &str) -> Vec { + InMemoryBackend::query_relationships(self, entity) + } + fn invalidate_relationship(&self, subject: &str, predicate: &str, object: &str) -> bool { + InMemoryBackend::invalidate_relationship(self, subject, predicate, object) + } + fn find_contradictions(&self, entity: &str) -> Vec<(Relationship, Relationship)> { + InMemoryBackend::find_contradictions(self, entity) + } + fn deposit_edge_pheromones(&self, from: &str, to: &str, success: f64, traversal: f64, recency: f64) { + InMemoryBackend::deposit_edge_pheromones(self, from, to, success, traversal, recency) + } + fn deposit_node_pheromones(&self, node_id: &str, exploitation: f64, exploration: f64) { + InMemoryBackend::deposit_node_pheromones(self, node_id, exploitation, exploration) + } + fn deposit_failure_edge_pheromones(&self, from: &str, to: &str, penalty: f64) { + InMemoryBackend::deposit_failure_edge_pheromones(self, from, to, penalty) + } + fn deposit_failure_node_pheromones(&self, node_id: &str, exploitation_penalty: f64) { + InMemoryBackend::deposit_failure_node_pheromones(self, node_id, exploitation_penalty) + } + fn decay_all_pheromones(&self, config: &gp_core::config::PheromoneConfig) { + InMemoryBackend::decay_all_pheromones(self, config) + } + fn hot_paths(&self, k: usize) -> Vec<(String, String, f64)> { + InMemoryBackend::hot_paths(self, k) + } + fn cold_spots(&self, k: usize) -> Vec<(String, String, f64)> { + InMemoryBackend::cold_spots(self, k) + } + fn total_pheromone_mass(&self) -> f64 { + InMemoryBackend::total_pheromone_mass(self) + } + fn add_similarity_edge(&self, drawer_a: &str, drawer_b: &str, similarity: f32) { + InMemoryBackend::add_similarity_edge(self, drawer_a, drawer_b, similarity) + } + fn remove_similarity_edge(&self, drawer_a: &str, drawer_b: &str) -> bool { + InMemoryBackend::remove_similarity_edge(self, drawer_a, drawer_b) + } + fn add_similarity_edges(&self, threshold: f32) -> usize { + InMemoryBackend::add_similarity_edges(self, threshold) + } + fn similarity_edge_count(&self) -> usize { + InMemoryBackend::similarity_edge_count(self) + } + fn list_similarity_edges(&self) -> Vec<(String, String, f32)> { + InMemoryBackend::list_similarity_edges(self) + } + fn create_hall(&self, from_room_id: &str, to_room_id: &str, wing_id: &str) { + InMemoryBackend::create_hall(self, from_room_id, to_room_id, wing_id) + } + fn create_tunnel(&self, from_room_id: &str, to_room_id: &str) { + InMemoryBackend::create_tunnel(self, from_room_id, to_room_id) + } + fn list_halls(&self) -> Vec<(String, String, String)> { + InMemoryBackend::list_halls(self) + } + fn list_tunnels(&self) -> Vec<(String, String)> { + InMemoryBackend::list_tunnels(self) + } + fn create_agent(&self, name: &str, domain: &str, focus: &str, goal_embedding: Embedding, temperature: f64) -> Result { + InMemoryBackend::create_agent(self, name, domain, focus, goal_embedding, temperature) + } + fn get_agent(&self, id: &str) -> Result { + InMemoryBackend::get_agent(self, id) + } + fn list_agents(&self) -> Vec { + InMemoryBackend::list_agents(self) + } + fn find_agent_by_name(&self, name: &str) -> Option { + InMemoryBackend::find_agent_by_name(self, name) + } + fn append_diary(&self, agent_id: &str, entry: &str) -> Result<()> { + InMemoryBackend::append_diary(self, agent_id, entry) + } + fn agent_count(&self) -> usize { + InMemoryBackend::agent_count(self) + } + fn wing_count(&self) -> usize { + InMemoryBackend::wing_count(self) + } + fn room_count(&self) -> usize { + InMemoryBackend::room_count(self) + } + fn closet_count(&self) -> usize { + InMemoryBackend::closet_count(self) + } + fn drawer_count(&self) -> usize { + InMemoryBackend::drawer_count(self) + } + fn entity_count(&self) -> usize { + InMemoryBackend::entity_count(self) + } + fn relationship_count(&self) -> usize { + InMemoryBackend::relationship_count(self) + } + fn edge_count(&self) -> usize { + InMemoryBackend::edge_count(self) + } +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- diff --git a/rust/gp-storage/src/palace_backend.rs b/rust/gp-storage/src/palace_backend.rs new file mode 100644 index 00000000000..4f93b3eb8a2 --- /dev/null +++ b/rust/gp-storage/src/palace_backend.rs @@ -0,0 +1,187 @@ +//! The `PalaceBackend` trait — pluggable storage for GraphPalace. +//! +//! Abstracts all high-level palace operations so alternative backends +//! (e.g., LadybugDB disk storage) can be swapped in. + +use gp_core::config::PheromoneConfig; +use gp_core::types::*; +use gp_core::Result; + +use crate::memory::Relationship; + +/// Comprehensive palace storage trait. +/// +/// Covers all CRUD, search, pheromone, knowledge graph, similarity, +/// navigation, and agent operations needed by `GraphPalace`. +/// +/// The bundled implementation is [`InMemoryBackend`](crate::InMemoryBackend). +pub trait PalaceBackend: Send + Sync { + // ── Palace hierarchy CRUD ──────────────────────────────────────────── + + fn create_wing( + &self, name: &str, wing_type: WingType, description: &str, embedding: Embedding, + ) -> Result; + + fn get_wing(&self, id: &str) -> Result; + fn list_wings(&self) -> Vec; + fn find_wing_by_name(&self, name: &str) -> Option; + + fn create_room( + &self, wing_id: &str, name: &str, hall_type: HallType, + description: &str, embedding: Embedding, + ) -> Result; + + fn get_room(&self, id: &str) -> Result; + fn list_rooms(&self, wing_id: &str) -> Vec; + + fn create_closet( + &self, room_id: &str, name: &str, summary: &str, embedding: Embedding, + ) -> Result; + + fn get_closet(&self, id: &str) -> Result; + + fn create_drawer( + &self, closet_id: &str, content: &str, embedding: Embedding, + source: DrawerSource, source_file: Option<&str>, importance: f64, + ) -> Result; + + fn get_drawer(&self, id: &str) -> Result; + fn delete_drawer(&self, id: &str) -> Result<()>; + + // ── Search ─────────────────────────────────────────────────────────── + + fn search_drawers( + &self, query_embedding: &Embedding, k: usize, threshold: f32, + ) -> Vec<(Drawer, f32)>; + + fn rebuild_hnsw_index(&self); + + // ── Knowledge graph ────────────────────────────────────────────────── + + fn create_entity( + &self, name: &str, entity_type: EntityType, description: &str, embedding: Embedding, + ) -> Result; + + fn get_entity(&self, id: &str) -> Result; + fn find_entity_by_name(&self, name: &str) -> Option; + + fn add_relationship( + &self, subject: &str, predicate: &str, object: &str, confidence: f64, + ) -> Result; + + fn add_relationship_temporal( + &self, subject: &str, predicate: &str, object: &str, confidence: f64, + valid_from: Option, valid_to: Option, + statement_type: StatementType, + ) -> Result; + + fn query_relationships(&self, entity: &str) -> Vec; + fn invalidate_relationship(&self, subject: &str, predicate: &str, object: &str) -> bool; + fn find_contradictions(&self, entity: &str) -> Vec<(Relationship, Relationship)>; + + // ── Pheromones ─────────────────────────────────────────────────────── + + fn deposit_edge_pheromones( + &self, from: &str, to: &str, success: f64, traversal: f64, recency: f64, + ); + + fn deposit_node_pheromones( + &self, node_id: &str, exploitation: f64, exploration: f64, + ); + + fn deposit_failure_edge_pheromones(&self, from: &str, to: &str, penalty: f64); + fn deposit_failure_node_pheromones(&self, node_id: &str, exploitation_penalty: f64); + + fn decay_all_pheromones(&self, config: &PheromoneConfig); + fn hot_paths(&self, k: usize) -> Vec<(String, String, f64)>; + fn cold_spots(&self, k: usize) -> Vec<(String, String, f64)>; + fn total_pheromone_mass(&self) -> f64; + + // ── Similarity graph ───────────────────────────────────────────────── + + fn add_similarity_edge(&self, drawer_a: &str, drawer_b: &str, similarity: f32); + fn remove_similarity_edge(&self, drawer_a: &str, drawer_b: &str) -> bool; + fn add_similarity_edges(&self, threshold: f32) -> usize; + fn similarity_edge_count(&self) -> usize; + fn list_similarity_edges(&self) -> Vec<(String, String, f32)>; + + // ── Navigation structures ──────────────────────────────────────────── + + fn create_hall(&self, from_room_id: &str, to_room_id: &str, wing_id: &str); + fn create_tunnel(&self, from_room_id: &str, to_room_id: &str); + fn list_halls(&self) -> Vec<(String, String, String)>; + fn list_tunnels(&self) -> Vec<(String, String)>; + + // ── Agents ─────────────────────────────────────────────────────────── + + fn create_agent( + &self, name: &str, domain: &str, focus: &str, + goal_embedding: Embedding, temperature: f64, + ) -> Result; + + fn get_agent(&self, id: &str) -> Result; + fn list_agents(&self) -> Vec; + fn find_agent_by_name(&self, name: &str) -> Option; + fn append_diary(&self, agent_id: &str, entry: &str) -> Result<()>; + fn agent_count(&self) -> usize; + + // ── Counts ─────────────────────────────────────────────────────────── + + fn wing_count(&self) -> usize; + fn room_count(&self) -> usize; + fn closet_count(&self) -> usize; + fn drawer_count(&self) -> usize; + fn entity_count(&self) -> usize; + fn relationship_count(&self) -> usize; + fn edge_count(&self) -> usize; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::memory::InMemoryBackend; + + fn zero_emb() -> Embedding { [0.0f32; 384] } + + #[test] + fn test_inmemory_implements_palace_backend() { + let backend: Box = Box::new(InMemoryBackend::new()); + let id = backend.create_wing("Test", WingType::Domain, "desc", zero_emb()).unwrap(); + assert!(id.starts_with("wing_")); + assert_eq!(backend.wing_count(), 1); + } + + #[test] + fn test_palace_backend_full_hierarchy() { + let backend: Box = Box::new(InMemoryBackend::new()); + let wid = backend.create_wing("W", WingType::Domain, "", zero_emb()).unwrap(); + let rid = backend.create_room(&wid, "R", HallType::Facts, "", zero_emb()).unwrap(); + let cid = backend.create_closet(&rid, "C", "S", zero_emb()).unwrap(); + let did = backend.create_drawer(&cid, "content", zero_emb(), DrawerSource::Conversation, None, 0.5).unwrap(); + assert_eq!(backend.wing_count(), 1); + assert_eq!(backend.room_count(), 1); + assert_eq!(backend.closet_count(), 1); + assert_eq!(backend.drawer_count(), 1); + let drawer = backend.get_drawer(&did).unwrap(); + assert_eq!(drawer.content, "content"); + } + + #[test] + fn test_palace_backend_pheromone_ops() { + let backend: Box = Box::new(InMemoryBackend::new()); + let wid = backend.create_wing("W", WingType::Domain, "", zero_emb()).unwrap(); + backend.deposit_node_pheromones(&wid, 1.0, 0.5); + assert!(backend.total_pheromone_mass() > 0.0); + } + + #[test] + fn test_palace_backend_failure_deposits() { + let backend: Box = Box::new(InMemoryBackend::new()); + let wid = backend.create_wing("W", WingType::Domain, "", zero_emb()).unwrap(); + backend.deposit_node_pheromones(&wid, 1.0, 0.0); + backend.deposit_failure_node_pheromones(&wid, 0.5); + // Exploitation should be reduced + let wing = backend.get_wing(&wid).unwrap(); + assert!((wing.pheromones.exploitation - 0.5).abs() < 1e-12); + } +} From d4cabcc6d84c08dd94a718646929589b19b4a8f7 Mon Sep 17 00:00:00 2001 From: web3guru888 Date: Sun, 26 Apr 2026 04:34:16 +0000 Subject: [PATCH 6/6] feat(gp-palace): add hyperstructure lifecycle tracking (NE/E ratio) Implements biological hyperstructure phase classification per Norris (2011). Each node is classified as Non-Equilibrium (active pheromone maintenance), Equilibrium (stable structure), or Transitioning based on its pheromone-to-connectivity ratio. Palace-wide lifecycle summary provides global NE/E ratio and per-node metrics. Exposed via PyO3. Closes #16 --- rust/gp-core/src/schema.rs | 9 +- rust/gp-palace/src/lib.rs | 5 +- rust/gp-palace/src/lifecycle.rs | 141 ++++++++++++++++++++++++++++++++ rust/gp-palace/src/palace.rs | 133 ++++++++++++++++++++++++++++++ rust/gp-python/src/lib.rs | 61 ++++++++++++++ 5 files changed, 341 insertions(+), 8 deletions(-) diff --git a/rust/gp-core/src/schema.rs b/rust/gp-core/src/schema.rs index 5a11a22d3a8..ab2b89a0b07 100644 --- a/rust/gp-core/src/schema.rs +++ b/rust/gp-core/src/schema.rs @@ -7,20 +7,15 @@ /// /// Different backends require different DDL syntax for index creation. /// The core node/rel table DDL is shared, but index creation varies. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum SchemaDialect { /// Standard Cypher DDL (used by InMemoryBackend and Kuzu). + #[default] Cypher, /// LadybugDB stored-procedure syntax for index creation. LadybugDb, } -impl Default for SchemaDialect { - fn default() -> Self { - Self::Cypher - } -} - /// All Cypher DDL statements for creating the GraphPalace schema. /// /// Returns statements in the correct execution order: diff --git a/rust/gp-palace/src/lib.rs b/rust/gp-palace/src/lib.rs index 313f5ab07b6..32ba0aefece 100644 --- a/rust/gp-palace/src/lib.rs +++ b/rust/gp-palace/src/lib.rs @@ -13,6 +13,9 @@ pub mod search; // Re-export primary public types. pub use export::{ImportMode, ImportStats, PalaceExport}; -pub use lifecycle::{ColdSpot, HotPath, KgRelationship, PalaceStatus}; +pub use lifecycle::{ + ColdSpot, HotPath, HyperstructureMetrics, HyperstructurePhase, + KgRelationship, LifecycleSummary, PalaceStatus, +}; pub use palace::GraphPalace; pub use search::{DuplicateMatch, SearchResult}; diff --git a/rust/gp-palace/src/lifecycle.rs b/rust/gp-palace/src/lifecycle.rs index bcc2ecbd532..c6e9ab0c8fa 100644 --- a/rust/gp-palace/src/lifecycle.rs +++ b/rust/gp-palace/src/lifecycle.rs @@ -77,6 +77,87 @@ pub struct KgRelationship { pub statement_type: StatementType, } +// --------------------------------------------------------------------------- +// Hyperstructure lifecycle (NE/E tracking) +// --------------------------------------------------------------------------- + +/// Phase classification for a hyperstructure node. +/// +/// Based on Norris (2011): NE hyperstructures are dynamically maintained +/// by active processes; E hyperstructures are stable self-assembled states. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum HyperstructurePhase { + /// Actively maintained by ongoing pheromone deposits. Dissolves if activity ceases. + NonEquilibrium, + /// Stable, self-assembled. Low activity but persistent structure. + Equilibrium, + /// Between states — moderate activity, not yet classified. + Transitioning, +} + +impl std::fmt::Display for HyperstructurePhase { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NonEquilibrium => write!(f, "non-equilibrium"), + Self::Equilibrium => write!(f, "equilibrium"), + Self::Transitioning => write!(f, "transitioning"), + } + } +} + +/// Lifecycle metrics for a palace node (wing, room, closet, or drawer). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HyperstructureMetrics { + /// Node ID. + pub node_id: String, + /// Human-readable label. + pub label: String, + /// Node type: "wing", "room", "closet", "drawer", "entity". + pub node_type: String, + /// Non-equilibrium score: sum of exploitation + exploration pheromones. + /// High values indicate active maintenance. + pub ne_score: f64, + /// Equilibrium score: structural connectivity (child count / edge count). + /// High values indicate stable structure. + pub e_score: f64, + /// NE/E ratio. > 1.0 = active, < 1.0 = stable, ≈ 1.0 = transitioning. + pub ne_e_ratio: f64, + /// Classified phase based on NE/E ratio thresholds. + pub phase: HyperstructurePhase, +} + +/// NE/E ratio threshold for NonEquilibrium classification. +pub const NE_THRESHOLD: f64 = 1.5; +/// NE/E ratio threshold for Equilibrium classification. +pub const E_THRESHOLD: f64 = 0.5; + +/// Classify a node's hyperstructure phase from its NE/E ratio. +pub fn classify_phase(ne_e_ratio: f64) -> HyperstructurePhase { + if ne_e_ratio >= NE_THRESHOLD { + HyperstructurePhase::NonEquilibrium + } else if ne_e_ratio <= E_THRESHOLD { + HyperstructurePhase::Equilibrium + } else { + HyperstructurePhase::Transitioning + } +} + +/// Summary of hyperstructure phase distribution across the palace. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LifecycleSummary { + /// Total NE nodes. + pub ne_count: usize, + /// Total E nodes. + pub e_count: usize, + /// Total transitioning nodes. + pub transitioning_count: usize, + /// Palace-wide NE/E ratio (ne_count / max(e_count, 1)). + pub global_ne_e_ratio: f64, + /// Per-node metrics (all nodes). + pub nodes: Vec, +} + #[cfg(test)] mod tests { use super::*; @@ -194,4 +275,64 @@ mod tests { assert_eq!(deser.statement_type, StatementType::Hypothesis); assert!(deser.valid_from.is_some()); } + + // ─── Hyperstructure lifecycle ───────────────────────────────────────── + + #[test] + fn test_classify_phase_ne() { + assert_eq!(classify_phase(2.0), HyperstructurePhase::NonEquilibrium); + assert_eq!(classify_phase(1.5), HyperstructurePhase::NonEquilibrium); + } + + #[test] + fn test_classify_phase_e() { + assert_eq!(classify_phase(0.3), HyperstructurePhase::Equilibrium); + assert_eq!(classify_phase(0.0), HyperstructurePhase::Equilibrium); + assert_eq!(classify_phase(0.5), HyperstructurePhase::Equilibrium); + } + + #[test] + fn test_classify_phase_transitioning() { + assert_eq!(classify_phase(1.0), HyperstructurePhase::Transitioning); + assert_eq!(classify_phase(0.8), HyperstructurePhase::Transitioning); + assert_eq!(classify_phase(1.4), HyperstructurePhase::Transitioning); + } + + #[test] + fn test_hyperstructure_metrics_serialization() { + let m = HyperstructureMetrics { + node_id: "wing_1".into(), + label: "Science".into(), + node_type: "wing".into(), + ne_score: 2.5, + e_score: 3.0, + ne_e_ratio: 0.833, + phase: HyperstructurePhase::Transitioning, + }; + let json = serde_json::to_string(&m).unwrap(); + let deser: HyperstructureMetrics = serde_json::from_str(&json).unwrap(); + assert_eq!(deser.node_id, "wing_1"); + assert_eq!(deser.phase, HyperstructurePhase::Transitioning); + } + + #[test] + fn test_lifecycle_summary_serialization() { + let s = LifecycleSummary { + ne_count: 5, + e_count: 10, + transitioning_count: 3, + global_ne_e_ratio: 0.5, + nodes: vec![], + }; + let json = serde_json::to_string(&s).unwrap(); + let deser: LifecycleSummary = serde_json::from_str(&json).unwrap(); + assert_eq!(deser.ne_count, 5); + } + + #[test] + fn test_hyperstructure_phase_display() { + assert_eq!(HyperstructurePhase::NonEquilibrium.to_string(), "non-equilibrium"); + assert_eq!(HyperstructurePhase::Equilibrium.to_string(), "equilibrium"); + assert_eq!(HyperstructurePhase::Transitioning.to_string(), "transitioning"); + } } diff --git a/rust/gp-palace/src/palace.rs b/rust/gp-palace/src/palace.rs index ed74c2e8805..845c80ac0ee 100644 --- a/rust/gp-palace/src/palace.rs +++ b/rust/gp-palace/src/palace.rs @@ -1016,6 +1016,86 @@ impl GraphPalace { }) } + // -- Hyperstructure lifecycle ---------------------------------------------- + + /// Compute hyperstructure lifecycle metrics for a single node. + pub fn hyperstructure_metrics(&self, node_id: &str) -> Result { + use crate::lifecycle::{classify_phase, HyperstructureMetrics}; + + let d = self.storage.read_data(); + + let (label, node_type, pheromones, child_count) = if let Some(w) = d.wings.get(node_id) { + let children = d.parent_map.values().filter(|p| *p == node_id).count(); + (w.name.clone(), "wing", w.pheromones.clone(), children) + } else if let Some(r) = d.rooms.get(node_id) { + let children = d.parent_map.values().filter(|p| *p == node_id).count(); + (r.name.clone(), "room", r.pheromones.clone(), children) + } else if let Some(c) = d.closets.get(node_id) { + (c.name.clone(), "closet", c.pheromones.clone(), c.drawer_count as usize) + } else if let Some(dr) = d.drawers.get(node_id) { + let content_preview: String = dr.content.chars().take(30).collect(); + (content_preview, "drawer", dr.pheromones.clone(), 0) + } else if let Some(e) = d.entities.get(node_id) { + let rels = d.relationships.iter() + .filter(|r| r.subject == node_id || r.object == node_id) + .count(); + (e.name.clone(), "entity", e.pheromones.clone(), rels) + } else { + return Err(GraphPalaceError::NodeNotFound { + id: node_id.to_string(), + }); + }; + + let ne_score = pheromones.exploitation + pheromones.exploration; + let e_score = (child_count as f64).max(1.0); + let ne_e_ratio = ne_score / e_score; + let phase = classify_phase(ne_e_ratio); + + Ok(HyperstructureMetrics { + node_id: node_id.to_string(), + label, + node_type: node_type.to_string(), + ne_score, + e_score, + ne_e_ratio, + phase, + }) + } + + /// Compute lifecycle summary for the entire palace. + pub fn lifecycle_summary(&self) -> Result { + use crate::lifecycle::{HyperstructurePhase, LifecycleSummary}; + + let d = self.storage.read_data(); + let mut all_ids: Vec = Vec::new(); + all_ids.extend(d.wings.keys().cloned()); + all_ids.extend(d.rooms.keys().cloned()); + all_ids.extend(d.closets.keys().cloned()); + all_ids.extend(d.drawers.keys().cloned()); + all_ids.extend(d.entities.keys().cloned()); + drop(d); // release lock before calling hyperstructure_metrics + + let mut nodes = Vec::new(); + for id in &all_ids { + if let Ok(m) = self.hyperstructure_metrics(id) { + nodes.push(m); + } + } + + let ne_count = nodes.iter().filter(|n| n.phase == HyperstructurePhase::NonEquilibrium).count(); + let e_count = nodes.iter().filter(|n| n.phase == HyperstructurePhase::Equilibrium).count(); + let transitioning_count = nodes.iter().filter(|n| n.phase == HyperstructurePhase::Transitioning).count(); + let global_ne_e_ratio = ne_count as f64 / (e_count.max(1) as f64); + + Ok(LifecycleSummary { + ne_count, + e_count, + transitioning_count, + global_ne_e_ratio, + nodes, + }) + } + // -- Export/Import ------------------------------------------------------- /// Export the entire palace to a serializable snapshot. @@ -2160,4 +2240,57 @@ mod tests { .collect(); assert_eq!(sim_neighbors.len(), 1, "should have exactly one SIMILAR_TO neighbor"); } + + // ─── Hyperstructure lifecycle ───────────────────────────────────────── + + #[test] + fn hyperstructure_metrics_for_wing() { + let mut p = make_palace(); + let wid = p.add_wing("Science", WingType::Domain, "").unwrap(); + let m = p.hyperstructure_metrics(&wid).unwrap(); + assert_eq!(m.node_type, "wing"); + assert_eq!(m.phase, crate::lifecycle::HyperstructurePhase::Equilibrium); + assert_eq!(m.ne_score, 0.0); // no pheromones deposited + } + + #[test] + fn hyperstructure_metrics_active_node() { + let mut p = make_palace(); + let wid = p.add_wing("Science", WingType::Domain, "").unwrap(); + // Deposit enough pheromones to make it NE + for _ in 0..20 { + p.storage().deposit_node_pheromones(&wid, 0.5, 0.3); + } + let m = p.hyperstructure_metrics(&wid).unwrap(); + assert!(m.ne_score > 0.0); + // With no children, e_score = 1.0, ne_score = 10+6 = 16, ratio = 16.0 → NE + assert_eq!(m.phase, crate::lifecycle::HyperstructurePhase::NonEquilibrium); + } + + #[test] + fn hyperstructure_metrics_not_found() { + let p = make_palace(); + assert!(p.hyperstructure_metrics("nonexistent").is_err()); + } + + #[test] + fn lifecycle_summary_empty_palace() { + let p = make_palace(); + let s = p.lifecycle_summary().unwrap(); + assert_eq!(s.ne_count, 0); + assert_eq!(s.e_count, 0); + assert_eq!(s.transitioning_count, 0); + assert!(s.nodes.is_empty()); + } + + #[test] + fn lifecycle_summary_with_data() { + let mut p = make_palace(); + let wid = p.add_wing("Science", WingType::Domain, "").unwrap(); + p.add_room(&wid, "Physics", HallType::Facts).unwrap(); + let s = p.lifecycle_summary().unwrap(); + assert_eq!(s.nodes.len(), 2); // wing + room + // Both should be equilibrium (no pheromone activity) + assert_eq!(s.e_count, 2); + } } diff --git a/rust/gp-python/src/lib.rs b/rust/gp-python/src/lib.rs index 5ae5bf2f27e..1694761854d 100644 --- a/rust/gp-python/src/lib.rs +++ b/rust/gp-python/src/lib.rs @@ -1148,6 +1148,67 @@ impl PyPalace { save_palace(&guard.0, &self.path) } + /// Get hyperstructure lifecycle metrics for a node. + /// + /// Returns a dict with node_id, label, node_type, ne_score, e_score, + /// ne_e_ratio, and phase classification. + /// + /// Args: + /// node_id (str): The node ID to inspect. + /// + /// Returns: + /// dict: Lifecycle metrics for the node. + fn hyperstructure_metrics(&self, node_id: &str) -> PyResult { + let guard = self.inner.lock().map_err(gp_err)?; + let palace = &guard.0; + let metrics = palace.hyperstructure_metrics(node_id).map_err(gp_err)?; + Python::with_gil(|py| { + let dict = pyo3::types::PyDict::new_bound(py); + dict.set_item("node_id", &metrics.node_id)?; + dict.set_item("label", &metrics.label)?; + dict.set_item("node_type", &metrics.node_type)?; + dict.set_item("ne_score", metrics.ne_score)?; + dict.set_item("e_score", metrics.e_score)?; + dict.set_item("ne_e_ratio", metrics.ne_e_ratio)?; + dict.set_item("phase", metrics.phase.to_string())?; + Ok(dict.into()) + }) + } + + /// Get lifecycle summary for the entire palace. + /// + /// Returns a dict with ne_count, e_count, transitioning_count, + /// global_ne_e_ratio, and a list of per-node metrics. + /// + /// Returns: + /// dict: Palace-wide lifecycle summary. + fn lifecycle_summary(&self) -> PyResult { + let guard = self.inner.lock().map_err(gp_err)?; + let palace = &guard.0; + let summary = palace.lifecycle_summary().map_err(gp_err)?; + Python::with_gil(|py| { + let dict = pyo3::types::PyDict::new_bound(py); + dict.set_item("ne_count", summary.ne_count)?; + dict.set_item("e_count", summary.e_count)?; + dict.set_item("transitioning_count", summary.transitioning_count)?; + dict.set_item("global_ne_e_ratio", summary.global_ne_e_ratio)?; + let nodes_list = pyo3::types::PyList::empty_bound(py); + for m in &summary.nodes { + let nd = pyo3::types::PyDict::new_bound(py); + nd.set_item("node_id", &m.node_id)?; + nd.set_item("label", &m.label)?; + nd.set_item("node_type", &m.node_type)?; + nd.set_item("ne_score", m.ne_score)?; + nd.set_item("e_score", m.e_score)?; + nd.set_item("ne_e_ratio", m.ne_e_ratio)?; + nd.set_item("phase", m.phase.to_string())?; + nodes_list.append(nd)?; + } + dict.set_item("nodes", nodes_list)?; + Ok(dict.into()) + }) + } + /// Return the number of drawers in the palace. fn __len__(&self) -> PyResult { let guard = self.inner.lock().map_err(gp_err)?;