Skip to content

Commit 6a4555d

Browse files
Merge pull request #3 from Travis-Gilbert/Travis-Gilbert/morphological-graph-burn
feat(morphology): add advisory message passing operation
2 parents fbef2a6 + 11e740b commit 6a4555d

8 files changed

Lines changed: 658 additions & 20 deletions

File tree

crates/rustyred-core/src/algorithm_ops.rs

Lines changed: 273 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
//! surfaces it through `execute_request_json`, the MCP tool generation, and the
99
//! HTTP route generation with no per-surface code.
1010
11-
use std::collections::{BTreeSet, HashMap};
11+
use std::collections::{BTreeMap, BTreeSet, HashMap};
1212
use std::sync::Arc;
1313

1414
use serde_json::{json, Value};
@@ -24,6 +24,10 @@ use crate::graph::{
2424
pagerank, paths_shortest, paths_shortest_weighted, personalized_pagerank, EdgeTuple,
2525
};
2626
use crate::graph_store::EdgeRecord;
27+
use crate::morphology::{
28+
default_relation_weights, is_morphological_relation, morphological_edges_from_records,
29+
morphology_stats, MorphologicalEdge,
30+
};
2731
use crate::operation::{
2832
arg_bool, arg_f64, arg_str, arg_u64, arg_usize, estimate_from_coefficients, require_str,
2933
AlgorithmGraph, AlgorithmOperation, GraphCounts, MemoryEstimate, OperationError, OperationMode,
@@ -51,6 +55,7 @@ pub fn algorithm_operations() -> Vec<Arc<dyn AlgorithmOperation>> {
5155
Arc::new(SccOp),
5256
Arc::new(NodeSimilarityOp),
5357
Arc::new(LinkPredictionOp),
58+
Arc::new(MorphologicalMessagePassingOp),
5459
Arc::new(PersonalizedPageRankOp),
5560
Arc::new(ConnectedComponentsOp),
5661
Arc::new(LabelPropagationOp),
@@ -889,6 +894,120 @@ impl AlgorithmOperation for LinkPredictionOp {
889894
}
890895
}
891896

897+
// ===== Morphological graph message-passing scaffold =====
898+
899+
#[derive(Clone, Copy, Debug)]
900+
pub struct MorphologicalMessagePassingOp;
901+
902+
impl AlgorithmOperation for MorphologicalMessagePassingOp {
903+
fn command(&self) -> &'static str {
904+
"rustyred.algorithm.morphological_message_passing"
905+
}
906+
fn name(&self) -> &'static str {
907+
"morphological_message_passing"
908+
}
909+
fn summary(&self) -> &'static str {
910+
"Advisory message passing over city2graph-style touched_to / connected_to / faced_to edges."
911+
}
912+
fn modes(&self) -> &'static [OperationMode] {
913+
STREAM_STATS_MUTATE
914+
}
915+
fn input_schema(&self) -> Value {
916+
json!({
917+
"type": "object",
918+
"properties": {
919+
"mode": { "type": "string", "enum": ["stream", "stats", "mutate", "estimate"], "default": "stream" },
920+
"feature_property": { "type": "string", "default": "features" },
921+
"mutate_property": { "type": "string", "default": "morphological_embedding" },
922+
"iterations": { "type": "integer", "default": 1, "minimum": 1 },
923+
"top_k": { "type": "integer" },
924+
"relation_weights": {
925+
"type": "object",
926+
"additionalProperties": { "type": "number" },
927+
"default": { "touched_to": 1.0, "connected_to": 0.8, "faced_to": 0.6 }
928+
}
929+
}
930+
})
931+
}
932+
fn estimate(&self, counts: GraphCounts, _args: &Value) -> MemoryEstimate {
933+
// Feature table + accumulator table, bounded by the existing graph size.
934+
estimate_from_coefficients(counts, 8192, 96, 192, 48, "Morphological message passing")
935+
}
936+
fn run(
937+
&self,
938+
graph: &mut dyn AlgorithmGraph,
939+
mode: OperationMode,
940+
args: &Value,
941+
) -> Result<Value, OperationError> {
942+
let edges = graph.list_edges()?;
943+
let morphological_edges = morphological_edges_from_records(&edges);
944+
let stats = morphology_stats(&morphological_edges);
945+
if mode == OperationMode::Stats {
946+
return Ok(json!({
947+
"operation": self.command(),
948+
"mode": "stats",
949+
"stats": stats,
950+
}));
951+
}
952+
if morphological_edges.is_empty() {
953+
return Ok(json!({
954+
"operation": self.command(),
955+
"mode": mode.as_str(),
956+
"node_count": 0,
957+
"edge_count": 0,
958+
"rows": [],
959+
}));
960+
}
961+
962+
let feature_property = arg_str(args, "feature_property").unwrap_or("features");
963+
let features = morphological_features(graph, &morphological_edges, feature_property)?;
964+
if features.is_empty() {
965+
return Err(OperationError::invalid_params(format!(
966+
"no nodes incident to morphological edges carry a numeric array property {feature_property:?}"
967+
)));
968+
}
969+
let iterations = arg_usize(args, "iterations", 1).max(1);
970+
let weights = relation_weights_from_args(args);
971+
let passed =
972+
crate::morphology::message_pass(&features, &morphological_edges, iterations, &weights)
973+
.map_err(|error| OperationError::invalid_params(error.to_string()))?;
974+
let rows = morphological_rows(passed, args.get("top_k").and_then(Value::as_u64));
975+
976+
match mode {
977+
OperationMode::Stream => Ok(json!({
978+
"operation": self.command(),
979+
"mode": "stream",
980+
"iterations": iterations,
981+
"feature_property": feature_property,
982+
"relation_weights": weights,
983+
"edge_count": stats.edge_count,
984+
"node_count": rows.len(),
985+
"rows": rows,
986+
})),
987+
OperationMode::Mutate => {
988+
let property =
989+
arg_str(args, "mutate_property").unwrap_or("morphological_embedding");
990+
for row in &rows {
991+
let node_id = row["node_id"].as_str().ok_or_else(|| {
992+
OperationError::invalid_params("internal row missing node_id")
993+
})?;
994+
graph.write_node_property(node_id, property, row["embedding"].clone())?;
995+
}
996+
Ok(json!({
997+
"operation": self.command(),
998+
"mode": "mutate",
999+
"iterations": iterations,
1000+
"feature_property": feature_property,
1001+
"mutate_property": property,
1002+
"edge_count": stats.edge_count,
1003+
"nodes_written": rows.len(),
1004+
}))
1005+
}
1006+
OperationMode::Stats | OperationMode::Estimate => unreachable!("handled earlier"),
1007+
}
1008+
}
1009+
}
1010+
8921011
// ===== Conform the pre-existing algorithms to the operation contract =====
8931012

