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