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 } ;
1212use std:: sync:: Arc ;
1313
1414use serde_json:: { json, Value } ;
@@ -24,6 +24,10 @@ use crate::graph::{
2424 pagerank, paths_shortest, paths_shortest_weighted, personalized_pagerank, EdgeTuple ,
2525} ;
2626use crate :: graph_store:: EdgeRecord ;
27+ use crate :: morphology:: {
28+ default_relation_weights, is_morphological_relation, morphological_edges_from_records,
29+ morphology_stats, MorphologicalEdge ,
30+ } ;
2731use 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) ]
13291530mod 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 ( ) ;
0 commit comments