graph_storage_sdk/plugin_api.rs
1//! The three plugin contracts of the graph-storage gateway.
2//!
3//! The gear is a stateless gateway over a pluggable store: every byte it
4//! serves comes from a [`GraphStoreV1`] implementation behind the port, and
5//! the built-in `PostgreSQL` store is registered exactly as an external plugin
6//! would be. `GraphEngineV1` serves traversal expansion;
7//! `EmbeddingProviderV1` turns text into vectors.
8//!
9//! An implementation that cannot provide an obligation declares the
10//! corresponding capability absent and returns `Unsupported` from the
11//! affected method. It never implements a weaker version — a silently
12//! weakened guarantee is worse than an absent capability, because the gear
13//! can route around the second and not the first.
14
15use async_trait::async_trait;
16use thiserror::Error;
17use tokio_util::sync::CancellationToken;
18use toolkit_security::AccessScope;
19
20use crate::models::{
21 ComponentReadiness, DeleteOutcome, DeleteRequest, Direction, EdgeKey, EdgeRef, EdgeView,
22 EmbeddingSpaceId, EngineCapabilities, GraphRevision, GtsTypeId, HopBudget, IngestOutcome,
23 IngestRequest, ItemError, LabelAssignment, LabelFilter, LabelId, LabelRecord, LabelSpec,
24 NodeId, NodeKey, NodeRow, NodeView, Page, ProjectionRequest, ReadSnapshot, RegisteredType,
25 RemainingBudget, RevisionOutcome, SearchRequest, SearchResponse, SourceNamespaceOwner,
26 StoreCapabilities, Subject, TenantId, TopologyPage, TopologyRequest, TruncationReason,
27 TypeIdSet, TypeQuery, TypeRecord, TypeRegistration, TypeRegistrationOptions,
28};
29
30/// Per-call context. The compiled scope is mandatory, not optional:
31/// authorization has to reach inside the statements (a search arm applies it
32/// before ranking and LIMIT), so it cannot be a filter the gear applies to
33/// whatever the plugin returns.
34///
35/// `scope` living here rather than in a per-method argument is what makes the
36/// request-level decline possible: an implementation inspects the compiled
37/// scope it is about to serve and may answer `ScopeUnservable` instead of a
38/// result, which the gateway resolves by falling back. It is a routing
39/// signal, not a failure, and it never reaches the caller.
40pub struct StoreCtx<'a> {
41 pub tenant: TenantId,
42 pub scope: &'a AccessScope,
43 /// The acting subject, stamped onto the audit envelope of every element a
44 /// write in this call creates, updates or tombstones (`fr-audit-envelope`).
45 pub subject: Subject,
46 /// Present when the call participates in a compound read that must
47 /// observe one graph state (Read Consistency Contract).
48 pub snapshot: Option<&'a ReadSnapshot>,
49 /// What is left of the operation's absolute deadline.
50 pub budget: RemainingBudget,
51 pub cancel: CancellationToken,
52}
53
54/// Store-side failure vocabulary. The gear normalizes these into canonical
55/// errors before they cross the public boundary; no vendor text survives.
56#[derive(Debug, Error)]
57#[non_exhaustive]
58pub enum GraphStoreError {
59 /// Request-level decline: this implementation cannot serve the compiled
60 /// scope. Routed by the gateway, never surfaced to the caller directly.
61 #[error("scope unservable: {reason}")]
62 ScopeUnservable { reason: String },
63 /// The capability is declared absent for this store.
64 #[error("unsupported: {what}")]
65 Unsupported { what: &'static str },
66 /// Per-item validation failures; the batch committed nothing.
67 #[error("{} item(s) failed validation", items.len())]
68 Validation { items: Vec<ItemError> },
69 /// Same-key different-type ingest, expected-version mismatch, or an
70 /// equal-generation replacement with different content.
71 #[error("conflict: {reason}")]
72 Conflict { reason: String },
73 /// Serialization failure under concurrent ingest; retry unchanged.
74 #[error("serialization failure")]
75 Serialization,
76 /// Older source generation for a scope; drop the stale run.
77 #[error("stale generation: recorded {recorded}, offered {offered}")]
78 StaleGeneration { recorded: i64, offered: i64 },
79 /// Idempotency key reused with a different request.
80 #[error("idempotency key reused with a different request")]
81 IdempotencyMismatch,
82 /// Receipt expired (or from a previous source epoch); reconcile first.
83 #[error("idempotency receipt expired")]
84 IdempotencyExpired,
85 /// Unauthorized or unknown resource — indistinguishable by contract.
86 #[error("not found")]
87 NotFound,
88 /// A documented hard bound was exceeded.
89 #[error("limit exceeded: {what}")]
90 LimitExceeded { what: String },
91 /// A write under a source namespace bound to another producer principal.
92 ///
93 /// Deliberately **not** `NotFound`: everywhere else a denial is
94 /// indistinguishable from absence (anti-enumeration), but here the caller
95 /// named a namespace whose owner is a fact about the tenant, not about
96 /// them, and telling them it does not exist would send them to create it.
97 /// DESIGN § Error Model maps this to `permission_denied` /
98 /// `SOURCE_NAMESPACE_FORBIDDEN`, "never retry; request ownership
99 /// transfer".
100 #[error("source namespace `{namespace}` is owned by another producer")]
101 SourceNamespaceForbidden { namespace: String },
102 /// The query itself is malformed — an unknown filter field, an
103 /// unparseable cursor, an ordering the store cannot serve. Distinct from
104 /// `LimitExceeded`: nothing here is about a bound, and telling a caller
105 /// "reduce the value" when they named a field that does not exist sends
106 /// them the wrong way.
107 #[error("invalid query: {what}")]
108 InvalidQuery { what: String },
109 /// Durable corruption detected; operator action.
110 #[error("store corrupt: {reason}")]
111 Corrupt { reason: String },
112 #[error("store unavailable: {reason}")]
113 Unavailable { reason: String },
114 #[error("deadline exceeded")]
115 Deadline,
116 #[error("cancelled")]
117 Cancelled,
118 /// Unexpected failure; details stay in access-controlled logs.
119 #[error("internal store error: {0}")]
120 Internal(String),
121}
122
123/// The store plugin contract (`cpt-cf-graph-storage-contract-graph-store-plugin`).
124///
125/// Five obligations are carried by specific methods and asserted by the
126/// conformance suite against both the built-in store and the in-memory fake:
127/// batch atomicity (`ingest`), single-writer serialization per scope identity
128/// (`ingest` + `replace_scope`), monotonic generation fencing
129/// (`replace_scope.generation`), no node removed while a live edge references
130/// it (`soft_delete`), and one snapshot across every arm of one read
131/// (`begin_read`).
132#[async_trait]
133pub trait GraphStoreV1: Send + Sync + 'static {
134 /// What this store provides. Anything absent here is answered
135 /// `Unsupported` by the methods below, never approximated.
136 fn capabilities(&self) -> StoreCapabilities;
137
138 // --- ontology ---------------------------------------------------------
139 /// Register a batch atomically.
140 ///
141 /// `options.on_existing` decides what a changed schema under a registered
142 /// identifier means: `Reject` (the default) conflicts, `Update` admits the
143 /// change when it is admissible. A byte-identical re-registration
144 /// converges under either. `options.dry_run` computes every verdict and
145 /// writes nothing, so a caller can ask what an edit costs before making
146 /// it; a dry run therefore reports a refusal in the result rather than as
147 /// an error.
148 async fn register_types_with(
149 &self,
150 ctx: &StoreCtx<'_>,
151 batch: Vec<TypeRegistration>,
152 options: TypeRegistrationOptions,
153 ) -> Result<Vec<RegisteredType>, GraphStoreError>;
154
155 /// Register a batch under the default options, keeping only the records.
156 ///
157 /// Provided, not implemented: one code path decides admission, so the
158 /// convenience form cannot drift from the form that carries the options.
159 async fn register_types(
160 &self,
161 ctx: &StoreCtx<'_>,
162 batch: Vec<TypeRegistration>,
163 ) -> Result<Vec<TypeRecord>, GraphStoreError> {
164 let registered = self
165 .register_types_with(ctx, batch, TypeRegistrationOptions::default())
166 .await?;
167 Ok(registered.into_iter().map(|item| item.record).collect())
168 }
169 async fn get_type(
170 &self,
171 ctx: &StoreCtx<'_>,
172 id: &GtsTypeId,
173 ) -> Result<TypeRecord, GraphStoreError>;
174 async fn list_types(
175 &self,
176 ctx: &StoreCtx<'_>,
177 query: TypeQuery,
178 ) -> Result<Page<TypeRecord>, GraphStoreError>;
179 /// Probe what this store can answer for, without a tenant or a scope.
180 ///
181 /// Readiness is reached before authentication — the matrix leaves the
182 /// health endpoints available when the authorization resolver is down —
183 /// so this is the one store call that takes no `StoreCtx`. It reports the
184 /// rows only the store can answer: the database and its migrations, and
185 /// the traversal backend the server actually provides.
186 async fn probe_readiness(&self) -> Vec<ComponentReadiness>;
187
188 // --- source namespaces ------------------------------------------------
189 /// The namespaces claimed in this tenant, with the principal bound to
190 /// each. A read of the ownership boundary itself, for an operator who has
191 /// to answer "who owns this source".
192 async fn list_source_namespaces(
193 &self,
194 ctx: &StoreCtx<'_>,
195 ) -> Result<Vec<SourceNamespaceOwner>, GraphStoreError>;
196
197 /// Bind `namespace` to `owner_principal`, recording who moved it.
198 ///
199 /// The only way a namespace changes hands: writing under someone else's
200 /// namespace is refused rather than treated as a claim, so there is no
201 /// implicit transfer. The caller is authorized for ontology
202 /// administration, not merely for writing.
203 async fn transfer_source_namespace(
204 &self,
205 ctx: &StoreCtx<'_>,
206 namespace: &str,
207 owner_principal: &str,
208 ) -> Result<SourceNamespaceOwner, GraphStoreError>;
209
210 /// Resolve GTS patterns to the set of registered types they cover, so a
211 /// caller's type filter and an authorizing permission's pattern can be
212 /// intersected on one representation.
213 async fn resolve_type_set(
214 &self,
215 ctx: &StoreCtx<'_>,
216 patterns: &[String],
217 ) -> Result<TypeIdSet, GraphStoreError>;
218
219 // --- write ------------------------------------------------------------
220 /// Nodes, edges and the idempotency record commit together or not at all.
221 /// A replay of a recorded key returns `IngestOutcome { replayed: true }`
222 /// without touching state.
223 ///
224 /// `embedding` carries one entry per node of `req`, in order: the vector
225 /// to store if this request embedded, and the canonical hash of the text
226 /// it was composed from either way. A store does not compose or embed
227 /// anything — that is the coordinator's, so that ingest and query cannot
228 /// diverge.
229 async fn ingest(
230 &self,
231 ctx: &StoreCtx<'_>,
232 req: IngestRequest,
233 embedding: EmbeddingPlan,
234 ) -> Result<IngestOutcome, GraphStoreError>;
235 /// Tombstone a node with its incident edges, or a single edge.
236 async fn soft_delete(
237 &self,
238 ctx: &StoreCtx<'_>,
239 req: DeleteRequest,
240 ) -> Result<DeleteOutcome, GraphStoreError>;
241
242 // --- labels -----------------------------------------------------------
243 // Labels are deferred: no store in this release provides them and the
244 // gear routes nothing to these methods. The slots are in the contract so
245 // that labels can arrive as `StoreCapabilities::labels` turning true
246 // rather than as a new trait version, and they default to `Unsupported`
247 // so that a store which does not provide them — every store today —
248 // writes nothing for them. A store that sets the capability overrides
249 // all four.
250 async fn upsert_label(
251 &self,
252 _ctx: &StoreCtx<'_>,
253 _label: LabelSpec,
254 ) -> Result<LabelRecord, GraphStoreError> {
255 Err(GraphStoreError::Unsupported { what: "labels" })
256 }
257 async fn delete_label(
258 &self,
259 _ctx: &StoreCtx<'_>,
260 _id: LabelId,
261 ) -> Result<RevisionOutcome, GraphStoreError> {
262 Err(GraphStoreError::Unsupported { what: "labels" })
263 }
264 async fn list_labels(&self, _ctx: &StoreCtx<'_>) -> Result<Vec<LabelRecord>, GraphStoreError> {
265 Err(GraphStoreError::Unsupported { what: "labels" })
266 }
267 async fn assign_labels(
268 &self,
269 _ctx: &StoreCtx<'_>,
270 _req: LabelAssignment,
271 ) -> Result<RevisionOutcome, GraphStoreError> {
272 Err(GraphStoreError::Unsupported { what: "labels" })
273 }
274
275 // --- read -------------------------------------------------------------
276 /// Open a snapshot for a compound read. Every subsequent call carrying it
277 /// in `StoreCtx` observes one graph state.
278 async fn begin_read(&self, ctx: &StoreCtx<'_>) -> Result<ReadSnapshot, GraphStoreError>;
279 /// Close a snapshot opened by `begin_read`, releasing whatever holds it.
280 async fn end_read(&self, snapshot: ReadSnapshot) -> Result<(), GraphStoreError>;
281 async fn revision(&self, ctx: &StoreCtx<'_>) -> Result<GraphRevision, GraphStoreError>;
282 async fn get_node(
283 &self,
284 ctx: &StoreCtx<'_>,
285 key: &NodeKey,
286 adjacency_limit: u32,
287 ) -> Result<NodeView, GraphStoreError>;
288 async fn hydrate_nodes(
289 &self,
290 ctx: &StoreCtx<'_>,
291 ids: &[NodeId],
292 ) -> Result<Vec<NodeView>, GraphStoreError>;
293 /// The type of each live node named, under the caller's scope, without
294 /// reading its row: tombstoned, unknown and unauthorized ids are absent
295 /// from the answer alike, as they are from `hydrate_nodes`.
296 ///
297 /// A read that filters its output by type asks this first, so it
298 /// hydrates only the rows it will return and charges every row it reads
299 /// against its byte budget. Optional: the default answers `Unsupported`,
300 /// and the gear then hydrates and filters afterwards, which returns the
301 /// same answer at the cost of reading rows it discards.
302 ///
303 /// The order of the pairs is not part of the contract. The gear keys the
304 /// answer by id and never reads it in sequence; the built-in store
305 /// answers in whatever order the statement returns, and a store that
306 /// preserves the order asked promises nothing more by doing so.
307 async fn node_types(
308 &self,
309 _ctx: &StoreCtx<'_>,
310 _ids: &[NodeId],
311 ) -> Result<Vec<(NodeId, GtsTypeId)>, GraphStoreError> {
312 Err(GraphStoreError::Unsupported { what: "node_types" })
313 }
314 /// One edge as an element, with its payload and audit envelope
315 /// (`fr-audit-envelope`, which asks for the envelope on every node *and
316 /// edge* a read surface returns). Scoped like a node read: an edge either
317 /// of whose endpoints lies outside the caller's scope reads as absent.
318 async fn get_edge(
319 &self,
320 ctx: &StoreCtx<'_>,
321 key: &EdgeKey,
322 ) -> Result<EdgeView, GraphStoreError>;
323 /// One call, not one per arm: the scope must apply inside each arm before
324 /// UNION, ranking and LIMIT, and RRF needs each arm's ranks.
325 async fn search(
326 &self,
327 ctx: &StoreCtx<'_>,
328 req: SearchRequest,
329 vector: Option<VectorArm>,
330 ) -> Result<SearchResponse, GraphStoreError>;
331 async fn project_table(
332 &self,
333 ctx: &StoreCtx<'_>,
334 req: ProjectionRequest,
335 ) -> Result<toolkit_odata::Page<NodeRow>, GraphStoreError>;
336 /// Node keys with their type and typed edge pairs, tombstoned rows
337 /// excluded, paged. A store that cannot expose it declares the capability
338 /// absent, and analytics is unavailable in that deployment (ADR-0007).
339 async fn load_topology(
340 &self,
341 ctx: &StoreCtx<'_>,
342 req: TopologyRequest,
343 ) -> Result<TopologyPage, GraphStoreError>;
344
345 // --- keys -------------------------------------------------------------
346 /// Resolve producer keys to internal ids under the caller's scope.
347 /// Unknown and unauthorized keys are absent from the answer alike
348 /// (anti-enumeration).
349 async fn resolve_node_ids(
350 &self,
351 ctx: &StoreCtx<'_>,
352 keys: &[NodeKey],
353 ) -> Result<Vec<(NodeKey, NodeId)>, GraphStoreError>;
354
355 // --- embeddings -------------------------------------------------------
356 /// What this store already holds of each key's vector, index-aligned with
357 /// `keys`: `None` for a key that is unknown, tombstoned or outside the
358 /// caller's scope (anti-enumeration, as `resolve_node_ids`).
359 ///
360 /// The coordinator reads this *before* embedding a batch so a node whose
361 /// text has not changed is not embedded again — the difference between a
362 /// re-sync that costs what it changes and one that costs a full import.
363 /// Read outside the write transaction on purpose: it is an optimization,
364 /// and the transaction's own `decide_vector` still settles every state.
365 async fn embedding_state(
366 &self,
367 ctx: &StoreCtx<'_>,
368 keys: &[NodeKey],
369 ) -> Result<Vec<Option<EmbeddingState>>, GraphStoreError>;
370}
371
372/// What a store holds of one node's vector, for the coordinator's skip
373/// decision. Two facts, because both are needed: a vector is worth keeping
374/// only if it was made from the node's *current* text (`input_hash`) and is
375/// rankable under the *current* space (`vector_epoch`).
376#[derive(Clone, Debug, PartialEq, Eq)]
377pub struct EmbeddingState {
378 /// Hash of the text the stored vector was made from, when a vector is
379 /// stored; the hash of the current input otherwise.
380 pub input_hash: Option<String>,
381 /// The epoch the stored vector is current under. `None` when there is no
382 /// vector, or when it is stale.
383 pub vector_epoch: Option<i64>,
384}
385
386/// Engine-side failure vocabulary.
387#[derive(Debug, Error)]
388#[non_exhaustive]
389pub enum GraphEngineError {
390 /// The engine cannot enforce a property of this scope. The port serves
391 /// the request on the fallback hop and logs the reason — a typed error,
392 /// never a best-effort weaker predicate.
393 #[error("scope not enforceable: {reason}")]
394 ScopeNotEnforceable { reason: String },
395 #[error("unsupported: {what}")]
396 Unsupported { what: &'static str },
397 #[error("engine unavailable: {reason}")]
398 Unavailable { reason: String },
399 #[error("deadline exceeded")]
400 Deadline,
401 #[error("cancelled")]
402 Cancelled,
403 #[error("internal engine error: {0}")]
404 Internal(String),
405}
406
407/// Directed one-hop expansion request. Direction is explicit because the
408/// undirected shorthand plans as an all-vertex probe; expansion is a one-hop
409/// primitive because multi-hop chain patterns enumerate paths and explode on
410/// hubs.
411pub struct ExpandRequest {
412 pub frontier: Vec<NodeId>,
413 pub direction: Direction,
414 /// Per-hop restriction, already resolved to registered types.
415 pub edge_types: Option<TypeIdSet>,
416 /// Per-hop restriction (labels are deferred; engines may answer
417 /// `Unsupported`).
418 pub labels: Option<LabelFilter>,
419 pub budget: HopBudget,
420 /// Ask for `ExpandResponse::degrees` to be filled.
421 ///
422 /// Off by default because it costs a second scoped read: the edges
423 /// incident to the *reached* set, not only to the frontier. A traversal
424 /// does not need it — its caller post-processes the whole region — and a
425 /// neighborhood projection cannot do without it, because that is what
426 /// decides which neighbours of a hub survive the node budget.
427 pub with_degrees: bool,
428}
429
430pub struct ExpandResponse {
431 pub reached: Vec<NodeId>,
432 /// Each reached node's degree in the authorized subgraph, index-aligned
433 /// with `reached`. Empty unless the request asked for it.
434 ///
435 /// The engine is the only party that can say: a reached node is an
436 /// internal id and an `EdgeRef` names its endpoints by producer key, so
437 /// nothing above this port can join the two without another read. Every
438 /// edge counted has passed the caller's scope, so this is the degree
439 /// *inside the authorized subgraph* and never a global one the caller
440 /// cannot see (Authorization Model: "degree ordering, budgets and
441 /// truncation are computed on authorized rows only"). It is what lets a
442 /// neighborhood projection keep the structural core when a hub exceeds
443 /// the node budget (`fr-neighborhood-projection`).
444 ///
445 /// A count is a lower bound when the hop reports `EdgeScanCap`: the scan
446 /// stopped at the budget, so a node may have edges it did not see. The
447 /// ranking is then approximate — which the truncation reason says.
448 pub degrees: Vec<u32>,
449 pub edges: Vec<EdgeRef>,
450 /// Never silent.
451 pub truncated: Option<TruncationReason>,
452 /// Which backend produced this answer.
453 ///
454 /// **Found while building the prototype.** The pattern backend declines by
455 /// falling back, and the decline was recorded only in a log line. A log
456 /// line is not something a test can assert on, so the suite could not tell
457 /// a pattern hop that ran from one that failed and was silently served by
458 /// the two-query hop instead -- which is exactly what happened, for every
459 /// traversal, when the pattern lost its anchor. Reporting the backend on
460 /// the response is what makes "the pattern actually served this" an
461 /// assertion rather than an assumption.
462 pub served_by: HopBackend,
463}
464
465/// The hop backends of ADR-0005, as the answer reports them.
466#[derive(Clone, Copy, Debug, PartialEq, Eq)]
467pub enum HopBackend {
468 /// One scoped `GRAPH_TABLE` statement.
469 Pattern,
470 /// Two scoped queries per hop; always available.
471 TwoQuery,
472}
473
474/// The engine's applied `(source epoch, graph revision)` position. The epoch
475/// is a non-reusable timeline identifier, so a projection that survived a
476/// point-in-time restore of the source database is detected rather than
477/// served.
478pub struct EngineCursor {
479 pub revision: GraphRevision,
480}
481
482pub struct ShortestPathRequest {
483 pub from: NodeId,
484 pub to: NodeId,
485 pub max_depth: u8,
486}
487
488pub struct PathResponse {
489 pub nodes: Vec<NodeId>,
490 pub edges: Vec<EdgeRef>,
491}
492
493/// Declared-capability pattern matching (not shipped by the built-in engine).
494pub struct PatternRequest {
495 pub pattern: String,
496}
497
498pub struct PatternResponse {
499 pub rows: Vec<Vec<NodeId>>,
500}
501
502/// The traversal-engine plugin contract
503/// (`cpt-cf-graph-storage-contract-graph-engine-plugin`).
504#[async_trait]
505pub trait GraphEngineV1: Send + Sync + 'static {
506 fn capabilities(&self) -> EngineCapabilities;
507
508 async fn cursor(&self, ctx: &StoreCtx<'_>) -> Result<EngineCursor, GraphEngineError>;
509
510 /// Directed one-hop expansion of an authorized frontier. Chained by the
511 /// caller with per-hop dedup; the engine never expands beyond one hop, so
512 /// budgets and authorization are re-evaluated between hops rather than
513 /// inside an opaque traversal.
514 async fn expand(
515 &self,
516 ctx: &StoreCtx<'_>,
517 req: ExpandRequest,
518 ) -> Result<ExpandResponse, GraphEngineError>;
519
520 /// Declared capabilities only; otherwise `GraphEngineError::Unsupported`.
521 async fn shortest_path(
522 &self,
523 ctx: &StoreCtx<'_>,
524 req: ShortestPathRequest,
525 ) -> Result<PathResponse, GraphEngineError>;
526 async fn match_pattern(
527 &self,
528 ctx: &StoreCtx<'_>,
529 req: PatternRequest,
530 ) -> Result<PatternResponse, GraphEngineError>;
531}
532
533/// What the Embedding Coordinator decided about one node, for the store to
534/// write. Index-aligned with `IngestRequest::nodes` — the same convention
535/// [`EmbedResponse::vectors`] uses, and for the same reason: any other
536/// association would have to be keyed on something a batch may legitimately
537/// repeat.
538#[derive(Clone, Debug, PartialEq)]
539pub struct NodeEmbedding {
540 /// The vector, or `None` when this request did not ask for embedding.
541 pub vector: Option<Vec<f32>>,
542 /// Canonical hash of the text this node embeds from, computed whether or
543 /// not it was embedded. It is what tells a later ingest whether a
544 /// preserved vector still describes the node — the difference between a
545 /// *preserved* vector and a *stale* one.
546 pub input_hash: String,
547}
548
549impl NodeEmbedding {
550 #[must_use]
551 pub fn computed(vector: Vec<f32>, input_hash: String) -> Self {
552 Self {
553 vector: Some(vector),
554 input_hash,
555 }
556 }
557
558 #[must_use]
559 pub fn skipped(input_hash: String) -> Self {
560 Self {
561 vector: None,
562 input_hash,
563 }
564 }
565}
566
567/// The vector arm of one search, as the coordinator resolved it.
568///
569/// Absent when the request asked for no vector arm, or when no comparable
570/// embedding space is in force. A store never embeds anything itself: query
571/// text and ingest text must go through one provider, and only the
572/// coordinator holds it.
573#[derive(Clone, Debug, PartialEq)]
574pub struct VectorArm {
575 pub query_vector: Vec<f32>,
576 /// Only vectors of this epoch may be ranked. Vectors of any other epoch,
577 /// and vectors whose input has since changed, are not comparable with the
578 /// query and must not appear.
579 pub epoch: i64,
580}
581
582/// The embedding decisions of one ingest batch.
583#[derive(Clone, Debug, Default, PartialEq)]
584pub struct EmbeddingPlan {
585 /// Epoch to stamp new vectors with. `None` means no comparable space is
586 /// in force, so no vector may be written or read.
587 pub epoch: Option<i64>,
588 /// One entry per node of the request, in order.
589 pub nodes: Vec<NodeEmbedding>,
590}
591
592/// Provider-side failure vocabulary. A provider failure fails the ingest
593/// batch; it is never downgraded to an unembedded write.
594#[derive(Debug, Error)]
595#[non_exhaustive]
596pub enum EmbeddingProviderError {
597 #[error("provider unavailable: {reason}")]
598 Unavailable { reason: String },
599 #[error("embedding space mismatch")]
600 SpaceMismatch,
601 #[error("deadline exceeded")]
602 Deadline,
603 #[error("cancelled")]
604 Cancelled,
605 #[error("internal provider error: {0}")]
606 Internal(String),
607}
608
609/// Batched, not per item: the batch is where a remote provider's round trip
610/// is amortized.
611pub struct EmbedRequest {
612 pub inputs: Vec<String>,
613 pub budget: RemainingBudget,
614 pub cancel: CancellationToken,
615}
616
617pub struct EmbedResponse {
618 /// Aligned with `inputs` by index; a provider that cannot return one
619 /// vector per input fails the call rather than returning a short vector.
620 pub vectors: Vec<Vec<f32>>,
621 /// Echoed so a mismatch is caught at use, not only at configuration.
622 pub space: EmbeddingSpaceId,
623}
624
625/// The embedding-provider plugin contract
626/// (`cpt-cf-graph-storage-contract-embedding-provider`).
627#[async_trait]
628pub trait EmbeddingProviderV1: Send + Sync + 'static {
629 /// Model artifact, tokenizer artifact, preprocessing and pooling
630 /// configuration — not just a dimension.
631 fn embedding_space(&self) -> &EmbeddingSpaceId;
632 fn dimension(&self) -> u32;
633
634 async fn embed(&self, req: EmbedRequest) -> Result<EmbedResponse, EmbeddingProviderError>;
635
636 async fn health(&self) -> Result<(), EmbeddingProviderError>;
637}