1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
use crate::error::LaserError;
use crate::laser::Laser;
use crate::types::ConversationId;
impl Laser {
/// A handle to the knowledge-graph surface `name`. Traversals require the
/// `graph` capability (LaserData Cloud) and ride the managed binary
/// transport. Against Apache Iggy a fetch returns
/// [`LaserError::Unsupported`].
pub fn graph(&self, name: impl Into<String>) -> GraphHandle<'_> {
GraphHandle {
laser: self,
name: name.into(),
start: None,
hops: Vec::new(),
node_filter: None,
edge_filter: None,
return_: laser_wire::graph::GraphReturn::Nodes,
limit: laser_wire::limits::DEFAULT_RECALL_LIMIT,
as_of: None,
conversation: None,
}
}
}
/// A fluent knowledge-graph traversal, created by [`Laser::graph`]. Set a start
/// (`start_ids`/`start_match`/`start_nearest`), add hops (`out`/`incoming`), pick
/// what to return, and finish with `.fetch().await`. Gated on the `graph`
/// feature: a traversal rides the managed binary transport.
pub struct GraphHandle<'a> {
laser: &'a Laser,
name: String,
start: Option<laser_wire::graph::GraphStart>,
hops: Vec<laser_wire::graph::Hop>,
node_filter: Option<laser_wire::query::Filter>,
edge_filter: Option<laser_wire::query::Filter>,
return_: laser_wire::graph::GraphReturn,
limit: usize,
as_of: Option<u64>,
conversation: Option<String>,
}
impl GraphHandle<'_> {
/// Narrow the traversal to elements a single conversation asserted (the
/// conversation lens): only nodes and edges whose source records that
/// `conversation` are traversed and returned. Applies to both `fetch` and
/// `neighbors`. Reads the whole graph when unset.
#[must_use]
pub fn conversation(mut self, conversation: ConversationId) -> Self {
self.conversation = Some(conversation.to_string());
self
}
/// Start the traversal from explicit node ids.
#[must_use]
pub fn start_ids(mut self, ids: Vec<laser_wire::graph::NodeId>) -> Self {
self.start = Some(laser_wire::graph::GraphStart::Ids(ids));
self
}
/// Start from the nodes matching a predicate.
#[must_use]
pub fn start_match(mut self, filter: laser_wire::query::Filter) -> Self {
self.start = Some(laser_wire::graph::GraphStart::Match(filter));
self
}
/// Start from the nodes nearest an embedding (vector-seeded traversal).
#[must_use]
pub fn start_nearest(mut self, embedding: Vec<f32>, k: usize) -> Self {
self.start = Some(laser_wire::graph::GraphStart::Nearest { embedding, k });
self
}
/// Follow outgoing edges of `edge_type` one hop.
#[must_use]
pub fn out(mut self, edge_type: impl Into<String>) -> Self {
self.hops.push(laser_wire::graph::Hop {
edge_type: Some(edge_type.into()),
dir: laser_wire::graph::EdgeDir::Out,
max: 1,
});
self
}
/// Follow incoming edges of `edge_type` one hop.
#[must_use]
pub fn incoming(mut self, edge_type: impl Into<String>) -> Self {
self.hops.push(laser_wire::graph::Hop {
edge_type: Some(edge_type.into()),
dir: laser_wire::graph::EdgeDir::In,
max: 1,
});
self
}
/// Follow edges of `edge_type` one hop in both directions.
#[must_use]
pub fn both(mut self, edge_type: impl Into<String>) -> Self {
self.hops.push(laser_wire::graph::Hop {
edge_type: Some(edge_type.into()),
dir: laser_wire::graph::EdgeDir::Both,
max: 1,
});
self
}
/// Return the traversed edges instead of the reachable nodes.
#[must_use]
pub fn return_edges(mut self) -> Self {
self.return_ = laser_wire::graph::GraphReturn::Edges;
self
}
/// Return the traversed edges as `(source, type, destination)` triplets
/// instead of the reachable nodes.
#[must_use]
pub fn return_triplets(mut self) -> Self {
self.return_ = laser_wire::graph::GraphReturn::Triplets;
self
}
/// Return whole paths (node and edge id sequences) instead of nodes.
#[must_use]
pub fn return_paths(mut self) -> Self {
self.return_ = laser_wire::graph::GraphReturn::Paths;
self
}
/// Cap the number of elements returned.
#[must_use]
pub fn limit(mut self, limit: usize) -> Self {
self.limit = limit;
self
}
/// Read the graph as of `micros` (valid-time, epoch micros): only edges whose
/// valid-time window contains that instant are traversed. Applies to both
/// `fetch` and `neighbors`.
#[must_use]
pub fn as_of(mut self, micros: u64) -> Self {
self.as_of = Some(micros);
self
}
/// Run the traversal. Requires `managed_graph`. Otherwise returns
/// [`LaserError::Unsupported`].
pub async fn fetch(self) -> Result<laser_wire::graph::GraphResult, LaserError> {
use laser_wire::graph::{GraphQuery, GraphStart};
self.require_graph()?;
let query = GraphQuery {
v: laser_wire::codes::GRAPH_OP_VERSION,
graph: self.name,
start: self.start.unwrap_or(GraphStart::Ids(Vec::new())),
traverse: self.hops,
node_filter: self.node_filter,
edge_filter: self.edge_filter,
return_: self.return_,
limit: self.limit,
fork: None,
consistency: laser_wire::query::Consistency::Eventual,
as_of: self.as_of,
conversation: self.conversation,
};
let payload = laser_wire::framing::encode_named(&query)
.map_err(|error| LaserError::Codec(format!("encode graph query: {error}")))?;
let payload = self
.laser
.send_raw_with_response(laser_wire::codes::AGDX_GRAPH_QUERY_CODE, payload)
.await?;
decode_graph_reply(&payload)
}
/// Read a node's neighbors: the nodes reachable in `dir` over `edge_type` (or
/// any type when `None`), following the same hop `depth` times. The cheap,
/// common traversal. Requires `managed_graph`. Otherwise returns
/// [`LaserError::Unsupported`].
pub async fn neighbors(
self,
node: laser_wire::graph::NodeId,
dir: laser_wire::graph::EdgeDir,
edge_type: Option<String>,
depth: u32,
) -> Result<laser_wire::graph::GraphResult, LaserError> {
use laser_wire::graph::GraphNeighbors;
self.require_graph()?;
let request = GraphNeighbors {
v: laser_wire::codes::GRAPH_OP_VERSION,
graph: self.name,
node,
dir,
edge_type,
depth,
limit: self.limit,
as_of: self.as_of,
conversation: self.conversation,
};
let payload = laser_wire::framing::encode_named(&request)
.map_err(|error| LaserError::Codec(format!("encode graph neighbors: {error}")))?;
let payload = self
.laser
.send_raw_with_response(laser_wire::codes::AGDX_GRAPH_NEIGHBORS_CODE, payload)
.await?;
decode_graph_reply(&payload)
}
/// Relate two entities in one call: `link("customer:42", "opened",
/// "ticket:7")` upserts both content-addressed entity nodes and the typed
/// edge between them. Sugar over [`upsert`](Self::upsert), so re-linking
/// the same triple converges on the same nodes and edge. The entity ids
/// double as their labels' values (`kind:value` strings work well).
pub async fn link(
self,
from: impl Into<String>,
relation: impl Into<String>,
to: impl Into<String>,
) -> Result<(), LaserError> {
use laser_wire::graph::GraphEdge;
let from = entity_node(&from.into());
let to = entity_node(&to.into());
let edge = GraphEdge::relate(&from, relation, &to);
self.upsert(vec![from, to], vec![edge]).await
}
/// Assert the latest value of a single-valued relationship: close every
/// live `relation` edge from `from` that points at a DIFFERENT target
/// (`valid_to` now, the bitemporal supersede), then link `from` to `to`.
/// The deterministic fact-invalidation mechanics (same subject, same
/// predicate, new object supersedes the old), returning how many edges
/// were closed. Contradiction detection beyond same-subject-same-predicate
/// is the caller's policy, and recording an `improve` feedback item that
/// links superseded to superseding is the caller's memory concern.
pub async fn relink(
self,
from: impl Into<String>,
relation: impl Into<String>,
to: impl Into<String>,
) -> Result<usize, LaserError> {
use laser_wire::graph::EdgeDir;
let laser = self.laser;
let graph = self.name.clone();
let from = from.into();
let relation = relation.into();
let to = to.into();
let from_node = entity_node(&from);
let to_node = entity_node(&to);
let live = laser
.graph(&graph)
.neighbors(from_node.id, EdgeDir::Out, Some(relation.clone()), 1)
.await?;
let now = now_micros();
let superseded: Vec<laser_wire::graph::GraphEdge> = live
.edges
.into_iter()
.filter(|edge| edge.to != to_node.id && edge.valid_to.is_none())
.map(|mut edge| {
edge.valid_to = Some(now);
edge
})
.collect();
let closed = superseded.len();
if !superseded.is_empty() {
laser.graph(&graph).upsert(Vec::new(), superseded).await?;
}
self.link(from, relation, to).await?;
Ok(closed)
}
/// Close the relationship `link` opened: rewrite the edge with `valid_to`
/// now, so the fact is superseded without being destroyed (a bitemporal
/// close). The nodes stay.
pub async fn unlink(
self,
from: impl Into<String>,
relation: impl Into<String>,
to: impl Into<String>,
) -> Result<(), LaserError> {
use laser_wire::graph::GraphEdge;
let from = entity_node(&from.into());
let to = entity_node(&to.into());
let mut edge = GraphEdge::relate(&from, relation, &to);
edge.valid_to = Some(now_micros());
self.upsert(Vec::new(), vec![edge]).await
}
/// Write `nodes` and `edges` into the graph: the projector path, surfaced for
/// callers that build the graph directly rather than through a `graph`
/// projection. Idempotent on content-addressed ids
/// ([`GraphNode::entity`](laser_wire::graph::GraphNode::entity),
/// [`GraphEdge::relate`](laser_wire::graph::GraphEdge::relate)), so re-applying
/// the same entities is a no-op. Requires `managed_graph`. Otherwise returns
/// [`LaserError::Unsupported`].
pub async fn upsert(
self,
nodes: Vec<laser_wire::graph::GraphNode>,
edges: Vec<laser_wire::graph::GraphEdge>,
) -> Result<(), LaserError> {
use laser_wire::graph::GraphUpsert;
use laser_wire::validate::Validate;
self.require_graph()?;
let request = GraphUpsert {
v: laser_wire::codes::GRAPH_OP_VERSION,
graph: self.name,
nodes,
edges,
};
// Spend the cap failure locally rather than amplifying an over-cap
// upsert against the managed plane, which rejects it with the same
// reason.
request.validate()?;
let payload = laser_wire::framing::encode_named(&request)
.map_err(|error| LaserError::Codec(format!("encode graph upsert: {error}")))?;
let payload = self
.laser
.send_raw_with_response(laser_wire::codes::AGDX_GRAPH_UPSERT_CODE, payload)
.await?;
decode_graph_reply(&payload).map(|_| ())
}
// Every graph op rides the managed binary transport, so it is unavailable
// against Apache Iggy. Fail the same way before encoding any request.
fn require_graph(&self) -> Result<(), LaserError> {
laser_wire::graph::validate_graph_name(&self.name)?;
if self.laser.current_capabilities().graph {
Ok(())
} else {
Err(LaserError::unsupported(
"graph",
"graph traversal is not served by this deployment",
))
}
}
}
// Decode a managed `GraphReply` into the `Ok` result or its typed error.
fn decode_graph_reply(payload: &[u8]) -> Result<laser_wire::graph::GraphResult, LaserError> {
use laser_wire::graph::GraphReply;
match crate::error::decode_managed_reply::<GraphReply>(payload)? {
GraphReply::Ok(result) => Ok(result),
GraphReply::Err(error) => Err(error.into()),
_ => Err(LaserError::Protocol(
"graph: unknown reply variant".to_owned(),
)),
}
}
/// The entity node for a `kind:value` style id: the id string is both the
/// label-ish identity and the `value` attribute, so `link`ed entities converge
/// on one content-addressed node per id.
fn entity_node(id: &str) -> laser_wire::graph::GraphNode {
let (label, value) = id.split_once(':').unwrap_or(("entity", id));
laser_wire::graph::GraphNode::entity(label, value)
}
fn now_micros() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| u64::try_from(duration.as_micros()).unwrap_or(u64::MAX))
.unwrap_or(0)
}