Skip to main content

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}