8941013
#[derive(Clone, Copy, Debug)]
@@ -1325,6 +1444,88 @@ fn edge_tuples(edges: &[EdgeRecord]) -> Vec<EdgeTuple> {
13251444
.collect()
13261445
}
13271446

1447+
fn morphological_features(
1448+
graph: &dyn AlgorithmGraph,
1449+
edges: &[MorphologicalEdge],
1450+
feature_property: &str,
1451+
) -> Result<BTreeMap<String, Vec<f64>>, OperationError> {
1452+
let mut node_ids = BTreeSet::new();
1453+
for edge in edges {
1454+
node_ids.insert(edge.source_id.as_str());
1455+
node_ids.insert(edge.target_id.as_str());
1456+
}
1457+
1458+
let mut features = BTreeMap::new();
1459+
for node_id in node_ids {
1460+
let Some(node) = graph.get_node(node_id)? else {
1461+
continue;
1462+
};
1463+
if let Some(vector) = read_f64_vector(&node.properties, feature_property) {
1464+
features.insert(node_id.to_string(), vector);
1465+
}
1466+
}
1467+
Ok(features)
1468+
}
1469+
1470+
fn read_f64_vector(properties: &Value, key: &str) -> Option<Vec<f64>> {
1471+
properties
1472+
.get(key)?
1473+
.as_array()?
1474+
.iter()
1475+
.map(Value::as_f64)
1476+
.collect()
1477+
}
1478+
1479+
fn relation_weights_from_args(args: &Value) -> BTreeMap<String, f64> {
1480+
let mut weights = default_relation_weights();
1481+
let Some(object) = args.get("relation_weights").and_then(Value::as_object) else {
1482+
return weights;
1483+
};
1484+
for (relation, value) in object {
1485+
if is_morphological_relation(relation) {
1486+
if let Some(weight) = value.as_f64() {
1487+
weights.insert(relation.trim().to_ascii_lowercase(), weight);
1488+
}
1489+
}
1490+
}
1491+
weights
1492+
}
1493+
1494+
fn morphological_rows(passed: BTreeMap<String, Vec<f64>>, top_k: Option<u64>) -> Vec<Value> {
1495+
let mut rows: Vec<Value> = passed
1496+
.into_iter()
1497+
.map(|(node_id, embedding)| {
1498+
let norm = embedding
1499+
.iter()
1500+
.map(|value| value * value)
1501+
.sum::<f64>()
1502+
.sqrt();
1503+
json!({
1504+
"node_id": node_id,
1505+
"embedding": embedding,
1506+
"norm": norm,
1507+
})
1508+
})
1509+
.collect();
1510+
rows.sort_by(|left, right| {
1511+
let left_norm = left["norm"].as_f64().unwrap_or(0.0);
1512+
let right_norm = right["norm"].as_f64().unwrap_or(0.0);
1513+
right_norm
1514+
.partial_cmp(&left_norm)
1515+
.unwrap_or(std::cmp::Ordering::Equal)
1516+
.then_with(|| {
1517+
left["node_id"]
1518+
.as_str()
1519+
.unwrap_or_default()
1520+
.cmp(right["node_id"].as_str().unwrap_or_default())
1521+
})
1522+
});
1523+
if let Some(top_k) = top_k {
1524+
rows.truncate(top_k as usize);
1525+
}
1526+
rows
1527+
}
1528+
13281529
#[cfg(test)]
13291530
mod tests {
13301531
use super::*;
@@ -1461,6 +1662,77 @@ mod tests {
14611662
assert!(scored.contains("b") || scored.contains("c"));
14621663
}
14631664

1665+
#[test]
1666+
fn morphological_message_passing_stream_stats_and_mutate() {
1667+
let mut store = InMemoryGraphStore::new();
1668+
for (id, features) in [
1669+
("place:a", vec![1.0, 0.0]),
1670+
("place:b", vec![0.0, 1.0]),
1671+
("movement:main", vec![0.25, 0.25]),
1672+
] {
1673+
store
1674+
.upsert_node(NodeRecord::new(
1675+
id,
1676+
["Morphology"],
1677+
json!({ "features": features }),
1678+
))
1679+
.unwrap();
1680+
}
1681+
store
1682+
.upsert_edge(EdgeRecord::new(
1683+
"a-touch-b",
1684+
"place:a",
1685+
"touched_to",
1686+
"place:b",
1687+
json!({}),
1688+
))
1689+
.unwrap();
1690+
store
1691+
.upsert_edge(EdgeRecord::new(
1692+
"a-face-main",
1693+
"place:a",
1694+
"faced_to",
1695+
"movement:main",
1696+
json!({}),
1697+
))
1698+
.unwrap();
1699+
1700+
let op = MorphologicalMessagePassingOp;
1701+
let stream = dispatch_operation(
1702+
&op,
1703+
&mut store,
1704+
&json!({
1705+
"mode": "stream",
1706+
"feature_property": "features",
1707+
"relation_weights": { "touched_to": 1.0, "faced_to": 1.0 }
1708+
}),
1709+
)
1710+
.unwrap();
1711+
assert_eq!(stream["mode"], "stream");
1712+
assert_eq!(stream["edge_count"], 2);
1713+
let place_b = stream["rows"]
1714+
.as_array()
1715+
.unwrap()
1716+
.iter()
1717+
.find(|row| row["node_id"] == "place:b")
1718+
.expect("place:b row");
1719+
assert_eq!(place_b["embedding"], json!([0.5, 0.5]));
1720+
1721+
let stats = dispatch_operation(&op, &mut store, &json!({ "mode": "stats" })).unwrap();
1722+
assert_eq!(stats["stats"]["touched_to_count"], 1);
1723+
assert_eq!(stats["stats"]["faced_to_count"], 1);
1724+
1725+
let mutate = dispatch_operation(
1726+
&op,
1727+
&mut store,
1728+
&json!({ "mode": "mutate", "mutate_property": "morph" }),
1729+
)
1730+
.unwrap();
1731+
assert_eq!(mutate["nodes_written"], 3);
1732+
let node = GraphStore::get_node(&store, "place:b").unwrap();
1733+
assert_eq!(node.properties["morph"], json!([0.5, 0.5]));
1734+
}
1735+
14641736
#[test]
14651737
fn scc_stream_reports_cycle_and_condensation() {
14661738
let mut store = store_with_triangles();

crates/rustyred-core/src/graph_store.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2125,7 +2125,7 @@ impl RedCoreGraphStore {
21252125

21262126
fn should_snapshot_for(&self, txn_id: u64) -> bool {
21272127
let interval = self.options.snapshot_interval_writes;
2128-
interval > 0 && txn_id > self.snapshot_txn_id && txn_id % interval == 0
2128+
interval > 0 && txn_id > self.snapshot_txn_id && txn_id.is_multiple_of(interval)
21292129
}
21302130

21312131
fn write_snapshot(&mut self) -> GraphStoreResult<()> {

crates/rustyred-core/src/lib.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ pub mod geometry;
2323
pub mod graph;
2424
pub mod graph_store;
2525
pub mod instant_kg;
26+
pub mod morphology;
2627
pub mod operation;
2728
pub mod plugin;
2829
pub mod spatial;
@@ -80,6 +81,12 @@ pub use instant_kg::{
8081
InstantKgStatus, PprResult, SearchResult, SessionDelta, INSTANT_KG_DEFAULT_ENCODER_VERSION,
8182
INSTANT_KG_DEFAULT_INGEST_VERSION, INSTANT_KG_PROTOCOL_VERSION,
8283
};
84+
pub use morphology::{
85+
default_relation_weights, dual_graph_edges, is_morphological_relation,
86+
message_pass as morphological_message_pass, morphological_edges_from_records, morphology_stats,
87+
relation_weights_from_map, MorphologicalEdge, MorphologicalNodeKind, MorphologyError,
88+
MorphologyStats, StreetSegmentTopology, CONNECTED_TO, FACED_TO, TOUCHED_TO,
89+
};
8390
pub use operation::{
8491
dispatch_operation, AlgorithmGraph, AlgorithmOperation, GraphCounts, MemoryEstimate,
8592
OperationError, OperationMode,

0 commit comments

Comments
 (0)