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}
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}