Skip to content

Commit ebed144

Browse files
committed
Unify graph edge direction reporting
1 parent 7aac869 commit ebed144

19 files changed

Lines changed: 685 additions & 231 deletions

File tree

GRAPH_DSL.md

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,16 @@ Hybrid search uses **Reciprocal Rank Fusion (RRF)** to merge the two result list
106106
"undirected" — follow edges in either role
107107
```
108108

109+
### Edge Response Semantics
110+
111+
All graph DSL edge lists use the same contract:
112+
113+
- `from` / `to` describe the edge itself, using the stored edge orientation.
114+
- `direction` describes how that edge was traversed relative to the current vertex:
115+
- `"out"` — traversed from source to target
116+
- `"in"` — traversed from target to source
117+
- `"none"` — undirected edge
118+
109119
### Field Projection (`select` / `edge_select`)
110120

111121
All graph DSL operations that return vertices or edges support optional field-projection fields at the top level of their
@@ -368,6 +378,7 @@ Extracts all vertices and edges reachable from a set of seed vertices within a b
368378
{
369379
"from": "3mFx...",
370380
"to": "7kLp...",
381+
"direction": "out",
371382
"schema": "Knows",
372383
"fields": { "since": 2019 }
373384
}
@@ -524,9 +535,9 @@ the endpoint vertices.
524535
{ "id": "7kLp...", "schema": "Person", "fields": { "name": "Bob" } }
525536
],
526537
"edges": [
527-
{ "from": "3mFx...", "to": "9qRz...", "schema": "Knows", "fields": {} },
528-
{ "from": "9qRz...", "to": "2pNw...", "schema": "WorksAt", "fields": {} },
529-
{ "from": "2pNw...", "to": "7kLp...", "schema": "Employs", "fields": {} }
538+
{ "from": "3mFx...", "to": "9qRz...", "direction": "out", "schema": "Knows", "fields": {} },
539+
{ "from": "9qRz...", "to": "2pNw...", "direction": "out", "schema": "WorksAt", "fields": {} },
540+
{ "from": "2pNw...", "to": "7kLp...", "direction": "out", "schema": "Employs", "fields": {} }
530541
]
531542
}
532543
```
@@ -870,7 +881,7 @@ The `expand` clause is identical to the one in the [Subgraph DSL](#subgraph-dsl-
870881

871882
// Edges discovered during expansion.
872883
"edges": [
873-
{ "from": "6hYw...", "to": "1cZa...", "schema": "NextChunk", "fields": {} }
884+
{ "from": "6hYw...", "to": "1cZa...", "direction": "out", "schema": "NextChunk", "fields": {} }
874885
]
875886
}
876887
```

HTTP_API.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,9 @@ Query edge body cells using the same request shape as `/v1/query`:
347347
```
348348

349349
- Edge query currently supports edge schemas with body fields.
350-
- Response includes `cell_id`, `from_id`, `to_id`, `schema_id`, `edge_type`, and `body`.
350+
- Response includes `cell_id`, `from_id`, `to_id`, `direction`, `schema_id`, `edge_type`, and `body`.
351+
- `direction` uses the same wire values as graph DSL edge lists: `out`, `in`, `none`.
352+
- For `/v1/graph/query/edges`, `direction` is `out` for directed edges and `none` for undirected edges.
351353

352354
`POST /v1/graph/query/traverse`
353355

@@ -373,6 +375,8 @@ Request:
373375
- `max_depth` defaults to `1` when omitted.
374376
- Traversal runs in two stages: Neb lookup for seed IDs first, then graph BFS expansion from those seeds.
375377
- Duplicate discovered vertices are de-duplicated before vertex materialization in the final response.
378+
- `edges[*].from_id` / `to_id` describe the edge itself rather than the traversal step.
379+
- `edges[*].direction` reports how traversal reached that edge: `out`, `in`, or `none`.
376380

