uni_plugin/traits/algorithm.rs
1//! Graph algorithm plugins.
2//!
3//! Two surfaces: [`AlgorithmProvider`] for black-box algorithms (the
4//! existing `uni-algo` library style), and [`GraphView`] — the stable,
5//! read-only topology API a provider obtains from its [`AlgorithmHost`]
6//! via [`AlgorithmHost::project`] to walk the graph without depending on
7//! `uni-store` / `uni-algo` types.
8
9use std::sync::Arc;
10
11use datafusion::execution::SendableRecordBatchStream;
12use futures::future::BoxFuture;
13use uni_common::core::id::Vid;
14
15use crate::errors::FnError;
16
17/// Static signature of an algorithm.
18///
19/// `args` and `slices` are additive, defaulted fields (construct with
20/// `..Default::default()`): existing providers that leave them empty keep the
21/// legacy untyped `config_json` contract unchanged. A provider that declares
22/// `args` opts into host-side arity/type validation before it runs (proposal
23/// §4.6 / decision D7); a provider that declares `slices` opts into load-time
24/// capability-slice version negotiation (proposal §4.3 / decision D6).
25#[derive(Clone, Debug, Default)]
26pub struct AlgorithmSignature {
27 /// Output column schema.
28 pub output_fields: Vec<arrow_schema::Field>,
29 /// Markdown docs.
30 pub docs: String,
31 /// Declared positional arguments, in call order.
32 ///
33 /// Empty (the default) preserves the legacy behavior: arguments arrive as a
34 /// raw positional `config_json` array the provider parses itself. When
35 /// non-empty, the host validates arity and coerces each positional argument
36 /// against the declared [`NamedArgType`](crate::traits::procedure::NamedArgType) before the provider runs, filling
37 /// omitted trailing arguments from their declared defaults.
38 pub args: Vec<crate::traits::procedure::NamedArgType>,
39 /// Required capability slices, checked at load time.
40 ///
41 /// Empty (the default) means the algorithm targets only the always-present
42 /// `graph-compute@1` surface. A declared [`SliceReq`] whose version the host
43 /// does not provide fails the load with a clear error (`0x86A`) rather than a
44 /// mysterious runtime "unknown kernel op" trap.
45 pub slices: Vec<SliceReq>,
46}
47
48/// A required capability-slice version an algorithm declares in its signature.
49///
50/// The host checks each requirement against the slices it actually implements
51/// (today only `graph-compute@1`) when the algorithm loads, refusing a mismatch
52/// up front (proposal §4.3 / decision D6). Adding a slice or bumping a version is
53/// a forward-compatible, additive change: an algorithm that declares no slices is
54/// grandfathered onto the base surface.
55#[derive(Clone, Debug, PartialEq, Eq)]
56pub struct SliceReq {
57 /// The capability-slice name, e.g. `"graph-compute"`.
58 pub slice: smol_str::SmolStr,
59 /// The minimum slice version the algorithm requires.
60 pub version: u16,
61}
62
63/// The capability slices this host implements, for load-time negotiation.
64///
65/// A guest algorithm's declared [`SliceReq`]s are checked against this table
66/// when it loads. Today the host implements only `graph-compute@1`; a future
67/// slice (e.g. `tensor-compute@1`) is added here in lockstep with its kernels so
68/// negotiation stays a pure lookup (proposal §4.3 / §10).
69pub const HOST_CAPABILITY_SLICES: &[(&str, u16)] = &[("graph-compute", 1)];
70
71impl AlgorithmSignature {
72 /// Validates the declared capability slices against `host_slices`.
73 ///
74 /// Each requirement must be met by a host slice of the same name whose
75 /// version is at least the requested one. Pass [`HOST_CAPABILITY_SLICES`] for
76 /// the production surface.
77 ///
78 /// # Errors
79 /// Returns `0x86A` (`SliceVersionMismatch`) naming the first requirement the
80 /// host cannot satisfy (proposal §4.3 / §12, decision D6).
81 pub fn check_slices(&self, host_slices: &[(&str, u16)]) -> Result<(), FnError> {
82 for req in &self.slices {
83 let satisfied = host_slices
84 .iter()
85 .any(|(name, ver)| *name == req.slice.as_str() && *ver >= req.version);
86 if !satisfied {
87 return Err(FnError::new(
88 0x86A,
89 format!(
90 "algorithm requires capability slice `{}@{}` the host does not provide",
91 req.slice, req.version
92 ),
93 ));
94 }
95 }
96 Ok(())
97 }
98
99 /// Validates and normalizes a positional `config_json` array against `args`.
100 ///
101 /// When `args` is empty this is a no-op returning `config_json` unchanged, so
102 /// providers on the legacy untyped contract are unaffected. Otherwise it
103 /// parses the positional JSON array and, per declared argument: rejects a
104 /// present value whose JSON kind is incompatible with the declared
105 /// [`ArgType`](crate::traits::scalar::ArgType), errors on a missing argument
106 /// that has no default, and appends the declared default for an omitted
107 /// trailing argument. Extra positional arguments beyond the declared arity
108 /// are rejected. The returned JSON array is what the provider then parses, so
109 /// it observes defaults already filled in.
110 ///
111 /// # Errors
112 /// Returns `0x86E` (argument arity/type violation) with a message naming the
113 /// offending argument (proposal §4.6, decision D7).
114 pub fn coerce_config_json(&self, config_json: &str) -> Result<String, FnError> {
115 use crate::traits::scalar::ArgType;
116
117 if self.args.is_empty() {
118 return Ok(config_json.to_owned());
119 }
120 let mut provided: Vec<serde_json::Value> = if config_json.trim().is_empty() {
121 Vec::new()
122 } else {
123 serde_json::from_str(config_json)
124 .map_err(|e| FnError::new(0x86E, format!("bad positional config json: {e}")))?
125 };
126 if provided.len() > self.args.len() {
127 return Err(FnError::new(
128 0x86E,
129 format!(
130 "too many arguments: got {}, expected at most {}",
131 provided.len(),
132 self.args.len()
133 ),
134 ));
135 }
136 let mut out = Vec::with_capacity(self.args.len());
137 for (i, arg) in self.args.iter().enumerate() {
138 match provided.get_mut(i) {
139 Some(value) => {
140 let value = std::mem::replace(value, serde_json::Value::Null);
141 // A `CypherValue` argument is opaque and accepts any JSON.
142 if !matches!(arg.ty, ArgType::CypherValue)
143 && !json_matches_argtype(&value, &arg.ty)
144 {
145 return Err(FnError::new(
146 0x86E,
147 format!("argument `{}` (position {i}) has the wrong type", arg.name),
148 ));
149 }
150 out.push(value);
151 }
152 None => match &arg.default {
153 Some(default) => out.push(scalar_default_to_json(default)),
154 None => {
155 return Err(FnError::new(
156 0x86E,
157 format!("missing required argument `{}` (position {i})", arg.name),
158 ));
159 }
160 },
161 }
162 }
163 serde_json::to_string(&out)
164 .map_err(|e| FnError::new(0x86E, format!("re-encoding coerced config: {e}")))
165 }
166}
167
168/// Whether a JSON value is compatible with a declared primitive/vector arg type.
169fn json_matches_argtype(value: &serde_json::Value, ty: &crate::traits::scalar::ArgType) -> bool {
170 use arrow_schema::DataType;
171
172 use crate::traits::scalar::ArgType;
173 match ty {
174 ArgType::CypherValue => true,
175 ArgType::Vector { .. } => value.is_array(),
176 ArgType::Variadic(inner) => json_matches_argtype(value, inner),
177 ArgType::Primitive(dt) => match dt {
178 DataType::Boolean => value.is_boolean(),
179 DataType::Utf8 | DataType::LargeUtf8 => value.is_string(),
180 DataType::Float16 | DataType::Float32 | DataType::Float64 => value.is_number(),
181 d if d.is_integer() => value.is_i64() || value.is_u64(),
182 // Unknown/opaque primitive: don't reject, defer to the provider.
183 _ => true,
184 },
185 }
186}
187
188/// Renders a declared [`ScalarValue`](datafusion::scalar::ScalarValue) default as
189/// JSON to append for an omitted trailing argument.
190fn scalar_default_to_json(default: &datafusion::scalar::ScalarValue) -> serde_json::Value {
191 use datafusion::scalar::ScalarValue;
192
193 match default {
194 ScalarValue::Null => serde_json::Value::Null,
195 ScalarValue::Boolean(Some(b)) => serde_json::Value::Bool(*b),
196 ScalarValue::Float32(Some(x)) => serde_json::json!(*x),
197 ScalarValue::Float64(Some(x)) => serde_json::json!(*x),
198 ScalarValue::Int8(Some(x)) => serde_json::json!(*x),
199 ScalarValue::Int16(Some(x)) => serde_json::json!(*x),
200 ScalarValue::Int32(Some(x)) => serde_json::json!(*x),
201 ScalarValue::Int64(Some(x)) => serde_json::json!(*x),
202 ScalarValue::UInt8(Some(x)) => serde_json::json!(*x),
203 ScalarValue::UInt16(Some(x)) => serde_json::json!(*x),
204 ScalarValue::UInt32(Some(x)) => serde_json::json!(*x),
205 ScalarValue::UInt64(Some(x)) => serde_json::json!(*x),
206 ScalarValue::Utf8(Some(s)) | ScalarValue::LargeUtf8(Some(s)) => {
207 serde_json::Value::String(s.clone())
208 }
209 // Any other/None scalar defaults to JSON null.
210 _ => serde_json::Value::Null,
211 }
212}
213
214/// Per-invocation context passed to an [`AlgorithmProvider`].
215///
216/// `host` is an opaque [`AlgorithmHost`] callback the host populates
217/// when invoking the algorithm. Algorithms that need a concrete
218/// graph-projection / storage handle downcast through `host` rather
219/// than depend on `uni-store` / `uni-algo` types directly — this keeps
220/// `uni-plugin` free of upward dependencies.
221#[non_exhaustive]
222pub struct AlgorithmContext<'a> {
223 /// JSON-serialized algorithm configuration.
224 pub config_json: &'a str,
225 /// Optional opaque host handle. `None` when no host is bound — the
226 /// algorithm may fall back to a config-only path or surface an
227 /// `Unbound` error.
228 pub host: Option<&'a dyn AlgorithmHost>,
229}
230
231impl std::fmt::Debug for AlgorithmContext<'_> {
232 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
233 f.debug_struct("AlgorithmContext")
234 .field("config_json", &self.config_json)
235 .field("host_bound", &self.host.is_some())
236 .finish()
237 }
238}
239
240impl<'a> AlgorithmContext<'a> {
241 /// Construct an `AlgorithmContext` with no host bound.
242 #[must_use]
243 pub fn new(config_json: &'a str) -> Self {
244 Self {
245 config_json,
246 host: None,
247 }
248 }
249
250 /// Attach a host handle.
251 #[must_use]
252 pub fn with_host(mut self, host: &'a dyn AlgorithmHost) -> Self {
253 self.host = Some(host);
254 self
255 }
256}
257
258/// Host callback surfacing graph access to plugin algorithms.
259///
260/// A provider's [`AlgorithmProvider::run`] receives an [`AlgorithmHost`]
261/// through its [`AlgorithmContext`] and calls [`AlgorithmHost::project`]
262/// to materialize a [`GraphView`] over the requested subgraph. Hosts
263/// (e.g. `uni-plugin-builtin`) implement `project` by building a
264/// projection from their `StorageManager` / `L0Manager`; the
265/// [`AlgorithmHost::as_any`] downcast hook remains for hosts that expose
266/// additional concrete state. This keeps `uni-plugin` free of upward
267/// dependencies on `uni-store` / `uni-algo`.
268pub trait AlgorithmHost: Send + Sync {
269 /// Downcast hook — bridges implement this to expose the concrete
270 /// host type.
271 fn as_any(&self) -> &dyn std::any::Any;
272
273 /// Materialize a read-only [`GraphView`] over the subgraph named by
274 /// `spec`.
275 ///
276 /// The returned future is `'static` (owns its inputs) so a provider
277 /// can move it into the stream it returns from the synchronous
278 /// [`AlgorithmProvider::run`] and `.await` it there. The default
279 /// implementation reports that the host offers no graph access;
280 /// graph-capable hosts override it.
281 ///
282 /// # Errors
283 ///
284 /// Returns [`FnError`] if the host offers no graph access, the
285 /// caller lacks the required capability (e.g. `HostQuery`), or the
286 /// projection cannot be built.
287 fn project(
288 &self,
289 spec: &GraphProjectionSpec,
290 ) -> BoxFuture<'static, Result<Arc<dyn GraphView>, FnError>> {
291 let _ = spec;
292 Box::pin(async {
293 Err(FnError::new(
294 0x805,
295 "AlgorithmHost: project() is not supported by this host",
296 ))
297 })
298 }
299}
300
301/// Selects which subgraph an [`AlgorithmHost::project`] call materializes.
302///
303/// Empty `node_labels` / `edge_types` mean "all". `weight_property`
304/// names an edge property to expose through [`GraphView::out_weight`];
305/// `include_reverse` requests inbound adjacency ([`GraphView::in_neighbors`]).
306#[derive(Clone, Debug, Default)]
307pub struct GraphProjectionSpec {
308 /// Vertex labels to include; empty selects every label.
309 pub node_labels: Vec<String>,
310 /// Edge types to include; empty selects every type.
311 pub edge_types: Vec<String>,
312 /// Edge property surfaced as the traversal weight, if any.
313 pub weight_property: Option<String>,
314 /// Whether to also build inbound adjacency.
315 pub include_reverse: bool,
316}
317
318/// Stable, read-only topology view handed to a plugin algorithm.
319///
320/// Vertices are addressed by dense `u32` slots (`0..vertex_count`);
321/// [`GraphView::to_vid`] / [`GraphView::to_slot`] translate to and from
322/// external [`Vid`]s at the boundary. Neighbor accessors return neighbor
323/// *slots*, not vids. A `GraphView` reflects the subgraph named by the
324/// [`GraphProjectionSpec`] that produced it and does not observe later
325/// writes.
326///
327/// # Panics
328///
329/// [`GraphView::out_weight`] panics unless [`GraphView::has_weights`] is
330/// `true`, and [`GraphView::in_neighbors`] / [`GraphView::in_degree`]
331/// panic unless [`GraphView::has_reverse`] is `true`. Guard with those
332/// predicates before calling.
333pub trait GraphView: Send + Sync {
334 /// Number of vertices; valid slots are `0..vertex_count`.
335 fn vertex_count(&self) -> usize;
336
337 /// Total number of outbound edges.
338 fn edge_count(&self) -> usize;
339
340 /// Outbound neighbor slots of `slot`.
341 fn out_neighbors(&self, slot: u32) -> &[u32];
342
343 /// Number of outbound edges from `slot`.
344 fn out_degree(&self, slot: u32) -> u32;
345
346 /// Inbound neighbor slots of `slot`.
347 ///
348 /// # Panics
349 ///
350 /// Panics unless [`GraphView::has_reverse`] is `true`.
351 fn in_neighbors(&self, slot: u32) -> &[u32];
352
353 /// Number of inbound edges into `slot`.
354 ///
355 /// # Panics
356 ///
357 /// Panics unless [`GraphView::has_reverse`] is `true`.
358 fn in_degree(&self, slot: u32) -> u32;
359
360 /// Whether inbound adjacency is available.
361 fn has_reverse(&self) -> bool;
362
363 /// Weight of the `edge_idx`-th outbound edge of `slot`.
364 ///
365 /// `edge_idx` indexes into [`GraphView::out_neighbors`] of `slot`.
366 ///
367 /// # Panics
368 ///
369 /// Panics unless [`GraphView::has_weights`] is `true`.
370 fn out_weight(&self, slot: u32, edge_idx: usize) -> f64;
371
372 /// Whether edge weights are available.
373 fn has_weights(&self) -> bool;
374
375 /// Translate a dense slot to its external [`Vid`].
376 fn to_vid(&self, slot: u32) -> Vid;
377
378 /// Translate an external [`Vid`] to its dense slot, if present.
379 fn to_slot(&self, vid: Vid) -> Option<u32>;
380
381 /// Iterate over every `(slot, vid)` pair in the view.
382 fn vertices(&self) -> Box<dyn Iterator<Item = (u32, Vid)> + '_>;
383}
384
385/// A black-box graph algorithm.
386///
387/// The trait is intentionally minimal: a signature describing the output,
388/// plus a `run` method returning a streaming `RecordBatch` sequence. The
389/// algorithm is responsible for fetching graph data via host APIs (out of
390/// scope of this trait — `uni-algo` will provide a `GraphView` abstraction
391/// the host adapter passes via `AlgorithmContext` once those APIs are
392/// available).
393pub trait AlgorithmProvider: Send + Sync {
394 /// Static signature.
395 fn signature(&self) -> &AlgorithmSignature;
396
397 /// Execute the algorithm.
398 ///
399 /// # Errors
400 ///
401 /// Returns [`FnError`] if the algorithm cannot be started; per-batch
402 /// failures are signaled via `Err` items in the returned stream.
403 fn run(&self, ctx: AlgorithmContext<'_>) -> Result<SendableRecordBatchStream, FnError>;
404}
405
406#[cfg(test)]
407mod tests {
408 use arrow_schema::DataType;
409 use datafusion::scalar::ScalarValue;
410
411 use super::{AlgorithmSignature, HOST_CAPABILITY_SLICES, SliceReq};
412 use crate::traits::procedure::NamedArgType;
413 use crate::traits::scalar::ArgType;
414
415 fn sig_with(args: Vec<NamedArgType>, slices: Vec<SliceReq>) -> AlgorithmSignature {
416 AlgorithmSignature {
417 args,
418 slices,
419 ..Default::default()
420 }
421 }
422
423 fn arg(name: &str, ty: ArgType, default: Option<ScalarValue>) -> NamedArgType {
424 NamedArgType {
425 name: name.into(),
426 ty,
427 default,
428 doc: String::new(),
429 }
430 }
431
432 #[test]
433 fn check_slices_accepts_available_and_rejects_missing() {
434 // graph-compute@1 is the host surface; @1 passes, @2 and unknown fail 0x86A.
435 let ok = sig_with(
436 vec![],
437 vec![SliceReq {
438 slice: "graph-compute".into(),
439 version: 1,
440 }],
441 );
442 assert!(ok.check_slices(HOST_CAPABILITY_SLICES).is_ok());
443
444 let too_new = sig_with(
445 vec![],
446 vec![SliceReq {
447 slice: "graph-compute".into(),
448 version: 2,
449 }],
450 );
451 let err = too_new
452 .check_slices(HOST_CAPABILITY_SLICES)
453 .expect_err("graph-compute@2 must be refused");
454 assert_eq!(err.code, 0x86A, "slice mismatch is 0x86A");
455
456 let unknown = sig_with(
457 vec![],
458 vec![SliceReq {
459 slice: "tensor-compute".into(),
460 version: 1,
461 }],
462 );
463 assert_eq!(
464 unknown
465 .check_slices(HOST_CAPABILITY_SLICES)
466 .unwrap_err()
467 .code,
468 0x86A
469 );
470
471 // No declared slices is grandfathered onto the base surface.
472 assert!(
473 sig_with(vec![], vec![])
474 .check_slices(HOST_CAPABILITY_SLICES)
475 .is_ok()
476 );
477 }
478
479 #[test]
480 fn coerce_config_passes_through_when_untyped() {
481 // Empty `args` preserves the legacy raw contract byte-for-byte.
482 let s = sig_with(vec![], vec![]);
483 assert_eq!(s.coerce_config_json("[1, 2, 3]").unwrap(), "[1, 2, 3]");
484 }
485
486 #[test]
487 fn coerce_config_fills_defaults_and_validates() {
488 let s = sig_with(
489 vec![
490 arg("src", ArgType::CypherValue, None),
491 arg(
492 "alpha",
493 ArgType::Primitive(DataType::Float64),
494 Some(ScalarValue::Float64(Some(0.85))),
495 ),
496 ],
497 vec![],
498 );
499
500 // A single provided arg fills the omitted `alpha` default.
501 let out = s.coerce_config_json("[5]").unwrap();
502 let arr: Vec<serde_json::Value> = serde_json::from_str(&out).unwrap();
503 assert_eq!(arr.len(), 2, "the omitted default is appended");
504 assert_eq!(arr[0], serde_json::json!(5));
505 assert!((arr[1].as_f64().unwrap() - 0.85).abs() < 1e-12);
506
507 // A missing required arg is rejected.
508 let err = s.coerce_config_json("[]").expect_err("src is required");
509 assert_eq!(err.code, 0x86E);
510
511 // A wrong-typed alpha (string, not number) is rejected.
512 let err = s
513 .coerce_config_json(r#"[5, "not-a-number"]"#)
514 .expect_err("alpha must be numeric");
515 assert_eq!(err.code, 0x86E);
516
517 // Too many positional args is rejected.
518 assert_eq!(s.coerce_config_json("[5, 0.9, 1]").unwrap_err().code, 0x86E);
519
520 // A CypherValue arg accepts an array (the `sourceVids` shape).
521 let arr_src = s.coerce_config_json("[[1, 2, 3], 0.9]").unwrap();
522 let parsed: Vec<serde_json::Value> = serde_json::from_str(&arr_src).unwrap();
523 assert!(parsed[0].is_array(), "CypherValue accepts an array");
524 }
525}