377381
Response:
378382
```json
@@ -385,6 +389,7 @@ Response:
385389
"id": "3Yv8v4Cw1W5Hk9p2LxQw9Q",
386390
"from_id": "5QqkMJFr5s7J9wB8Q2kN1J",
387391
"to_id": "2m8JqZfC2MbgQfE7x4oP6R",
392+
"direction": "out",
388393
"schema_id": 77,
389394
"edge_type": "Directed"
390395
}

crates/morpheus-http-client/src/types.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -629,6 +629,7 @@ pub struct GraphTraverseEdgeDto {
629629
pub id: Option<String>,
630630
pub from_id: String,
631631
pub to_id: String,
632+
pub direction: String,
632633
pub schema_id: u32,
633634
pub edge_type: Value,
634635
}
@@ -692,6 +693,7 @@ pub struct GraphLookupEdgeDto {
692693
pub cell_id: String,
693694
pub from_id: String,
694695
pub to_id: String,
696+
pub direction: String,
695697
pub schema_id: u32,
696698
pub edge_type: Value,
697699
pub body: Value,
@@ -714,6 +716,7 @@ pub struct DslSubgraphVertex {
714716
pub struct DslSubgraphEdge {
715717
pub from: String,
716718
pub to: String,
719+
pub direction: String,
717720
pub schema: String,
718721
pub fields: serde_json::Map<String, Value>,
719722
}

src/dsl/graph/exec_context.rs

Lines changed: 10 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@ use crate::dsl::exec_helpers::{
99
build_field_names, cell_data_to_json_map, distributed_directions_for_edge_schemas,
1010
id_to_base58, project_fields,
1111
};
12-
use crate::dsl::graph::json::{GraphRAGResult, GraphRAGSeedVertex, SubgraphEdge, SubgraphVertex};
12+
use crate::dsl::graph::json::{GraphRAGResult, GraphRAGSeedVertex, SubgraphVertex};
13+
use crate::dsl::graph::path_materialize::{expand_edge_report_key, materialize_expand_edges};
1314
use crate::dsl::query::bind::{extract_root_seed_ids, BoundGraphRAGQuery, BoundPredicate};
1415
use crate::graph::edge::EdgeType;
1516
use crate::server::schema::GraphSchema;
1617
use crate::server::MorpheusRuntime;
1718
use crate::traversal::dist::adjacency::{AdjacencyConfig, WeightSpec};
18-
use crate::traversal::expand::distributed::{ExpandJobOptions, ExpandRunConfig};
19-
use serde_json::Map as JsonMap;
19+
use crate::traversal::expand::distributed::{ExpandEdge, ExpandJobOptions, ExpandRunConfig};
2020

2121
pub async fn execute_context(
2222
runtime: &Arc<MorpheusRuntime>,
@@ -34,7 +34,7 @@ pub async fn execute_context(
3434

3535
// 2. BFS expand from seeds across all edge schemas.
3636
let mut expanded_vertex_ids: HashSet<Id> = HashSet::new();
37-
let mut all_edges: Vec<(Id, Id, u32, Option<Id>)> = Vec::new();
37+
let mut all_edges: Vec<ExpandEdge> = Vec::new();
3838
let directions = distributed_directions_for_edge_schemas(
3939
runtime,
4040
&query.expand.edge_schema_ids,
@@ -71,9 +71,7 @@ pub async fn execute_context(
7171
expanded_vertex_ids.insert(*id);
7272
}
7373
}
74-
for e in result.edges {
75-
all_edges.push((e.from, e.to, e.schema_id, e.edge_id));
76-
}
74+
all_edges.extend(result.edges);
7775
}
7876

7977
// 3. Build seed vertices with scores.
@@ -154,67 +152,21 @@ pub async fn execute_context(
154152
}
155153

156154
// Deduplicate and build edges.
157-
let mut seen_edges: HashSet<(Id, Id, u32)> = HashSet::new();
155+
let mut seen_edges = HashSet::new();
158156
let deduped: Vec<_> = all_edges
159157
.into_iter()
160-
.filter(|(from, to, schema_id, _)| {
158+
.filter(|edge| {
161159
let edge_type = runtime
162160
.schema_container()
163-
.schema_type(*schema_id)
161+
.schema_type(edge.schema_id)
164162
.and_then(|gs| match gs {
165163
GraphSchema::Edge(attrs) => Some(attrs.edge_type),
166164
_ => None,
167165
});
168-
let key = match edge_type {
169-
Some(EdgeType::Undirected) => {
170-
let (a, b) = if from.higher < to.higher
171-
|| (from.higher == to.higher && from.lower <= to.lower)
172-
{
173-
(*from, *to)
174-
} else {
175-
(*to, *from)
176-
};
177-
(a, b, *schema_id)
178-
}
179-
_ => (*from, *to, *schema_id),
180-
};
181-
seen_edges.insert(key)
166+
seen_edges.insert(expand_edge_report_key(edge, edge_type))
182167
})
183168
.collect();
184-
185-
let mut edges: Vec<SubgraphEdge> = Vec::with_capacity(deduped.len());
186-
for (from, to, schema_id, edge_body_id) in deduped {
187-
let schema_name = runtime
188-
.schema_container()
189-
.get_neb_schema(schema_id)
190-
.map(|s| s.name.clone())
191-
.unwrap_or_default();
192-
193-
let fields = if let Some(cell_id) = edge_body_id {
194-
match runtime.neb_client().read_cell(cell_id).await {
195-
Ok(Ok(cell)) => {
196-
let field_names = runtime
197-
.schema_container()
198-
.get_neb_schema(cell.header.schema)
199-
.map(|s| build_field_names(&s.fields));
200-
project_fields(
201-
cell_data_to_json_map(&cell.data, field_names.as_ref()),
202-
query.edge_select.as_deref(),
203-
)
204-
}
205-
_ => JsonMap::new(),
206-
}
207-
} else {
208-
JsonMap::new()
209-
};
210-
211-
edges.push(SubgraphEdge {
212-
from: id_to_base58(&from),
213-
to: id_to_base58(&to),
214-
schema: schema_name,
215-
fields,
216-
});
217-
}
169+
let edges = materialize_expand_edges(runtime, &deduped, query.edge_select.as_deref()).await?;
218170

219171
Ok(GraphRAGResult {
220172
seeds: seed_vertices,

src/dsl/graph/exec_path.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,9 +330,10 @@ pub async fn execute_paths(
330330
}
331331
for edge in &path.edges {
332332
let edge_key = format!(
333-
"{}|{}|{}|{}",
333+
"{}|{}|{:?}|{}|{}",
334334
edge.from,
335335
edge.to,
336+
edge.direction,
336337
edge.schema,
337338
serde_json::to_string(&edge.fields).unwrap_or_default()
338339
);

src/dsl/graph/exec_subgraph.rs

Lines changed: 11 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@ use crate::dsl::exec_helpers::{
88
build_field_names, cell_data_to_json_map, distributed_directions_for_edge_schemas,
99
id_to_base58, project_fields, rrf_rerank_ids,
1010
};
11-
use crate::dsl::graph::json::{SubgraphEdge, SubgraphResult, SubgraphVertex};
11+
use crate::dsl::graph::json::{SubgraphResult, SubgraphVertex};
12+
use crate::dsl::graph::path_materialize::{expand_edge_report_key, materialize_expand_edges};
1213
use crate::dsl::query::bind::{extract_root_seed_ids, BoundPredicate, BoundSubgraphQuery};
1314
use crate::graph::edge::EdgeType;
1415
use crate::server::schema::GraphSchema;
1516
use crate::server::MorpheusRuntime;
1617
use crate::traversal::dist::adjacency::{AdjacencyConfig, WeightSpec};
17-
use crate::traversal::expand::distributed::{ExpandJobOptions, ExpandRunConfig};
18+
use crate::traversal::expand::distributed::{ExpandEdge, ExpandJobOptions, ExpandRunConfig};
1819

1920
pub async fn execute_subgraph(
2021
runtime: &Arc<MorpheusRuntime>,
@@ -37,7 +38,7 @@ pub async fn execute_subgraph(
3738
// Also include the seed vertices themselves
3839
all_vertex_ids.extend(seeds.iter().copied());
3940

40-
let mut all_edges: Vec<(Id, Id, u32, Option<Id>)> = Vec::new();
41+
let mut all_edges: Vec<ExpandEdge> = Vec::new();
4142
let directions = distributed_directions_for_edge_schemas(
4243
runtime,
4344
&query.expand.edge_schema_ids,
@@ -70,9 +71,7 @@ pub async fn execute_subgraph(
7071
.map_err(|e| format!("subgraph expand failed: {e}"))?;
7172

7273
all_vertex_ids.extend(result.vertices.iter().copied());
73-
for e in result.edges {
74-
all_edges.push((e.from, e.to, e.schema_id, e.edge_id));
75-
}
74+
all_edges.extend(result.edges);
7675
}
7776

7877
// 3. Fetch vertex cells and serialize to SubgraphVertex
@@ -116,66 +115,22 @@ pub async fn execute_subgraph(
116115
// Deduplicate by canonical (from, to, schema_id).
117116
// For undirected schemas, (A,B) and (B,A) represent the same edge; normalise
118117
// them so that the smaller Id comes first before inserting into the seen set.
119-
let mut seen_edges: HashSet<(Id, Id, u32)> = HashSet::new();
118+
let mut seen_edges = HashSet::new();
120119
let deduped_edges: Vec<_> = all_edges
121120
.into_iter()
122-
.filter(|(from, to, schema_id, _)| {
121+
.filter(|edge| {
123122
let edge_type = runtime
124123
.schema_container()
125-
.schema_type(*schema_id)
124+
.schema_type(edge.schema_id)
126125
.and_then(|gs| match gs {
127126
GraphSchema::Edge(attrs) => Some(attrs.edge_type),
128127
_ => None,
129128
});
130-
let key = match edge_type {
131-
Some(EdgeType::Undirected) => {
132-
let (a, b) = if from.higher < to.higher
133-
|| (from.higher == to.higher && from.lower <= to.lower)
134-
{
135-
(*from, *to)
136-
} else {
137-
(*to, *from)
138-
};
139-
(a, b, *schema_id)
140-
}
141-
_ => (*from, *to, *schema_id),
142-
};
143-
seen_edges.insert(key)
129+
seen_edges.insert(expand_edge_report_key(edge, edge_type))
144130
})
145131
.collect();
146-
let mut edges = Vec::new();
147-
for (from, to, schema_id, edge_body_id) in deduped_edges {
148-
let schema_name = runtime
149-
.schema_container()
150-
.get_neb_schema(schema_id)
151-
.map(|s| s.name.clone())
152-
.unwrap_or_default();
153-
154-
let fields = if let Some(cell_id) = edge_body_id {
155-
match runtime.neb_client().read_cell(cell_id).await {
156-
Ok(Ok(cell)) => {
157-
let field_names = runtime
158-
.schema_container()
159-
.get_neb_schema(cell.header.schema)
160-
.map(|s| build_field_names(&s.fields));
161-
project_fields(
162-
cell_data_to_json_map(&cell.data, field_names.as_ref()),
163-
query.edge_select.as_deref(),
164-
)
165-
}
166-
_ => serde_json::Map::new(),
167-
}
168-
} else {
169-
serde_json::Map::new()
170-
};
171-
172-
edges.push(SubgraphEdge {
173-
from: id_to_base58(&from),
174-
to: id_to_base58(&to),
175-
schema: schema_name,
176-
fields,
177-
});
178-
}
132+
let edges = materialize_expand_edges(runtime, &deduped_edges, query.edge_select.as_deref())
133+
.await?;
179134

180135
Ok(SubgraphResult { vertices, edges })
181136
}

src/dsl/graph/json.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,10 +232,21 @@ pub struct SubgraphVertex {
232232
pub fields: serde_json::Map<String, serde_json::Value>,
233233
}
234234

235+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
236+
pub enum SubgraphEdgeDirection {
237+
#[serde(rename = "in")]
238+
In,
239+
#[serde(rename = "out")]
240+
Out,
241+
#[serde(rename = "none")]
242+
None,
243+
}
244+
235245
#[derive(Debug, Clone, Serialize, Deserialize)]
236246
pub struct SubgraphEdge {
237247
pub from: String,
238248
pub to: String,
249+
pub direction: SubgraphEdgeDirection,
239250
pub schema: String,
240251
pub fields: serde_json::Map<String, serde_json::Value>,
241252
}

0 commit comments

Comments
 (0)