From d29bf7ae37e1d573eb4a705ace98cfbd5c7bd7fb Mon Sep 17 00:00:00 2001 From: Rita Agafonova <36133358+rita-aga@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:07:54 -0700 Subject: [PATCH 1/9] feat(spec): declare [[vector]] access paths, verified by the cascade (ARN-159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 1 of ARN-159 (kernel-native vector similarity, ADR-0155). Adds the `[[vector]]` spec declaration — name, property, model_property, dims, metric — as a sibling of `[[key]]`, parsed via the same serde second pass and carried onto the JIT TransitionTable as `DeclaredVector`. The verification cascade rejects a vector path whose property/model_property is not a declared state variable, whose dims is 0, whose metric is not one of cosine/dot/l2, or whose name repeats. No index or read surface yet — those are the following slices. Co-Authored-By: Claude Fable 5 --- crates/temper-jit/src/shadow.rs | 1 + crates/temper-jit/src/swap.rs | 1 + crates/temper-jit/src/table/builder.rs | 11 ++ crates/temper-jit/src/table/types.rs | 22 +++ crates/temper-spec/src/automaton/parser.rs | 48 ++++++ .../temper-spec/src/automaton/parser_test.rs | 92 ++++++++++ .../src/automaton/toml_parser/mod.rs | 31 ++++ .../src/automaton/toml_parser/tests.rs | 31 ++++ crates/temper-spec/src/automaton/types.rs | 29 ++++ docs/adrs/0155-declared-vector-access-path.md | 158 ++++++++++++++++++ 10 files changed, 424 insertions(+) create mode 100644 docs/adrs/0155-declared-vector-access-path.md diff --git a/crates/temper-jit/src/shadow.rs b/crates/temper-jit/src/shadow.rs index 0fe5c183..8cd41174 100644 --- a/crates/temper-jit/src/shadow.rs +++ b/crates/temper-jit/src/shadow.rs @@ -116,6 +116,7 @@ mod tests { states: vec!["Draft".into(), "Submitted".into(), "Cancelled".into()], initial_state: "Draft".into(), keys: vec![], + vectors: vec![], rules: vec![ TransitionRule { name: "SubmitOrder".into(), diff --git a/crates/temper-jit/src/swap.rs b/crates/temper-jit/src/swap.rs index fe7bbabf..6b05c59f 100644 --- a/crates/temper-jit/src/swap.rs +++ b/crates/temper-jit/src/swap.rs @@ -87,6 +87,7 @@ mod tests { states: vec!["A".into(), "B".into()], initial_state: "A".into(), keys: vec![], + vectors: vec![], rules: vec![TransitionRule { name: "GoB".into(), from_states: vec!["A".into()], diff --git a/crates/temper-jit/src/table/builder.rs b/crates/temper-jit/src/table/builder.rs index ec2c8cdb..bf48afc6 100644 --- a/crates/temper-jit/src/table/builder.rs +++ b/crates/temper-jit/src/table/builder.rs @@ -129,6 +129,17 @@ impl TransitionTable { properties: k.properties.clone(), }) .collect(), + vectors: automaton + .vectors + .iter() + .map(|v| super::types::DeclaredVector { + name: v.name.clone(), + property: v.property.clone(), + model_property: v.model_property.clone(), + dims: v.dims, + metric: v.metric.clone(), + }) + .collect(), state_var_metadata, composite_actions, rule_index, diff --git a/crates/temper-jit/src/table/types.rs b/crates/temper-jit/src/table/types.rs index 25c4f202..e18fe1ff 100644 --- a/crates/temper-jit/src/table/types.rs +++ b/crates/temper-jit/src/table/types.rs @@ -24,6 +24,20 @@ pub struct DeclaredKey { pub properties: Vec, } +/// A declared vector access path carried on the table (ADR-0155). `name` +/// identifies it; `property` is the float-vector state variable, `model_property` +/// the model-tag state variable that partitions the space, `dims` the expected +/// length, `metric` one of `cosine`/`dot`/`l2`. The actor parses and co-commits +/// these on write to maintain `entity_vector_index`; `Temper.Nearest` reads them. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeclaredVector { + pub name: String, + pub property: String, + pub model_property: String, + pub dims: usize, + pub metric: String, +} + /// A transition table: state machine transitions as DATA, not code. /// Can be hot-swapped per-actor without restart. #[derive(Debug, Clone, Serialize)] @@ -39,6 +53,10 @@ pub struct TransitionTable { /// ADR-0153: declared unique/alternate keys the kernel indexes for /// negative-existence reads. Empty when the spec declared no `[[key]]`. pub keys: Vec, + /// ADR-0155: declared vector access paths the kernel indexes for exact-scan + /// kNN reads (`Temper.Nearest`). Empty when the spec declared no `[[vector]]`. + #[serde(default)] + pub vectors: Vec, /// Per-state-variable metadata for platform primitives (ADR-0045, ADR-0047). /// Keyed by state-variable name. Empty map when the IOA spec did not /// declare any per-field overrides. @@ -143,6 +161,8 @@ impl<'de> Deserialize<'de> for TransitionTable { composite_actions: BTreeMap, #[serde(default)] keys: Vec, + #[serde(default)] + vectors: Vec, } let raw = TransitionTableRaw::deserialize(deserializer)?; @@ -152,6 +172,7 @@ impl<'de> Deserialize<'de> for TransitionTable { initial_state: raw.initial_state, rules: raw.rules, keys: raw.keys, + vectors: raw.vectors, state_var_metadata: raw.state_var_metadata, composite_actions: raw.composite_actions, rule_index: BTreeMap::new(), @@ -266,6 +287,7 @@ mod tests { states: vec!["Draft".to_string(), "Active".to_string()], initial_state: "Draft".to_string(), keys: vec![], + vectors: vec![], rules: vec![ TransitionRule { name: "Submit".to_string(), diff --git a/crates/temper-spec/src/automaton/parser.rs b/crates/temper-spec/src/automaton/parser.rs index 7c9ef78b..9db40622 100644 --- a/crates/temper-spec/src/automaton/parser.rs +++ b/crates/temper-spec/src/automaton/parser.rs @@ -650,6 +650,54 @@ fn validate(automaton: &Automaton) -> Result<(), AutomatonParseError> { // 6. Validate [[action.triggers]] declarations (ADR-0046). validate_action_triggers(automaton, &action_names)?; + // 7. Validate [[vector]] access-path declarations (ADR-0155). + // - `property` and `model_property` must be declared state variables. + // - `dims` must be > 0. + // - `metric` must be one of cosine | dot | l2. + // - names must be unique (each identifies one index partition + `decl=`). + validate_vector_decls(automaton)?; + + Ok(()) +} + +/// Validate all `[[vector]]` access-path declarations per ADR-0155. +fn validate_vector_decls(automaton: &Automaton) -> Result<(), AutomatonParseError> { + const METRICS: [&str; 3] = ["cosine", "dot", "l2"]; + let state_var_names: std::collections::BTreeSet<&str> = + automaton.state.iter().map(|sv| sv.name.as_str()).collect(); + let mut seen_names: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new(); + for vec_decl in &automaton.vectors { + if !seen_names.insert(vec_decl.name.as_str()) { + return Err(AutomatonParseError::Validation(format!( + "vector path '{}' declared twice", + vec_decl.name + ))); + } + if !state_var_names.contains(vec_decl.property.as_str()) { + return Err(AutomatonParseError::Validation(format!( + "vector path '{}' references undeclared property state variable '{}'", + vec_decl.name, vec_decl.property + ))); + } + if !state_var_names.contains(vec_decl.model_property.as_str()) { + return Err(AutomatonParseError::Validation(format!( + "vector path '{}' references undeclared model_property state variable '{}'", + vec_decl.name, vec_decl.model_property + ))); + } + if vec_decl.dims == 0 { + return Err(AutomatonParseError::Validation(format!( + "vector path '{}' must declare dims > 0", + vec_decl.name + ))); + } + if !METRICS.contains(&vec_decl.metric.as_str()) { + return Err(AutomatonParseError::Validation(format!( + "vector path '{}' has unknown metric '{}' (expected one of cosine, dot, l2)", + vec_decl.name, vec_decl.metric + ))); + } + } Ok(()) } diff --git a/crates/temper-spec/src/automaton/parser_test.rs b/crates/temper-spec/src/automaton/parser_test.rs index cae66984..4f3762c0 100644 --- a/crates/temper-spec/src/automaton/parser_test.rs +++ b/crates/temper-spec/src/automaton/parser_test.rs @@ -139,6 +139,98 @@ from = [] } } +// --- ADR-0155: [[vector]] access-path validation ----------------------- + +#[cfg(test)] +mod vector_validation { + use super::super::parse_automaton; + + const BASE_SPEC: &str = r#" +[automaton] +name = "DesignLanguage" +states = ["Draft", "Published"] +initial = "Draft" + +[[state]] +name = "taste_vector" +type = "string" +initial = "" + +[[state]] +name = "taste_vector_model" +type = "string" +initial = "" + +[[action]] +name = "Publish" +from = ["Draft"] +to = "Published" +"#; + + #[test] + fn valid_vector_path_parses() { + let spec = format!( + "{BASE_SPEC}\n[[vector]]\nname = \"taste\"\nproperty = \"taste_vector\"\nmodel_property = \"taste_vector_model\"\ndims = 384\nmetric = \"cosine\"\n" + ); + let auto = parse_automaton(&spec).expect("valid vector path parses"); + assert_eq!(auto.vectors.len(), 1); + assert_eq!(auto.vectors[0].name, "taste"); + assert_eq!(auto.vectors[0].dims, 384); + assert_eq!(auto.vectors[0].metric, "cosine"); + } + + #[test] + fn rejects_undeclared_property() { + let spec = format!( + "{BASE_SPEC}\n[[vector]]\nname = \"taste\"\nproperty = \"missing_vec\"\nmodel_property = \"taste_vector_model\"\ndims = 384\nmetric = \"cosine\"\n" + ); + let err = parse_automaton(&spec).expect_err("undeclared property must reject"); + assert!( + err.to_string().contains("undeclared property state variable 'missing_vec'"), + "got: {err}" + ); + } + + #[test] + fn rejects_undeclared_model_property() { + let spec = format!( + "{BASE_SPEC}\n[[vector]]\nname = \"taste\"\nproperty = \"taste_vector\"\nmodel_property = \"missing_model\"\ndims = 384\nmetric = \"cosine\"\n" + ); + let err = parse_automaton(&spec).expect_err("undeclared model_property must reject"); + assert!( + err.to_string().contains("undeclared model_property state variable 'missing_model'"), + "got: {err}" + ); + } + + #[test] + fn rejects_zero_dims() { + let spec = format!( + "{BASE_SPEC}\n[[vector]]\nname = \"taste\"\nproperty = \"taste_vector\"\nmodel_property = \"taste_vector_model\"\ndims = 0\nmetric = \"cosine\"\n" + ); + let err = parse_automaton(&spec).expect_err("dims=0 must reject"); + assert!(err.to_string().contains("must declare dims > 0"), "got: {err}"); + } + + #[test] + fn rejects_unknown_metric() { + let spec = format!( + "{BASE_SPEC}\n[[vector]]\nname = \"taste\"\nproperty = \"taste_vector\"\nmodel_property = \"taste_vector_model\"\ndims = 384\nmetric = \"manhattan\"\n" + ); + let err = parse_automaton(&spec).expect_err("unknown metric must reject"); + assert!(err.to_string().contains("unknown metric 'manhattan'"), "got: {err}"); + } + + #[test] + fn rejects_duplicate_vector_name() { + let spec = format!( + "{BASE_SPEC}\n[[vector]]\nname = \"taste\"\nproperty = \"taste_vector\"\nmodel_property = \"taste_vector_model\"\ndims = 384\nmetric = \"cosine\"\n[[vector]]\nname = \"taste\"\nproperty = \"taste_vector\"\nmodel_property = \"taste_vector_model\"\ndims = 8\nmetric = \"dot\"\n" + ); + let err = parse_automaton(&spec).expect_err("duplicate name must reject"); + assert!(err.to_string().contains("declared twice"), "got: {err}"); + } +} + // --- ADR-0050: liveness coverage rule ---------------------------------- #[cfg(test)] diff --git a/crates/temper-spec/src/automaton/toml_parser/mod.rs b/crates/temper-spec/src/automaton/toml_parser/mod.rs index 453dd6fd..d1511ed9 100644 --- a/crates/temper-spec/src/automaton/toml_parser/mod.rs +++ b/crates/temper-spec/src/automaton/toml_parser/mod.rs @@ -31,6 +31,9 @@ enum Section { /// ADR-0153: `[[key]]` unique-key declarations. Passthrough; extracted via /// serde in the second pass. Key, + /// ADR-0155: `[[vector]]` vector access-path declarations. Passthrough; + /// extracted via serde in the second pass. + Vector, Webhook, /// ADR-0046: nested `[[action.triggers]]` blocks. Hand-rolled parser /// skips the body; triggers are extracted via serde in the second pass @@ -76,6 +79,9 @@ impl ParseState { // ADR-0153: [[key]] unique-key declarations — passthrough; serde // extracts them in the second pass. "[[key]]" => self.start_passthrough_section(Section::Key), + // ADR-0155: [[vector]] access-path declarations — passthrough; serde + // extracts them in the second pass. + "[[vector]]" => self.start_passthrough_section(Section::Vector), "[[webhook]]" => self.start_webhook_section(), _ if line.starts_with("[webhook.") => self.start_webhook_section(), // ADR-0046: nested [[action.triggers]] — flush the action body so @@ -106,6 +112,7 @@ impl ParseState { Section::FieldInvariant | Section::StateTimeout | Section::Key + | Section::Vector | Section::Webhook | Section::ActionTrigger | Section::CompositeActionMetadata @@ -157,6 +164,7 @@ impl ParseState { field_invariants: Vec::new(), state_timeouts: Vec::new(), keys: Vec::new(), + vectors: Vec::new(), admission: None, }) } @@ -442,6 +450,9 @@ pub(super) fn parse_toml_to_automaton(input: &str) -> Result Result, AutomatonPar .map_err(|e| AutomatonParseError::Toml(format!("key: {e}"))) } +/// Extract `[[vector]]` access-path declarations from TOML source via serde +/// (ADR-0155). Same isolation pattern as `extract_keys`. Errors are propagated — +/// a silently-dropped vector path would leave similarity unindexed while the spec +/// author believes `Temper.Nearest` will work. +fn extract_vectors(source: &str) -> Result, AutomatonParseError> { + let slice = isolate_sections(source, "[[vector]]"); + if slice.trim().is_empty() { + return Ok(Vec::new()); + } + + #[derive(serde::Deserialize)] + struct VectorWrapper { + #[serde(default, rename = "vector")] + vectors: Vec, + } + toml::from_str::(&slice) + .map(|w| w.vectors) + .map_err(|e| AutomatonParseError::Toml(format!("vector: {e}"))) +} + /// Return a minimal TOML document containing only the `[[field_invariant]]` /// sections from `source`. Any other top-level section is skipped. /// diff --git a/crates/temper-spec/src/automaton/toml_parser/tests.rs b/crates/temper-spec/src/automaton/toml_parser/tests.rs index b9683c5f..c72b0341 100644 --- a/crates/temper-spec/src/automaton/toml_parser/tests.rs +++ b/crates/temper-spec/src/automaton/toml_parser/tests.rs @@ -44,6 +44,37 @@ fn extract_keys_empty_when_no_key_blocks() { assert!(extract_keys(src).expect("extract keys").is_empty()); } +#[test] +fn extracts_declared_vector_paths() { + // ADR-0155: [[vector]] declares a vector access path the kernel indexes. + let src = r#" +[automaton] +name = "DesignLanguage" +states = ["Draft", "Published"] +initial = "Draft" + +[[vector]] +name = "taste" +property = "taste_vector" +model_property = "taste_vector_model" +dims = 384 +metric = "cosine" +"#; + let vectors = extract_vectors(src).expect("extract vectors"); + assert_eq!(vectors.len(), 1); + assert_eq!(vectors[0].name, "taste"); + assert_eq!(vectors[0].property, "taste_vector"); + assert_eq!(vectors[0].model_property, "taste_vector_model"); + assert_eq!(vectors[0].dims, 384); + assert_eq!(vectors[0].metric, "cosine"); +} + +#[test] +fn extract_vectors_empty_when_no_vector_blocks() { + let src = "[automaton]\nname = \"File\"\nstates = [\"Created\"]\ninitial = \"Created\"\n"; + assert!(extract_vectors(src).expect("extract vectors").is_empty()); +} + #[test] fn parse_kv_trims_whitespace() { let (key, value) = parse_kv(" key = \"value\" ").unwrap(); diff --git a/crates/temper-spec/src/automaton/types.rs b/crates/temper-spec/src/automaton/types.rs index 20ea2420..34cf5604 100644 --- a/crates/temper-spec/src/automaton/types.rs +++ b/crates/temper-spec/src/automaton/types.rs @@ -50,6 +50,12 @@ pub struct Automaton { /// annotation is derived from this declaration. #[serde(default, rename = "key")] pub keys: Vec, + /// ADR-0155: declared vector access paths. Each names a float-vector property + /// (and the model-tag property that partitions its space) that the kernel + /// indexes in `entity_vector_index` for exact-scan kNN reads (`Temper.Nearest`). + /// Empty when the spec declared no `[[vector]]`. + #[serde(default, rename = "vector")] + pub vectors: Vec, /// Admission control caps (ADR-0051). When present, the dispatch layer /// gates concurrent calls per `(tenant, entity_type, action)` before /// reaching the actor. @@ -70,6 +76,29 @@ pub struct KeyDecl { pub properties: Vec, } +/// ADR-0155: a declared vector access path on an entity. `property` names the +/// float-vector state variable (a JSON array, or a JSON-encoded string, of +/// `dims` floats); `model_property` names the state variable holding the model +/// tag that partitions the space (only vectors sharing a tag are ever compared). +/// The kernel maintains `entity_vector_index` over each declared path and serves +/// exact-scan kNN through `Temper.Nearest`. `metric` is one of `cosine`, `dot`, +/// `l2`. Multiple vector paths on one entity are distinguished by `name`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct VectorDecl { + /// Identifier for this path (the `decl_name` in `entity_vector_index` and the + /// `decl=` argument to `Temper.Nearest`). + pub name: String, + /// The state variable holding the float vector to index. + pub property: String, + /// The state variable holding the model tag that partitions the vector space. + pub model_property: String, + /// Vector dimensionality. Must be > 0; a row whose parsed vector length differs + /// is not indexed (same posture as an incomplete declared key). + pub dims: usize, + /// Similarity metric: `cosine`, `dot`, or `l2`. + pub metric: String, +} + /// Automaton metadata. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AutomatonMeta { diff --git a/docs/adrs/0155-declared-vector-access-path.md b/docs/adrs/0155-declared-vector-access-path.md new file mode 100644 index 00000000..96831697 --- /dev/null +++ b/docs/adrs/0155-declared-vector-access-path.md @@ -0,0 +1,158 @@ +# ADR-0155: Declared vector access path (kernel-native kNN) + +- Status: Accepted +- Date: 2026-07-06 +- Deciders: Temper core maintainers +- Related: + - ADR-0153: Declared composite-key index — the pattern this extends (a spec + declares an access path; the kernel maintains it from persisted state; + every read is budgeted, Cedar-governed, deterministic). + - ADR-0154: OData read-surface truthfulness — a new read surface must not lie. + - RFC-0003: Vector access paths in the Temper query plane (signed off). + - `crates/temper-spec/src/automaton/types.rs` (`[[vector]]` decl) + - `crates/temper-server/src/vector_index.rs` (pack/parse/rank) + - `crates/temper-server/src/odata/nearest.rs` (`Temper.Nearest`) + - `crates/temper-store-{sim,postgres,turso}` (index maintenance) + +## Context + +Entity catalogs are searchable by equality `$filter` and substring facets only. +Producers (Katagami taste vectors, Aya retrieval) now write embedding vectors onto +entities as ordinary action-event state, but similarity ranking runs out of process +(UI-side cosine, an interim MCP tool) — ungoverned, unbudgeted, and duplicated per +consumer. ADR-0153 already showed how to make an access path first-class: a spec +declares it, the kernel derives an index from persisted state, and reads of it are +bounded and deterministic. This ADR applies that same shape to vector similarity. + +Embedding *generation* stays outside the kernel forever (it is a nondeterministic +API call; a live call in the write path would break seed-reproducible verification). +The kernel only indexes vectors that already live on an entity as opaque `f32[dims]`. + +Scale honesty: tenants hold ≤1k entities; exact cosine over 1k×384-d is microseconds. +This buys governance, verifiability, and a platform primitive every tenant gets for +free — not performance. ANN is a later, declared opt-in, out of scope here. + +## Decision + +### Sub-Decision 1: `[[vector]]` declaration, verified by the cascade + +A sibling of `[[key]]` on the automaton: + +```toml +[[vector]] +name = "taste" +property = "taste_vector" # JSON array (or JSON-string) of floats on the entity +model_property = "taste_vector_model" # partitions the space; only same-tag vectors compare +dims = 384 +metric = "cosine" # cosine | dot | l2 +``` + +The verification cascade checks: `property` and `model_property` are declared state +variables, `dims > 0`, and `metric ∈ {cosine, dot, l2}`. Declaring a vector path on a +property that actions never write is legal — it indexes nothing (same posture as keys). + +**Why**: named parameters on a declaration map 1:1 onto the index partition key and the +read surface; the cascade is the one place spec mistakes are caught before deploy. + +### Sub-Decision 2: derived, model-partitioned index maintained from persisted state + +Index rows live in `entity_vector_index (tenant, entity_type, decl_name, model_tag, +entity_id, vector BLOB)`, PK on everything but `vector`; the blob is packed +little-endian f32. The journal keeps the human-readable JSON on the action event; the +index is derived, rebuildable state. Model tags partition the space — a kNN query +resolves against exactly one `model_tag` (named, or defaulted to the reference +entity's tag). Vectors from different models are never compared. + +Maintenance mirrors ADR-0153: co-committed with the event in the same transaction on +Postgres and the sim store; written by the write-behind projection and gated by a +per-`(tenant, type, decl-set)` backfill watermark on Turso (no co-commit today). +Adding a declaration to an existing type triggers the same reconcile/backfill path as +declared keys. + +### Sub-Decision 3: exact-scan kNN, ranked in the kernel (not the store) + +The store returns candidate `(entity_id, vector)` rows for one +`(tenant, type, decl, model_tag)` partition in **deterministic entity-id order**; the +**kernel** computes the metric with f32 accumulation in that fixed order and keeps a +k-heap. Ranking in one place (not per backend) is what makes the result identical on +sim, Postgres, and Turso — the property the DST asserts. The candidate count charges +the existing `scan_candidate_budget`; an over-budget partition returns the same 413 +(`QueryTooLarge`) contract as any other read. Ties break by entity id. + +`@temper.score` is a closeness where higher = nearer for every metric: cosine +similarity, dot product, and **negative** L2 distance — so "ordered by score +descending, nearest first" holds uniformly. + +### Sub-Decision 4: `Temper.Nearest` — a GET bound function + +``` +GET /tdata/DesignLanguages/Temper.Nearest(decl='taste',to='en-…',k=10) +GET /tdata/DesignLanguages/Temper.Nearest(decl='taste',vector='[…]',k=10,model='…') +GET /tdata/DesignLanguages/Temper.Nearest(decl='taste',to='en-…',k=10,filter='Status eq ''Published''') +``` + +Named parameters: `decl` (required), one of `to` (rank against another entity's vector; +that entity is excluded from its own results) or `vector` (raw query vector; `model` +required), `k`, optional `model` override, optional equality `filter` applied before +ranking. Response is the standard OData list shape ordered by score, each row carrying +`@temper.score`. The equality filter is applied by walking the full ranked candidate +list in score order and materializing + filtering + Cedar-`read`-authorizing lazily +until `k` rows are accepted — which is exactly "filter, then take top-k." + +**Why a bound function** (RFC-0003 Q1): agents consume this as a tool call, and a +bound function's named parameters map 1:1 onto a tool schema; a query-option grammar +would make agents compose nested custom syntax inside a URL string where they fumble +quoting. + +### Sub-Decision 5: governance and budget are unchanged + +The read runs under the same Cedar entityset `list` + per-row `read` authorization, the +same tenant isolation, and the same budget accounting as every query-plane read. That +is the entire point of doing this in the kernel. + +## Consequences + +### Positive +- Semantic search is a governed, budgeted, deterministic platform primitive every + tenant gets by declaring five lines of TOML. +- One implementation replaces per-consumer app-side cosine. + +### Negative +- The kernel gains a vector index table per backend and a ranking path to maintain. +- v1 loads a partition's vectors per query (fine at ≤1k; ANN is the declared escape + hatch when a partition approaches the budget). + +### Risks +- A backend computing its own ranking would diverge — mitigated by ranking only in the + kernel over a store-supplied, id-ordered candidate list. +- Turso lag: a just-written vector may not be indexed yet — the write-behind projection + plus the backfill watermark bound this exactly as ADR-0153 bounds keyed absence. + +### DST Compliance +- Ranking is pure: no clock, no randomness, no map-iteration dependence; f32 + accumulation in the store's fixed id order; ties broken by entity id. +- The sim store co-commits vector rows under the same lock as the journal, so a read + reflects the journal deterministically. A DST drives seeded writes + `Nearest` reads + and asserts identical ordering across all seeds. + +## Non-Goals +- Embedding generation (stays in post-transition integrations/WASM). +- ANN / approximate indexes (a later declared `index = "hnsw"` opt-in). +- Consumer cutovers (Katagami "related", Aya retrieval) and deletion of interim + app-side serving — a separate effort. +- Multimodal / dims-changing model swaps (a new declaration version + backfill). + +## Alternatives Considered +1. **pgvector sidecar** — rejected as the end state (a second store to run and a + parallel implementation); remains the documented escape hatch if kernel work stalls. +2. **Kernel-computed embeddings** — rejected permanently; a live API call in the write + path breaks seed-reproducible verification. +3. **Automatic ANN switch at scale** — rejected; index behavior is a declared contract, + not a silent heuristic (silent engine switches are how reads start lying). +4. **Ranking inside each store backend** — rejected; divergent f32 results across + backends would make the read non-reproducible under DST. + +## Rollback Policy +The declaration is additive and inert until a spec adds `[[vector]]`. Removing the +declaration stops maintenance; dropping `entity_vector_index` + the watermark rows +fully reverts the feature with no journal impact (the index is derived state). From e1ab6e1e9bb1ca2752ac217b955e89f9ac014ba8 Mon Sep 17 00:00:00 2001 From: Rita Agafonova <36133358+rita-aga@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:08:57 -0700 Subject: [PATCH 2/9] style(spec): rustfmt the vector-validation tests (ARN-159) Co-Authored-By: Claude Fable 5 --- crates/temper-spec/src/automaton/parser_test.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/temper-spec/src/automaton/parser_test.rs b/crates/temper-spec/src/automaton/parser_test.rs index 4f3762c0..392a7b91 100644 --- a/crates/temper-spec/src/automaton/parser_test.rs +++ b/crates/temper-spec/src/automaton/parser_test.rs @@ -186,7 +186,8 @@ to = "Published" ); let err = parse_automaton(&spec).expect_err("undeclared property must reject"); assert!( - err.to_string().contains("undeclared property state variable 'missing_vec'"), + err.to_string() + .contains("undeclared property state variable 'missing_vec'"), "got: {err}" ); } @@ -198,7 +199,8 @@ to = "Published" ); let err = parse_automaton(&spec).expect_err("undeclared model_property must reject"); assert!( - err.to_string().contains("undeclared model_property state variable 'missing_model'"), + err.to_string() + .contains("undeclared model_property state variable 'missing_model'"), "got: {err}" ); } @@ -209,7 +211,10 @@ to = "Published" "{BASE_SPEC}\n[[vector]]\nname = \"taste\"\nproperty = \"taste_vector\"\nmodel_property = \"taste_vector_model\"\ndims = 0\nmetric = \"cosine\"\n" ); let err = parse_automaton(&spec).expect_err("dims=0 must reject"); - assert!(err.to_string().contains("must declare dims > 0"), "got: {err}"); + assert!( + err.to_string().contains("must declare dims > 0"), + "got: {err}" + ); } #[test] @@ -218,7 +223,10 @@ to = "Published" "{BASE_SPEC}\n[[vector]]\nname = \"taste\"\nproperty = \"taste_vector\"\nmodel_property = \"taste_vector_model\"\ndims = 384\nmetric = \"manhattan\"\n" ); let err = parse_automaton(&spec).expect_err("unknown metric must reject"); - assert!(err.to_string().contains("unknown metric 'manhattan'"), "got: {err}"); + assert!( + err.to_string().contains("unknown metric 'manhattan'"), + "got: {err}" + ); } #[test] From 620db36c5c17a7be9cf70881ae12aede33d8a829 Mon Sep 17 00:00:00 2001 From: Rita Agafonova <36133358+rita-aga@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:31:52 -0700 Subject: [PATCH 3/9] feat(vector): sim-store index + Temper.Nearest kNN + DST (ARN-159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 2 of ARN-159 (ADR-0155). Makes vector similarity a live, budgeted, deterministic read on the sim backend end to end: - runtime EventStore gains EntityVectorRow / EntityVectorCandidate and a single co-commit entry point `append_with_index_rows` (keys + vectors in one tx); append_with_keys now forwards to it, so existing callers are unchanged. Plus vector_candidates / backfill_entity_vectors / mark_vector_index_backfilled / vector_index_backfilled_types / vectored_entity_ids_for_type (defaults inert). - sim store co-commits vector rows under the journal lock and serves candidates in deterministic entity-id order; the entity actor parses each declared [[vector]] from post-transition state and co-commits it. - new temper-server::vector_index: little-endian f32 packing, tolerant vector parsing (JSON array or JSON string), exact-scan ranking (cosine/dot/l2) with the determinism contract — f32 accumulation in candidate order, ties by entity id. - Temper.Nearest: a collection-bound GET function //Temper.Nearest(decl,to|vector,k,model,filter). Named-arg URL parsing in temper-odata; the handler resolves the model partition, charges the candidate budget (413 on overflow), ranks, then materializes + equality-filters + read-authorizes lazily in score order until k accepted (== filter then top-k), emitting the OData list shape with a per-row @temper.score. - DST: seeded writes + Nearest ranking reproduce across 100 seeds; model partitioning and self-exclusion hold. 9 vector_index unit tests, path-parse tests. Postgres + Turso backends and the backfill driver are the following slices. Co-Authored-By: Claude Fable 5 --- crates/temper-odata/src/path.rs | 87 ++++- crates/temper-runtime/src/persistence/mod.rs | 132 ++++++- .../temper-server/src/entity_actor/actor.rs | 45 ++- crates/temper-server/src/lib.rs | 1 + crates/temper-server/src/odata/mod.rs | 1 + crates/temper-server/src/odata/nearest.rs | 327 ++++++++++++++++++ .../src/odata/query_plane_read/types.rs | 12 + crates/temper-server/src/odata/read.rs | 37 +- crates/temper-server/src/state/entity_ops.rs | 22 ++ crates/temper-server/src/storage/mod.rs | 204 +++++++++++ crates/temper-server/src/vector_index.rs | 282 +++++++++++++++ .../tests/dst_entity_vector_index.rs | 191 ++++++++++ crates/temper-store-sim/src/lib.rs | 148 +++++++- test-fixtures/specs/vectored_item.ioa.toml | 37 ++ 14 files changed, 1496 insertions(+), 30 deletions(-) create mode 100644 crates/temper-server/src/odata/nearest.rs create mode 100644 crates/temper-server/src/vector_index.rs create mode 100644 crates/temper-server/tests/dst_entity_vector_index.rs create mode 100644 test-fixtures/specs/vectored_item.ioa.toml diff --git a/crates/temper-odata/src/path.rs b/crates/temper-odata/src/path.rs index 3cec91f1..d8e5a27f 100644 --- a/crates/temper-odata/src/path.rs +++ b/crates/temper-odata/src/path.rs @@ -48,10 +48,14 @@ pub enum ODataPath { action: String, }, - /// A bound function on an entity, e.g. `/Orders('abc-123')/Namespace.GetTotal()`. + /// A bound function on an entity or collection, e.g. + /// `/Orders('abc-123')/Namespace.GetTotal()` or, with named arguments, + /// `/Orders/Temper.Nearest(decl='taste',to='x',k=10)`. `params` holds the + /// parsed `name=value` argument list in declared order (empty for `()`). BoundFunction { parent: Box, function: String, + params: Vec<(String, String)>, }, /// The `$value` media stream of an entity, e.g. `/Files('f-1')/$value`. @@ -197,17 +201,26 @@ fn parse_continuation_segment(segment: &str, parent: ODataPath) -> Result Result { } } +/// Parse a bound-function argument list like `decl='taste',to='x',k=10` into an +/// ordered list of `(name, value)` pairs. Empty input yields an empty list (the +/// `Function()` case). Quoted values are unquoted (with `''` escapes collapsed) via +/// [`parse_key_literal`], and commas inside quotes do not split, so a raw-vector +/// argument such as `vector='[0.1, 0.2]'` parses as one value. Reuses the composite +/// key splitting/unquoting rules so URL argument syntax stays consistent. +fn parse_function_params(args_str: &str) -> Result, ODataError> { + if args_str.trim().is_empty() { + return Ok(Vec::new()); + } + let mut params = Vec::new(); + for part in split_composite_key(args_str)? { + let part = part.trim(); + let Some(eq_pos) = part.find('=') else { + return Err(ODataError::InvalidPath { + message: format!("bound function argument '{part}' is missing '='"), + }); + }; + let name = part[..eq_pos].trim().to_string(); + if name.is_empty() { + return Err(ODataError::InvalidPath { + message: format!("bound function argument '{part}' has an empty name"), + }); + } + let value = parse_key_literal(part[eq_pos + 1..].trim())?; + params.push((name, value)); + } + Ok(params) +} + /// Check if a key expression is a composite key (has `=` outside quotes). fn is_composite_key(s: &str) -> bool { let mut in_quotes = false; @@ -488,10 +531,44 @@ mod tests { KeyValue::Single("abc-123".into()) )), function: "GetOrderTotal".into(), + params: vec![], } ); } + #[test] + fn parse_collection_bound_function_with_named_params() { + // ADR-0155: Temper.Nearest is a collection-bound function with named args. + let result = + parse_path("/DesignLanguages/Temper.Nearest(decl='taste',to='en-1',k=10)").unwrap(); + assert_eq!( + result, + ODataPath::BoundFunction { + parent: Box::new(ODataPath::EntitySet("DesignLanguages".into())), + function: "Nearest".into(), + params: vec![ + ("decl".into(), "taste".into()), + ("to".into(), "en-1".into()), + ("k".into(), "10".into()), + ], + } + ); + } + + #[test] + fn parse_bound_function_arg_value_may_contain_commas_in_quotes() { + // A raw-vector argument is a quoted list; commas inside quotes do not split. + let result = parse_path( + "/DesignLanguages/Temper.Nearest(decl='taste',vector='[0.1, 0.2]',model='m')", + ) + .unwrap(); + let ODataPath::BoundFunction { params, .. } = result else { + panic!("expected bound function"); + }; + assert_eq!(params[1], ("vector".into(), "[0.1, 0.2]".into())); + assert_eq!(params[2], ("model".into(), "m".into())); + } + #[test] fn parse_composite_key() { let result = parse_path("/OrderItems(OrderId='abc',LineNo=1)").unwrap(); diff --git a/crates/temper-runtime/src/persistence/mod.rs b/crates/temper-runtime/src/persistence/mod.rs index b94e5a62..a1cfd887 100644 --- a/crates/temper-runtime/src/persistence/mod.rs +++ b/crates/temper-runtime/src/persistence/mod.rs @@ -95,6 +95,35 @@ pub struct EntityKeyRow { pub key_hash: String, } +/// A derived vector-index row to co-commit with an append (ADR-0155). Parsed from +/// the entity's post-transition state for one declared `[[vector]]` path: the +/// float vector and the model tag that partitions its space. Stores that maintain +/// `entity_vector_index` write one row per `(decl_name, model_tag, entity_id)`; the +/// blob is packed little-endian f32. Unlike a key row this has no uniqueness +/// constraint — it is derived, rebuildable ranking state. +#[derive(Debug, Clone, PartialEq)] +pub struct EntityVectorRow { + /// The declared vector path's identifier (the `[[vector]]` block's `name`). + pub decl_name: String, + /// The model tag that partitions this vector's space (only same-tag vectors + /// are ever compared). + pub model_tag: String, + /// The float vector, exactly `dims` long. + pub vector: Vec, +} + +/// One candidate row returned from the vector index for a kNN read (ADR-0155): +/// an entity and its packed vector for one `(tenant, type, decl, model_tag)` +/// partition. The kernel — not the store — computes the metric over these in the +/// store-supplied (entity-id) order, so ranking is identical across backends. +#[derive(Debug, Clone, PartialEq)] +pub struct EntityVectorCandidate { + /// The entity holding this vector. + pub entity_id: String, + /// The float vector, exactly `dims` long. + pub vector: Vec, +} + /// Trait for the event store backend (implemented by temper-store-postgres). /// Uses desugared async-in-trait to enforce Send bounds on futures. pub trait EventStore: Send + Sync + 'static { @@ -107,10 +136,10 @@ pub trait EventStore: Send + Sync + 'static { ) -> impl std::future::Future> + Send; /// Append events and co-commit declared key-index rows (ADR-0153) in the - /// **same transaction** as the journal append. The default ignores - /// `key_rows` and delegates to [`EventStore::append`] — only stores with a - /// query plane (postgres, turso) maintain `entity_key_index`. The sequence - /// and atomicity contract is identical to `append`. + /// **same transaction** as the journal append. Now a thin forwarder to + /// [`EventStore::append_with_index_rows`] with no vector rows, so callers that + /// only maintain keys are unchanged. Overriding `append_with_index_rows` is + /// what a co-committing backend does; this method is kept for existing callers. fn append_with_keys( &self, persistence_id: &str, @@ -118,10 +147,103 @@ pub trait EventStore: Send + Sync + 'static { events: &[PersistenceEnvelope], key_rows: &[EntityKeyRow], ) -> impl std::future::Future> + Send { - let _ = key_rows; + self.append_with_index_rows(persistence_id, expected_sequence, events, key_rows, &[]) + } + + /// Append events and co-commit BOTH declared key-index rows (ADR-0153) and + /// derived vector-index rows (ADR-0155) in the **same transaction** as the + /// journal append. This is the single co-commit entry point the entity actor + /// calls. The default ignores both index kinds and delegates to + /// [`EventStore::append`] — only stores with a query plane that co-commit + /// (postgres, sim) override it. Turso keeps the default (its query plane, + /// including vectors, is maintained write-behind by the projection, not + /// co-committed). The sequence and atomicity contract is identical to `append`. + fn append_with_index_rows( + &self, + persistence_id: &str, + expected_sequence: u64, + events: &[PersistenceEnvelope], + key_rows: &[EntityKeyRow], + vector_rows: &[EntityVectorRow], + ) -> impl std::future::Future> + Send { + let _ = (key_rows, vector_rows); self.append(persistence_id, expected_sequence, events) } + /// Backfill derived vector-index rows for an **existing** entity (ADR-0155), + /// without appending a journal event. Idempotent (upsert): re-running yields + /// the same rows. Used to populate `entity_vector_index` for entities written + /// before the vector path was declared, or (on write-behind backends) to catch + /// the index up. The default is a no-op (non-indexing backends); query-plane + /// stores upsert the rows. + fn backfill_entity_vectors( + &self, + tenant: &str, + entity_type: &str, + entity_id: &str, + vector_rows: &[EntityVectorRow], + ) -> impl std::future::Future> + Send { + let _ = (tenant, entity_type, entity_id, vector_rows); + async { Ok(()) } + } + + /// The candidate `(entity_id, vector)` rows for one vector-index partition + /// `(tenant, entity_type, decl_name, model_tag)`, in **deterministic entity-id + /// order** (ADR-0155). The kernel ranks these; the store only supplies the + /// packed vectors. Default empty (non-indexing backends have no vector index). + fn vector_candidates( + &self, + tenant: &str, + entity_type: &str, + decl_name: &str, + model_tag: &str, + ) -> impl std::future::Future, PersistenceError>> + Send + { + let _ = (tenant, entity_type, decl_name, model_tag); + async { Ok(Vec::new()) } + } + + /// Record that `entity_vector_index` is **complete** for `(tenant, entity_type)` + /// — every existing entity has had its declared vectors indexed by the backfill + /// (ADR-0155 watermark, mirroring `mark_key_index_backfilled`). `vector_set` is + /// the sorted, comma-joined declared vector-path NAMES the backfill covered, so a + /// later declaration of an ADDITIONAL path is detected as a set change and the + /// type is re-indexed. Idempotent. Default no-op. + fn mark_vector_index_backfilled( + &self, + tenant: &str, + entity_type: &str, + vector_set: &str, + ) -> impl std::future::Future> + Send { + let _ = (tenant, entity_type, vector_set); + async { Ok(()) } + } + + /// The `(entity_type, vector_set)` watermarks for `tenant` — each type whose + /// `entity_vector_index` backfill is complete, paired with the covered path set. + /// Default empty (no backend authority). Mirrors `key_index_backfilled_types`. + fn vector_index_backfilled_types( + &self, + tenant: &str, + ) -> impl std::future::Future, PersistenceError>> + Send + { + let _ = tenant; + async { Ok(Vec::new()) } + } + + /// The `entity_id`s that already have at least one `entity_vector_index` row for + /// `(tenant, entity_type)`. Lets the vector backfill **resume** cheaply, skipping + /// already-indexed entities. Default empty (no resumption). Mirrors + /// `keyed_entity_ids_for_type`. + fn vectored_entity_ids_for_type( + &self, + tenant: &str, + entity_type: &str, + ) -> impl std::future::Future, PersistenceError>> + Send { + let _ = (tenant, entity_type); + async { Ok(Vec::new()) } + } + /// Backfill declared key-index rows for an **existing** entity (ADR-0153), /// without appending a journal event. Idempotent: re-running yields the same /// rows. Used to populate `entity_key_index` for entities written before the diff --git a/crates/temper-server/src/entity_actor/actor.rs b/crates/temper-server/src/entity_actor/actor.rs index 6ac05a14..f571d48a 100644 --- a/crates/temper-server/src/entity_actor/actor.rs +++ b/crates/temper-server/src/entity_actor/actor.rs @@ -351,28 +351,59 @@ impl EntityActor { // W2 / temper#146: measure append wait — the hypothesis is that // writer-lock / fsync serialization is a cold-start bottleneck. - // ADR-0153: derive the declared key rows from the new state and co-commit - // them with the journal append, so a keyed read is correct without a scan. - let key_rows = { + // ADR-0153/0155: derive the declared key rows AND the vector-index rows from + // the new state and co-commit them with the journal append, so a keyed read + // is correct without a scan and a kNN read reflects the write deterministically. + let (key_rows, vector_rows) = { let table = self.table.read().expect("table lock poisoned"); - let mut rows = Vec::new(); + let mut key_rows = Vec::new(); + let mut vector_rows = Vec::new(); if let Some(field_map) = state.fields.as_object() { for key in &table.keys { if let Some(hash) = crate::key_index::canonical_key_hash(&key.name, &key.properties, field_map) { - rows.push(temper_runtime::persistence::EntityKeyRow { + key_rows.push(temper_runtime::persistence::EntityKeyRow { key_name: key.name.clone(), key_hash: hash, }); } } + for decl in &table.vectors { + // A vector is indexed only when its property parses to `dims` + // floats AND its model tag is a non-empty string — otherwise the + // path indexes nothing for this entity (like an incomplete key). + let Some(vector) = field_map + .get(&decl.property) + .and_then(|v| crate::vector_index::parse_vector_property(v, decl.dims)) + else { + continue; + }; + let Some(model_tag) = field_map + .get(&decl.model_property) + .and_then(|v| v.as_str()) + .filter(|tag| !tag.is_empty()) + else { + continue; + }; + vector_rows.push(temper_runtime::persistence::EntityVectorRow { + decl_name: decl.name.clone(), + model_tag: model_tag.to_string(), + vector, + }); + } } - rows + (key_rows, vector_rows) }; let append_start = Instant::now(); let result = store - .append_with_keys(persistence_id, state.sequence_nr, &[envelope], &key_rows) + .append_with_index_rows( + persistence_id, + state.sequence_nr, + &[envelope], + &key_rows, + &vector_rows, + ) .await; crate::runtime_metrics::record_event_store_append_wait( backend.as_str(), diff --git a/crates/temper-server/src/lib.rs b/crates/temper-server/src/lib.rs index f6101afa..1010e8e1 100644 --- a/crates/temper-server/src/lib.rs +++ b/crates/temper-server/src/lib.rs @@ -45,6 +45,7 @@ mod static_web; pub mod storage; pub(crate) mod trajectory_outbox; pub mod trigger; +pub mod vector_index; pub mod wasm_registry; pub mod webhooks; pub(crate) mod workflow_tracing; diff --git a/crates/temper-server/src/odata/mod.rs b/crates/temper-server/src/odata/mod.rs index 2567335d..7675e97f 100644 --- a/crates/temper-server/src/odata/mod.rs +++ b/crates/temper-server/src/odata/mod.rs @@ -8,6 +8,7 @@ mod common; pub(crate) mod constraints; mod content_addressed; mod filter_sql; +mod nearest; mod query_plane_read; mod rate_limit; mod read; diff --git a/crates/temper-server/src/odata/nearest.rs b/crates/temper-server/src/odata/nearest.rs new file mode 100644 index 00000000..e08be4ba --- /dev/null +++ b/crates/temper-server/src/odata/nearest.rs @@ -0,0 +1,327 @@ +//! `Temper.Nearest` — the exact-scan kNN bound function (ADR-0155). +//! +//! A collection-bound OData function that ranks a declared `[[vector]]` path: +//! +//! ```text +//! GET /tdata//Temper.Nearest(decl='taste',to='',k=10) +//! GET /tdata//Temper.Nearest(decl='taste',vector='[…]',k=10,model='') +//! GET /tdata//Temper.Nearest(decl='taste',to='',k=10,filter='Status eq ''Published''') +//! ``` +//! +//! The store supplies the partition's candidate `(entity_id, vector)` rows in a +//! fixed order; [`crate::vector_index`] ranks them (nearest first, id tiebreak). The +//! read runs under the same Cedar `list`/`read` gates and the same candidate budget +//! as any query-plane read — that is the whole point of doing kNN in the kernel. + +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use temper_authz::SecurityContext; +use temper_odata::path::ODataPath; +use temper_odata::query::types::QueryOptions; +use temper_runtime::tenant::TenantId; + +use super::authz::{LIST_ACTION, READ_ACTION, authorize_read}; +use super::common::resolve_entity_type; +use super::query_plane_read::QueryPlaneReadBudget; +use super::read_support::materialize_entity_set_entities; +use crate::response::odata_error; +use crate::state::ServerState; +use crate::vector_index::{VectorMetric, parse_vector_property, rank_nearest}; + +/// Look up a named argument in the parsed bound-function parameter list. +fn arg<'a>(params: &'a [(String, String)], name: &str) -> Option<&'a str> { + params + .iter() + .find(|(key, _)| key == name) + .map(|(_, value)| value.as_str()) +} + +fn bad_request(message: &str) -> Response { + odata_error(StatusCode::BAD_REQUEST, "InvalidNearestQuery", message).into_response() +} + +/// Handle `GET //Temper.Nearest(...)`. +pub(super) async fn handle_nearest( + state: &ServerState, + tenant: &TenantId, + security_ctx: &SecurityContext, + parent: &ODataPath, + params: &[(String, String)], + _query_options: &QueryOptions, +) -> Response { + // Temper.Nearest is collection-bound: the parent must be an entity set. + let set_name = match parent { + ODataPath::EntitySet(name) => name.clone(), + _ => { + return bad_request( + "Temper.Nearest is bound to an entity collection, e.g. /DesignLanguages/Temper.Nearest(...)", + ); + } + }; + let entity_type = match resolve_entity_type(state, tenant, &set_name) { + Some(et) => et, + None => { + return odata_error( + StatusCode::NOT_FOUND, + "ResourceNotFound", + &format!("Entity set '{set_name}' not found"), + ) + .into_response(); + } + }; + + // Resolve the declared vector path named by `decl=`. + let Some(decl_name) = arg(params, "decl") else { + return bad_request("Temper.Nearest requires a 'decl' argument naming the vector path"); + }; + let vectors = state.declared_vectors_for(tenant, &entity_type); + let Some(decl) = vectors.iter().find(|v| v.name == decl_name) else { + return bad_request(&format!( + "entity type '{entity_type}' declares no vector path named '{decl_name}'" + )); + }; + let Some(metric) = VectorMetric::parse(&decl.metric) else { + return odata_error( + StatusCode::INTERNAL_SERVER_ERROR, + "InvalidVectorMetric", + &format!("vector path '{decl_name}' has an unsupported metric"), + ) + .into_response(); + }; + + // Cedar: the collection-level list gate, up front (same gate as an ordinary + // list read). Per-row read is enforced during materialization below. + if let Err(response) = authorize_read( + state, + tenant, + security_ctx, + LIST_ACTION, + &entity_type, + "", + &serde_json::json!({}), + ) { + return *response; + } + + let budget = QueryPlaneReadBudget::from_config(); + let k = match arg(params, "k") { + Some(raw) => match raw.parse::() { + Ok(k) if k >= 1 => k.min(budget.max_result_k()), + _ => return bad_request("'k' must be a positive integer"), + }, + None => 10usize.min(budget.max_result_k()).max(1), + }; + + // Resolve the query vector + its model tag from either `to=` (another entity) + // or `vector=` (a raw query vector). Exactly one is required. + let to_arg = arg(params, "to"); + let vector_arg = arg(params, "vector"); + let model_arg = arg(params, "model"); + let (query_vector, model_tag, exclude_id): (Vec, String, Option) = match ( + to_arg, vector_arg, + ) { + (Some(_), Some(_)) => { + return bad_request("Temper.Nearest takes exactly one of 'to' or 'vector', not both"); + } + (None, None) => { + return bad_request("Temper.Nearest requires either 'to' (an entity id) or 'vector'"); + } + (Some(reference_id), None) => { + let materialized = materialize_entity_set_entities( + state, + tenant, + &entity_type, + &set_name, + std::slice::from_ref(&reference_id.to_string()), + true, + None, + ) + .await; + let Some(body) = materialized.entities.into_iter().next() else { + return odata_error( + StatusCode::NOT_FOUND, + "ResourceNotFound", + &format!("reference entity '{reference_id}' not found"), + ) + .into_response(); + }; + let Some(vector) = body + .get(&decl.property) + .and_then(|v| parse_vector_property(v, decl.dims)) + else { + return bad_request(&format!( + "reference entity '{reference_id}' has no usable '{}' vector for path '{decl_name}'", + decl.property + )); + }; + let model_tag = match model_arg { + Some(tag) => tag.to_string(), + None => match body + .get(&decl.model_property) + .and_then(|v| v.as_str()) + .filter(|tag| !tag.is_empty()) + { + Some(tag) => tag.to_string(), + None => { + return bad_request(&format!( + "reference entity '{reference_id}' has no '{}' model tag; pass 'model' explicitly", + decl.model_property + )); + } + }, + }; + (vector, model_tag, Some(reference_id.to_string())) + } + (None, Some(raw_vector)) => { + let Some(vector) = parse_vector_property( + &serde_json::Value::String(raw_vector.to_string()), + decl.dims, + ) else { + return bad_request(&format!( + "'vector' must be a JSON array of exactly {} finite numbers", + decl.dims + )); + }; + let Some(model_tag) = model_arg.filter(|tag| !tag.is_empty()) else { + return bad_request("'model' is required when querying by raw 'vector'"); + }; + (vector, model_tag.to_string(), None) + } + }; + + // Optional pre-ranking equality filter (`filter='Status eq ''Published'''`). + let equality_filter = match arg(params, "filter") { + Some(raw) => match temper_odata::query::filter::parse_filter(raw) { + Ok(expr) => match super::filter_sql::equality_field_predicates(&expr) { + Some(pairs) => Some(pairs), + None => { + return bad_request( + "Temper.Nearest 'filter' supports only equality predicates joined by 'and'", + ); + } + }, + Err(error) => return bad_request(&format!("invalid 'filter': {error}")), + }, + None => None, + }; + + // Candidate scan: pull the partition's vectors and charge the read budget. + let Some((store, _)) = state.event_journal() else { + return odata_error( + StatusCode::SERVICE_UNAVAILABLE, + "StorageUnavailable", + "vector search requires a query-plane store", + ) + .into_response(); + }; + let candidates = match store + .vector_candidates(tenant.as_str(), &entity_type, decl_name, &model_tag) + .await + { + Ok(candidates) => candidates, + Err(error) => { + return odata_error( + StatusCode::INTERNAL_SERVER_ERROR, + "VectorScanFailed", + &format!("vector candidate scan failed: {error}"), + ) + .into_response(); + } + }; + if candidates.len() > budget.candidate_budget() { + return odata_error( + StatusCode::PAYLOAD_TOO_LARGE, + "QueryTooLarge", + "This vector partition holds more candidates than the bounded read budget permits. Declare an ANN index or narrow the model partition.", + ) + .into_response(); + } + + // Rank every candidate; walk in nearest-first order, materializing + + // equality-filtering + read-authorizing lazily until k rows are accepted. + // Walking the full ranked list and taking the first k that pass the filter is + // exactly "filter, then take the top k". + let ranked = rank_nearest( + metric, + &query_vector, + &candidates, + candidates.len(), + exclude_id.as_deref(), + ); + + let mut value: Vec = Vec::with_capacity(k); + for scored in ranked { + if value.len() >= k { + break; + } + let materialized = materialize_entity_set_entities( + state, + tenant, + &entity_type, + &set_name, + std::slice::from_ref(&scored.entity_id), + true, + None, + ) + .await; + let Some(mut body) = materialized.entities.into_iter().next() else { + continue; + }; + if let Some(pairs) = &equality_filter + && !body_matches_equality(&body, pairs) + { + continue; + } + if authorize_read( + state, + tenant, + security_ctx, + READ_ACTION, + &entity_type, + &scored.entity_id, + &body, + ) + .is_err() + { + continue; + } + if let Some(obj) = body.as_object_mut() { + obj.insert( + "@temper.score".to_string(), + serde_json::Number::from_f64(scored.score as f64) + .map(serde_json::Value::Number) + .unwrap_or(serde_json::Value::Null), + ); + } + value.push(body); + } + + crate::response::ODataResponse { + status: StatusCode::OK, + body: serde_json::json!({ + "@odata.context": format!("$metadata#{set_name}"), + "value": value, + }), + } + .into_response() +} + +/// Whether a materialized entity body satisfies every equality predicate. A +/// missing field matches only `field eq null`. Values compare structurally (the +/// filter literals are already JSON), tolerant of the snake/Pascal field-name +/// split the rest of the read plane accommodates. +fn body_matches_equality(body: &serde_json::Value, pairs: &[(String, serde_json::Value)]) -> bool { + let Some(obj) = body.as_object() else { + return false; + }; + pairs.iter().all(|(field, expected)| { + let actual = obj + .get(field) + .or_else(|| obj.get(&temper_spec::to_snake_case(field))) + .or_else(|| obj.get(&temper_spec::to_pascal_case(field))); + match actual { + Some(value) => value == expected, + None => expected.is_null(), + } + }) +} diff --git a/crates/temper-server/src/odata/query_plane_read/types.rs b/crates/temper-server/src/odata/query_plane_read/types.rs index bf4fb2d4..df3d2889 100644 --- a/crates/temper-server/src/odata/query_plane_read/types.rs +++ b/crates/temper-server/src/odata/query_plane_read/types.rs @@ -34,6 +34,18 @@ impl QueryPlaneReadBudget { .max(self.default_page_size) } + /// The candidate budget a kNN scan charges (ADR-0155): the same bound the + /// query-plane scan uses, exposed to the `Temper.Nearest` handler. + pub(in crate::odata) fn candidate_budget(self) -> usize { + self.scan_candidate_budget() + } + + /// The cap on `k` (and the default when `k` is absent) for a kNN read: the + /// max page size any OData read may return. + pub(in crate::odata) fn max_result_k(self) -> usize { + self.max_entities + } + pub(in crate::odata::query_plane_read) fn requested_top( self, query_options: &QueryOptions, diff --git a/crates/temper-server/src/odata/read.rs b/crates/temper-server/src/odata/read.rs index b69493f5..5b5d9be8 100644 --- a/crates/temper-server/src/odata/read.rs +++ b/crates/temper-server/src/odata/read.rs @@ -658,16 +658,33 @@ pub(super) async fn handle_odata_get_for_tenant( .await } - ODataPath::BoundFunction { parent, function } => { - handle_bound_function( - &state, - &tenant, - &security_ctx, - &parent, - &function, - &query_options, - ) - .await + ODataPath::BoundFunction { + parent, + function, + params, + } => { + if function == "Nearest" { + // ADR-0155: the collection-bound exact-scan kNN function. + super::nearest::handle_nearest( + &state, + &tenant, + &security_ctx, + &parent, + ¶ms, + &query_options, + ) + .await + } else { + handle_bound_function( + &state, + &tenant, + &security_ctx, + &parent, + &function, + &query_options, + ) + .await + } } ODataPath::Value { ref parent } => { diff --git a/crates/temper-server/src/state/entity_ops.rs b/crates/temper-server/src/state/entity_ops.rs index 8a1dba8e..b1276054 100644 --- a/crates/temper-server/src/state/entity_ops.rs +++ b/crates/temper-server/src/state/entity_ops.rs @@ -248,6 +248,28 @@ impl ServerState { .unwrap_or_default() } + /// The declared `[[vector]]` access paths for `(tenant, entity_type)` — the + /// registry table first (covers os-app entities), the boot-time + /// `transition_tables` as fallback (ADR-0155). Same registry-lock discipline as + /// [`Self::declared_keys_for`]: fail fast on a poisoned lock rather than silently + /// falling through. Empty when the type declares no vector path. + pub(crate) fn declared_vectors_for( + &self, + tenant: &TenantId, + entity_type: &str, + ) -> Vec { + { + let registry = self.registry.read().expect("registry lock poisoned"); + if let Some(table) = registry.get_table(tenant, entity_type) { + return table.vectors.clone(); + } + } + self.transition_tables + .get(entity_type) + .map(|table| table.vectors.clone()) + .unwrap_or_default() + } + /// Load the current entity state and derive the Cedar resource view used /// for action authorization. pub(crate) async fn load_authz_resource_snapshot( diff --git a/crates/temper-server/src/storage/mod.rs b/crates/temper-server/src/storage/mod.rs index 88bb554a..1a9ef953 100644 --- a/crates/temper-server/src/storage/mod.rs +++ b/crates/temper-server/src/storage/mod.rs @@ -79,6 +79,53 @@ pub trait DynEventStore: Send + Sync { key_rows: &'a [temper_runtime::persistence::EntityKeyRow], ) -> EventStoreFuture<'a, Result>; + #[allow(clippy::too_many_arguments)] + fn append_with_index_rows<'a>( + &'a self, + persistence_id: &'a str, + expected_sequence: u64, + events: &'a [PersistenceEnvelope], + key_rows: &'a [temper_runtime::persistence::EntityKeyRow], + vector_rows: &'a [temper_runtime::persistence::EntityVectorRow], + ) -> EventStoreFuture<'a, Result>; + + fn backfill_entity_vectors<'a>( + &'a self, + tenant: &'a str, + entity_type: &'a str, + entity_id: &'a str, + vector_rows: &'a [temper_runtime::persistence::EntityVectorRow], + ) -> EventStoreFuture<'a, Result<(), PersistenceError>>; + + fn vector_candidates<'a>( + &'a self, + tenant: &'a str, + entity_type: &'a str, + decl_name: &'a str, + model_tag: &'a str, + ) -> EventStoreFuture< + 'a, + Result, PersistenceError>, + >; + + fn mark_vector_index_backfilled<'a>( + &'a self, + tenant: &'a str, + entity_type: &'a str, + vector_set: &'a str, + ) -> EventStoreFuture<'a, Result<(), PersistenceError>>; + + fn vector_index_backfilled_types<'a>( + &'a self, + tenant: &'a str, + ) -> EventStoreFuture<'a, Result, PersistenceError>>; + + fn vectored_entity_ids_for_type<'a>( + &'a self, + tenant: &'a str, + entity_type: &'a str, + ) -> EventStoreFuture<'a, Result, PersistenceError>>; + fn lookup_by_key<'a>( &'a self, tenant: &'a str, @@ -193,6 +240,92 @@ where )) } + fn append_with_index_rows<'a>( + &'a self, + persistence_id: &'a str, + expected_sequence: u64, + events: &'a [PersistenceEnvelope], + key_rows: &'a [temper_runtime::persistence::EntityKeyRow], + vector_rows: &'a [temper_runtime::persistence::EntityVectorRow], + ) -> EventStoreFuture<'a, Result> { + Box::pin(EventStore::append_with_index_rows( + self, + persistence_id, + expected_sequence, + events, + key_rows, + vector_rows, + )) + } + + fn backfill_entity_vectors<'a>( + &'a self, + tenant: &'a str, + entity_type: &'a str, + entity_id: &'a str, + vector_rows: &'a [temper_runtime::persistence::EntityVectorRow], + ) -> EventStoreFuture<'a, Result<(), PersistenceError>> { + Box::pin(EventStore::backfill_entity_vectors( + self, + tenant, + entity_type, + entity_id, + vector_rows, + )) + } + + fn vector_candidates<'a>( + &'a self, + tenant: &'a str, + entity_type: &'a str, + decl_name: &'a str, + model_tag: &'a str, + ) -> EventStoreFuture< + 'a, + Result, PersistenceError>, + > { + Box::pin(EventStore::vector_candidates( + self, + tenant, + entity_type, + decl_name, + model_tag, + )) + } + + fn mark_vector_index_backfilled<'a>( + &'a self, + tenant: &'a str, + entity_type: &'a str, + vector_set: &'a str, + ) -> EventStoreFuture<'a, Result<(), PersistenceError>> { + Box::pin(EventStore::mark_vector_index_backfilled( + self, + tenant, + entity_type, + vector_set, + )) + } + + fn vector_index_backfilled_types<'a>( + &'a self, + tenant: &'a str, + ) -> EventStoreFuture<'a, Result, PersistenceError>> { + Box::pin(EventStore::vector_index_backfilled_types(self, tenant)) + } + + fn vectored_entity_ids_for_type<'a>( + &'a self, + tenant: &'a str, + entity_type: &'a str, + ) -> EventStoreFuture<'a, Result, PersistenceError>> { + Box::pin(EventStore::vectored_entity_ids_for_type( + self, + tenant, + entity_type, + )) + } + fn lookup_by_key<'a>( &'a self, tenant: &'a str, @@ -374,6 +507,77 @@ impl BoxedEventStore { .await } + pub async fn append_with_index_rows( + &self, + persistence_id: &str, + expected_sequence: u64, + events: &[PersistenceEnvelope], + key_rows: &[temper_runtime::persistence::EntityKeyRow], + vector_rows: &[temper_runtime::persistence::EntityVectorRow], + ) -> Result { + self.0 + .append_with_index_rows( + persistence_id, + expected_sequence, + events, + key_rows, + vector_rows, + ) + .await + } + + pub async fn backfill_entity_vectors( + &self, + tenant: &str, + entity_type: &str, + entity_id: &str, + vector_rows: &[temper_runtime::persistence::EntityVectorRow], + ) -> Result<(), PersistenceError> { + self.0 + .backfill_entity_vectors(tenant, entity_type, entity_id, vector_rows) + .await + } + + pub async fn vector_candidates( + &self, + tenant: &str, + entity_type: &str, + decl_name: &str, + model_tag: &str, + ) -> Result, PersistenceError> { + self.0 + .vector_candidates(tenant, entity_type, decl_name, model_tag) + .await + } + + pub async fn mark_vector_index_backfilled( + &self, + tenant: &str, + entity_type: &str, + vector_set: &str, + ) -> Result<(), PersistenceError> { + self.0 + .mark_vector_index_backfilled(tenant, entity_type, vector_set) + .await + } + + pub async fn vector_index_backfilled_types( + &self, + tenant: &str, + ) -> Result, PersistenceError> { + self.0.vector_index_backfilled_types(tenant).await + } + + pub async fn vectored_entity_ids_for_type( + &self, + tenant: &str, + entity_type: &str, + ) -> Result, PersistenceError> { + self.0 + .vectored_entity_ids_for_type(tenant, entity_type) + .await + } + pub async fn lookup_by_key( &self, tenant: &str, diff --git a/crates/temper-server/src/vector_index.rs b/crates/temper-server/src/vector_index.rs new file mode 100644 index 00000000..8cb11ed6 --- /dev/null +++ b/crates/temper-server/src/vector_index.rs @@ -0,0 +1,282 @@ +//! Vector access-path packing, parsing, and exact-scan kNN ranking (ADR-0155). +//! +//! A declared `[[vector]]` path indexes a float vector per entity, partitioned by +//! a model tag. The store supplies candidate `(entity_id, vector)` rows in a fixed +//! (entity-id) order; this module computes the metric and ranks them. Ranking +//! lives here — in the kernel, once — rather than in each storage backend, so the +//! result is identical on sim, Postgres, and Turso (the property the DST asserts). +//! +//! The ranking is **deterministic**: no clock, no randomness, no map iteration; f32 +//! accumulation proceeds in the store's candidate order and ties break by entity +//! id. This is what makes kernel-side similarity admissible under deterministic +//! simulation where app-side similarity never was. + +use temper_runtime::persistence::EntityVectorCandidate; + +/// The similarity metric declared on a `[[vector]]` path. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VectorMetric { + /// Cosine similarity — the dot product normalized by both magnitudes. + Cosine, + /// Raw dot product. + Dot, + /// Euclidean (L2) distance. Exposed as a closeness (negated) so nearest is + /// always the largest score, uniform with cosine/dot. + L2, +} + +impl VectorMetric { + /// Parse the declared metric string. `None` for an unknown metric (the spec + /// cascade already rejects those, so this only guards a corrupt table). + pub fn parse(metric: &str) -> Option { + match metric { + "cosine" => Some(Self::Cosine), + "dot" => Some(Self::Dot), + "l2" => Some(Self::L2), + _ => None, + } + } +} + +/// Pack an `f32` slice to little-endian bytes — the on-disk `entity_vector_index` +/// blob encoding. +pub fn pack_f32_le(vector: &[f32]) -> Vec { + let mut bytes = Vec::with_capacity(vector.len() * 4); + for value in vector { + bytes.extend_from_slice(&value.to_le_bytes()); + } + bytes +} + +/// Unpack little-endian bytes back to `f32`. `None` if the byte length is not a +/// multiple of 4 (a corrupt blob), so a bad row is skipped rather than panicking. +pub fn unpack_f32_le(bytes: &[u8]) -> Option> { + if !bytes.len().is_multiple_of(4) { + return None; + } + let mut out = Vec::with_capacity(bytes.len() / 4); + for chunk in bytes.chunks_exact(4) { + out.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])); + } + Some(out) +} + +/// Parse an entity's vector property into exactly `dims` `f32`s. +/// +/// Accepts either a JSON array of numbers (`[0.1, 0.2, …]`) or a JSON **string** +/// containing such an array (`"[0.1, 0.2, …]"`) — producers may store the vector +/// either way. Returns `None` on any mismatch (wrong length, a non-numeric +/// element, an unparseable string), in which case the entity is simply not indexed +/// for this path — the same posture as an incomplete declared key. Non-finite +/// components (`NaN`/`inf`) also decline, so ranking never sees a value that has no +/// total order. +pub fn parse_vector_property(value: &serde_json::Value, dims: usize) -> Option> { + let array = match value { + serde_json::Value::Array(items) => items.clone(), + serde_json::Value::String(text) => match serde_json::from_str(text).ok()? { + serde_json::Value::Array(items) => items, + _ => return None, + }, + _ => return None, + }; + if array.len() != dims { + return None; + } + let mut out = Vec::with_capacity(dims); + for item in &array { + let component = item.as_f64()? as f32; + if !component.is_finite() { + return None; + } + out.push(component); + } + Some(out) +} + +/// One ranked entity plus its closeness score (higher = nearer). +#[derive(Debug, Clone, PartialEq)] +pub struct ScoredEntity { + pub entity_id: String, + pub score: f32, +} + +/// The closeness score for `query` vs `candidate` under `metric` — higher is +/// nearer for every metric: cosine similarity, dot product, and **negated** L2 +/// distance. f32 accumulation proceeds in index order. `None` if the lengths +/// differ (a corrupt row); a zero-magnitude vector scores 0 under cosine rather +/// than producing a `NaN`. +fn closeness(metric: VectorMetric, query: &[f32], candidate: &[f32]) -> Option { + if query.len() != candidate.len() { + return None; + } + match metric { + VectorMetric::Dot => { + let mut dot = 0.0f32; + for (q, c) in query.iter().zip(candidate.iter()) { + dot += q * c; + } + Some(dot) + } + VectorMetric::Cosine => { + let mut dot = 0.0f32; + let mut q_norm = 0.0f32; + let mut c_norm = 0.0f32; + for (q, c) in query.iter().zip(candidate.iter()) { + dot += q * c; + q_norm += q * q; + c_norm += c * c; + } + let denom = q_norm.sqrt() * c_norm.sqrt(); + if denom == 0.0 { + Some(0.0) + } else { + Some(dot / denom) + } + } + VectorMetric::L2 => { + let mut sum_sq = 0.0f32; + for (q, c) in query.iter().zip(candidate.iter()) { + let d = q - c; + sum_sq += d * d; + } + // Negated so nearest (smallest distance) is the largest score. + Some(-sum_sq.sqrt()) + } + } +} + +/// Rank `candidates` nearest-first and return at most `k`. +/// +/// Order is (score descending, then entity id ascending) — a total, deterministic +/// order under every seed. `exclude` drops one entity id (the reference entity when +/// the query came from `to=`, so it is never its own top result). Candidates +/// whose length does not match `query` are skipped (corrupt rows never rank). +pub fn rank_nearest( + metric: VectorMetric, + query: &[f32], + candidates: &[EntityVectorCandidate], + k: usize, + exclude: Option<&str>, +) -> Vec { + let mut scored: Vec = Vec::with_capacity(candidates.len()); + for candidate in candidates { + if exclude == Some(candidate.entity_id.as_str()) { + continue; + } + if let Some(score) = closeness(metric, query, &candidate.vector) { + scored.push(ScoredEntity { + entity_id: candidate.entity_id.clone(), + score, + }); + } + } + // Deterministic total order: score desc (via total_cmp so there is no + // NaN-induced ambiguity), then entity id asc as the tiebreak. + scored.sort_by(|a, b| { + b.score + .total_cmp(&a.score) + .then_with(|| a.entity_id.cmp(&b.entity_id)) + }); + scored.truncate(k); + scored +} + +#[cfg(test)] +mod tests { + use super::*; + + fn candidate(id: &str, vector: Vec) -> EntityVectorCandidate { + EntityVectorCandidate { + entity_id: id.to_string(), + vector, + } + } + + #[test] + fn pack_unpack_roundtrips() { + let v = vec![0.0f32, 1.5, -2.25, 384.0]; + assert_eq!(unpack_f32_le(&pack_f32_le(&v)).unwrap(), v); + } + + #[test] + fn unpack_rejects_misaligned_bytes() { + assert!(unpack_f32_le(&[1, 2, 3]).is_none()); + } + + #[test] + fn parse_accepts_array_and_string_forms() { + let dims = 3; + let from_array = parse_vector_property(&serde_json::json!([1.0, 2.0, 3.0]), dims).unwrap(); + let from_string = + parse_vector_property(&serde_json::json!("[1.0, 2.0, 3.0]"), dims).unwrap(); + assert_eq!(from_array, vec![1.0, 2.0, 3.0]); + assert_eq!(from_string, from_array); + } + + #[test] + fn parse_rejects_wrong_dims_and_nonnumeric() { + assert!(parse_vector_property(&serde_json::json!([1.0, 2.0]), 3).is_none()); + assert!(parse_vector_property(&serde_json::json!([1.0, "x", 3.0]), 3).is_none()); + assert!(parse_vector_property(&serde_json::json!("not json"), 3).is_none()); + assert!(parse_vector_property(&serde_json::json!(42), 3).is_none()); + } + + #[test] + fn cosine_ranks_most_similar_first() { + let query = vec![1.0, 0.0]; + let candidates = vec![ + candidate("orthogonal", vec![0.0, 1.0]), + candidate("same", vec![2.0, 0.0]), + candidate("opposite", vec![-1.0, 0.0]), + ]; + let ranked = rank_nearest(VectorMetric::Cosine, &query, &candidates, 3, None); + assert_eq!(ranked[0].entity_id, "same"); + assert_eq!(ranked[1].entity_id, "orthogonal"); + assert_eq!(ranked[2].entity_id, "opposite"); + } + + #[test] + fn l2_ranks_nearest_first_and_excludes_self() { + let query = vec![0.0, 0.0]; + let candidates = vec![ + candidate("self", vec![0.0, 0.0]), + candidate("near", vec![1.0, 0.0]), + candidate("far", vec![5.0, 5.0]), + ]; + let ranked = rank_nearest(VectorMetric::L2, &query, &candidates, 10, Some("self")); + assert_eq!(ranked.len(), 2); + assert_eq!(ranked[0].entity_id, "near"); + assert_eq!(ranked[1].entity_id, "far"); + } + + #[test] + fn ties_break_by_entity_id_ascending() { + let query = vec![1.0, 0.0]; + // Two candidates with identical vectors -> identical score -> id tiebreak. + let candidates = vec![ + candidate("b", vec![1.0, 0.0]), + candidate("a", vec![1.0, 0.0]), + ]; + let ranked = rank_nearest(VectorMetric::Dot, &query, &candidates, 2, None); + assert_eq!(ranked[0].entity_id, "a"); + assert_eq!(ranked[1].entity_id, "b"); + } + + #[test] + fn k_caps_result_length() { + let query = vec![1.0]; + let candidates: Vec<_> = (0..10) + .map(|i| candidate(&format!("e{i:02}"), vec![i as f32])) + .collect(); + let ranked = rank_nearest(VectorMetric::Dot, &query, &candidates, 3, None); + assert_eq!(ranked.len(), 3); + } + + #[test] + fn zero_magnitude_cosine_scores_zero_not_nan() { + let query = vec![0.0, 0.0]; + let candidates = vec![candidate("x", vec![1.0, 1.0])]; + let ranked = rank_nearest(VectorMetric::Cosine, &query, &candidates, 1, None); + assert_eq!(ranked[0].score, 0.0); + } +} diff --git a/crates/temper-server/tests/dst_entity_vector_index.rs b/crates/temper-server/tests/dst_entity_vector_index.rs new file mode 100644 index 00000000..3d17c2e1 --- /dev/null +++ b/crates/temper-server/tests/dst_entity_vector_index.rs @@ -0,0 +1,191 @@ +//! DST: the entity_vector_index kNN reproducibility invariant (ADR-0155). +//! +//! Property: seeded writes of declared vectors, followed by a `Temper.Nearest` +//! ranking, produce the **same order under every seed**. The kernel ranks a +//! store-supplied, id-ordered candidate list with f32 accumulation in that fixed +//! order and an entity-id tiebreak — so the sim seed (which drives fault injection +//! and scheduling) cannot change the result. This is what makes kernel-side +//! similarity admissible where app-side similarity never was. +//! +//! Real `EntityActor` + `SimEventStore` co-commit path, all seeds. + +use std::collections::BTreeMap; +use std::sync::{Arc, RwLock}; +use std::time::Duration; + +use temper_jit::table::TransitionTable; +use temper_runtime::ActorSystem; +use temper_runtime::scheduler::install_deterministic_context; +use temper_server::storage::{BackendLabel, BoxedEventStore}; +use temper_server::vector_index::{VectorMetric, rank_nearest}; +use temper_server::{EntityActor, EntityMsg, EntityResponse}; +use temper_store_sim::SimEventStore; + +const ITEM_IOA: &str = include_str!("../../../test-fixtures/specs/vectored_item.ioa.toml"); +const NUM_SEEDS: u64 = 100; + +fn item_table() -> Arc> { + Arc::new(RwLock::new(TransitionTable::from_ioa_source(ITEM_IOA))) +} + +async fn dispatch( + actor_ref: &temper_runtime::actor::ActorRef, + action: &str, + params: serde_json::Value, +) -> EntityResponse { + actor_ref + .ask( + EntityMsg::Action { + name: action.to_string(), + params, + cross_entity_booleans: BTreeMap::new(), + idempotency_key: None, + }, + Duration::from_secs(5), + ) + .await + .expect("actor should respond") +} + +/// Create one Item with a JSON-string embedding and a model tag. +async fn create_item( + system: &ActorSystem, + table: &Arc>, + store: &BoxedEventStore, + entity_id: &str, + embedding: &[f32], + model: &str, +) { + let actor = EntityActor::with_persistence( + "Item", + entity_id, + table.clone(), + serde_json::json!({}), + store.clone(), + BackendLabel::Sim, + ) + .with_tenant("default"); + let actor_ref = system.spawn(actor, entity_id); + let embedding_json = serde_json::to_string(embedding).expect("serialize embedding"); + let r = dispatch( + &actor_ref, + "Create", + serde_json::json!({ "Embedding": embedding_json, "EmbeddingModel": model }), + ) + .await; + assert!(r.success, "Create failed: {:?}", r.error); +} + +/// The fixed corpus every seed writes. Cosine nearest to [1,0,0,0] is `a` +/// (identical direction), then `b` (near), then `c`/`d` (orthogonal, score 0, +/// broken to id order c < d). +fn corpus() -> Vec<(&'static str, [f32; 4], &'static str)> { + vec![ + ("item-a", [1.0, 0.0, 0.0, 0.0], "m1"), + ("item-b", [0.9, 0.1, 0.0, 0.0], "m1"), + ("item-c", [0.0, 1.0, 0.0, 0.0], "m1"), + ("item-d", [0.0, 0.0, 1.0, 0.0], "m1"), + // A different model tag: must never appear in an m1 ranking. + ("item-e", [1.0, 0.0, 0.0, 0.0], "m2"), + ] +} + +#[tokio::test] +async fn dst_nearest_ranking_is_reproducible_across_seeds() { + let query = [1.0f32, 0.0, 0.0, 0.0]; + let mut reference_order: Option> = None; + + for seed in 0..NUM_SEEDS { + let (_guard, _clock, _id) = install_deterministic_context(seed); + let store: BoxedEventStore = BoxedEventStore::new(SimEventStore::no_faults(seed)); + let table = item_table(); + let system = ActorSystem::new("dst-vector"); + + for (id, embedding, model) in corpus() { + create_item(&system, &table, &store, id, &embedding, model).await; + } + + // Co-commit: the m1 partition holds exactly the four m1 items right after + // the writes (no async projection lag on the co-committing sim store). + let candidates = store + .vector_candidates("default", "Item", "embed", "m1") + .await + .expect("vector candidates"); + assert_eq!( + candidates.len(), + 4, + "seed {seed}: m1 partition must hold the four m1 items (model partitioning)" + ); + + let ranked = rank_nearest(VectorMetric::Cosine, &query, &candidates, 10, None); + let order: Vec = ranked.iter().map(|s| s.entity_id.clone()).collect(); + + assert_eq!( + order, + vec![ + "item-a".to_string(), + "item-b".to_string(), + "item-c".to_string(), + "item-d".to_string() + ], + "seed {seed}: ranking order (score desc, id tiebreak) must be exact" + ); + // item-e (model m2) must never leak into the m1 ranking. + assert!( + !order.iter().any(|id| id == "item-e"), + "seed {seed}: a different model tag must never be ranked" + ); + + match &reference_order { + None => reference_order = Some(order), + Some(reference) => assert_eq!( + &order, reference, + "seed {seed}: ranking must be identical to seed 0 (seed-independent)" + ), + } + } +} + +#[tokio::test] +async fn dst_nearest_by_reference_excludes_self() { + for seed in 0..NUM_SEEDS { + let (_guard, _clock, _id) = install_deterministic_context(seed); + let store: BoxedEventStore = BoxedEventStore::new(SimEventStore::no_faults(seed)); + let table = item_table(); + let system = ActorSystem::new("dst-vector-self"); + + for (id, embedding, model) in corpus() { + create_item(&system, &table, &store, id, &embedding, model).await; + } + + // Rank against item-a's own vector, excluding item-a (the "related to X" + // shape) — its nearest neighbour is item-b, and item-a is absent. + let candidates = store + .vector_candidates("default", "Item", "embed", "m1") + .await + .expect("vector candidates"); + let a_vector = candidates + .iter() + .find(|c| c.entity_id == "item-a") + .expect("item-a present") + .vector + .clone(); + let ranked = rank_nearest( + VectorMetric::Cosine, + &a_vector, + &candidates, + 10, + Some("item-a"), + ); + let order: Vec = ranked.iter().map(|s| s.entity_id.clone()).collect(); + assert!( + !order.contains(&"item-a".to_string()), + "seed {seed}: the reference entity must be excluded from its own results" + ); + assert_eq!( + order.first().map(String::as_str), + Some("item-b"), + "seed {seed}: item-a's nearest neighbour is item-b" + ); + } +} diff --git a/crates/temper-store-sim/src/lib.rs b/crates/temper-store-sim/src/lib.rs index 5d1cf8cb..c1e5c397 100644 --- a/crates/temper-store-sim/src/lib.rs +++ b/crates/temper-store-sim/src/lib.rs @@ -13,7 +13,8 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; use temper_runtime::persistence::{ - EventStore, PersistenceAppend, PersistenceAppendResult, PersistenceEnvelope, PersistenceError, + EntityVectorCandidate, EntityVectorRow, EventStore, PersistenceAppend, PersistenceAppendResult, + PersistenceEnvelope, PersistenceError, }; use temper_runtime::tenant::parse_persistence_id_parts; @@ -158,6 +159,16 @@ struct SimEventStoreInner { /// table — gates authoritative keyed absence, and detects a key-set change so a /// newly-declared key re-keys instead of being treated as already complete. key_index_watermark: BTreeMap<(String, String), String>, + /// ADR-0155: derived vector index, co-committed with the journal under the same + /// lock. `(tenant, entity_type, decl_name, model_tag, entity_id) -> vector`. The + /// deterministic reference for the real stores' `entity_vector_index` — the + /// exact-scan kNN access path. Unlike the key index this has no uniqueness + /// constraint; it is derived, rebuildable ranking state. + vector_index: BTreeMap<(String, String, String, String, String), Vec>, + /// ADR-0155 backfill watermark: `(tenant, entity_type) -> vector_set` — each + /// completed type mapped to the sorted comma-joined declared vector-path names the + /// backfill covered. Mirrors `key_index_watermark`. + vector_index_watermark: BTreeMap<(String, String), String>, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -186,6 +197,8 @@ impl SimEventStore { pending_append_delays: BTreeMap::new(), key_index: BTreeMap::new(), key_index_watermark: BTreeMap::new(), + vector_index: BTreeMap::new(), + vector_index_watermark: BTreeMap::new(), })), } } @@ -341,16 +354,17 @@ impl EventStore for SimEventStore { expected_sequence: u64, events: &[PersistenceEnvelope], ) -> Result { - self.append_with_keys(persistence_id, expected_sequence, events, &[]) + self.append_with_index_rows(persistence_id, expected_sequence, events, &[], &[]) .await } - async fn append_with_keys( + async fn append_with_index_rows( &self, persistence_id: &str, expected_sequence: u64, events: &[PersistenceEnvelope], key_rows: &[temper_runtime::persistence::EntityKeyRow], + vector_rows: &[EntityVectorRow], ) -> Result { let append_delay = { let mut inner = self @@ -539,6 +553,35 @@ impl EventStore for SimEventStore { } } + // ADR-0155: co-commit the derived vector-index rows under the SAME lock as + // the journal write. No uniqueness constraint — vectors are derived ranking + // state. Drop the entity's prior rows for each decl (its model_tag or vector + // may have changed), then claim the current (decl, model_tag) -> vector. + if !vector_rows.is_empty() { + let mut parts = persistence_id.splitn(3, ':'); + let tenant = parts.next().unwrap_or(""); + let entity_type = parts.next().unwrap_or(""); + let entity_id = parts.next().unwrap_or(""); + for row in vector_rows { + inner.vector_index.retain(|(t, et, decl, _, eid), _| { + !(t.as_str() == tenant + && et.as_str() == entity_type + && decl.as_str() == row.decl_name.as_str() + && eid.as_str() == entity_id) + }); + inner.vector_index.insert( + ( + tenant.to_string(), + entity_type.to_string(), + row.decl_name.clone(), + row.model_tag.clone(), + entity_id.to_string(), + ), + row.vector.clone(), + ); + } + } + Ok(new_seq) } @@ -636,6 +679,105 @@ impl EventStore for SimEventStore { Ok(ids.into_iter().collect()) } + async fn backfill_entity_vectors( + &self, + tenant: &str, + entity_type: &str, + entity_id: &str, + vector_rows: &[EntityVectorRow], + ) -> Result<(), PersistenceError> { + let mut inner = self.inner.lock().expect("SimEventStore lock poisoned"); // ci-ok: infallible lock + for row in vector_rows { + // Upsert: drop the entity's prior rows for this decl, then claim the + // current (decl, model_tag) -> vector. Idempotent. + inner.vector_index.retain(|(t, et, decl, _, eid), _| { + !(t.as_str() == tenant + && et.as_str() == entity_type + && decl.as_str() == row.decl_name.as_str() + && eid == entity_id) + }); + inner.vector_index.insert( + ( + tenant.to_string(), + entity_type.to_string(), + row.decl_name.clone(), + row.model_tag.clone(), + entity_id.to_string(), + ), + row.vector.clone(), + ); + } + Ok(()) + } + + async fn vector_candidates( + &self, + tenant: &str, + entity_type: &str, + decl_name: &str, + model_tag: &str, + ) -> Result, PersistenceError> { + let inner = self.inner.lock().expect("SimEventStore lock poisoned"); // ci-ok: infallible lock + // BTreeMap iteration is ordered by key, so `entity_id` (the last key + // component within a fixed partition) yields deterministic candidate order. + let mut out = Vec::new(); + for ((t, et, decl, tag, entity_id), vector) in inner.vector_index.iter() { + if t.as_str() == tenant + && et.as_str() == entity_type + && decl.as_str() == decl_name + && tag.as_str() == model_tag + { + out.push(EntityVectorCandidate { + entity_id: entity_id.clone(), + vector: vector.clone(), + }); + } + } + Ok(out) + } + + async fn mark_vector_index_backfilled( + &self, + tenant: &str, + entity_type: &str, + vector_set: &str, + ) -> Result<(), PersistenceError> { + let mut inner = self.inner.lock().expect("SimEventStore lock poisoned"); // ci-ok: infallible lock + inner.vector_index_watermark.insert( + (tenant.to_string(), entity_type.to_string()), + vector_set.to_string(), + ); + Ok(()) + } + + async fn vector_index_backfilled_types( + &self, + tenant: &str, + ) -> Result, PersistenceError> { + let inner = self.inner.lock().expect("SimEventStore lock poisoned"); // ci-ok: infallible lock + Ok(inner + .vector_index_watermark + .iter() + .filter(|((t, _), _)| t.as_str() == tenant) + .map(|((_, et), vector_set)| (et.clone(), vector_set.clone())) + .collect()) + } + + async fn vectored_entity_ids_for_type( + &self, + tenant: &str, + entity_type: &str, + ) -> Result, PersistenceError> { + let inner = self.inner.lock().expect("SimEventStore lock poisoned"); // ci-ok: infallible lock + let mut ids: BTreeSet = BTreeSet::new(); + for ((t, et, _, _, entity_id), _) in inner.vector_index.iter() { + if t.as_str() == tenant && et.as_str() == entity_type { + ids.insert(entity_id.clone()); + } + } + Ok(ids.into_iter().collect()) + } + async fn append_batch( &self, appends: &[PersistenceAppend], diff --git a/test-fixtures/specs/vectored_item.ioa.toml b/test-fixtures/specs/vectored_item.ioa.toml new file mode 100644 index 00000000..34b713f9 --- /dev/null +++ b/test-fixtures/specs/vectored_item.ioa.toml @@ -0,0 +1,37 @@ +# Vectored Item — minimal fixture for the ADR-0155 entity_vector_index DST. +# +# An Item carries an `Embedding` (a JSON-string float vector) and an +# `EmbeddingModel` tag, and declares a `[[vector]]` access path over them. The DST +# asserts that seeded writes + `Temper.Nearest` ranking reproduce across every seed: +# the kernel ranks a store-supplied, id-ordered candidate list, so the order is +# seed-independent. + +[automaton] +name = "Item" +states = ["New", "Ready"] +initial = "New" + +[[state]] +name = "Embedding" +type = "string" +initial = "" + +[[state]] +name = "EmbeddingModel" +type = "string" +initial = "" + +[[vector]] +name = "embed" +property = "Embedding" +model_property = "EmbeddingModel" +dims = 4 +metric = "cosine" + +[[action]] +name = "Create" +kind = "input" +from = ["New"] +to = "Ready" +params = ["Embedding", "EmbeddingModel"] +hint = "Create the item with its embedding vector (JSON string) and model tag." From 2f27fe665794892190e03f17ddaf7ec244738ca9 Mon Sep 17 00:00:00 2001 From: Rita Agafonova <36133358+rita-aga@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:38:42 -0700 Subject: [PATCH 4/9] =?UTF-8?q?feat(vector):=20postgres=20backend=20?= =?UTF-8?q?=E2=80=94=20co-commit=20vectors=20in=20the=20event=20tx=20(ARN-?= =?UTF-8?q?159)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 3 of ARN-159 (ADR-0155). Postgres now maintains entity_vector_index: - migration 0012 adds entity_vector_index (packed-LE-f32 BYTEA blob, PK on tenant/type/decl/model_tag/entity, partition + reverse indexes) and vector_index_backfill_watermark. - the event append co-commits vector rows in the SAME transaction as the journal (delete-then-insert per decl so a changed vector/model tag replaces cleanly), mirroring the declared-key co-commit — so a kNN read is consistent with committed state without a scan. - backfill_entity_vectors / vector_candidates (ORDER BY entity_id for deterministic ranking) / mark_vector_index_backfilled / vector_index_backfilled_types / vectored_entity_ids_for_type implemented against the new tables. - the LE-f32 blob encoders move to temper-runtime beside EntityVectorRow so the stores and the kernel ranking share one byte layout (server re-exports them). Co-Authored-By: Claude Fable 5 --- crates/temper-runtime/src/persistence/mod.rs | 24 +++ crates/temper-server/src/vector_index.rs | 27 +-- .../migrations/0012_entity_vector_index.sql | 45 +++++ crates/temper-store-postgres/src/store.rs | 188 +++++++++++++++++- 4 files changed, 257 insertions(+), 27 deletions(-) create mode 100644 crates/temper-store-postgres/migrations/0012_entity_vector_index.sql diff --git a/crates/temper-runtime/src/persistence/mod.rs b/crates/temper-runtime/src/persistence/mod.rs index a1cfd887..3f66a6a7 100644 --- a/crates/temper-runtime/src/persistence/mod.rs +++ b/crates/temper-runtime/src/persistence/mod.rs @@ -112,6 +112,30 @@ pub struct EntityVectorRow { pub vector: Vec, } +/// Pack an `f32` slice to little-endian bytes — the `entity_vector_index` blob +/// encoding shared by every backend (ADR-0155). Kept here beside [`EntityVectorRow`] +/// so the stores and the kernel ranking agree on the byte layout. +pub fn pack_f32_le(vector: &[f32]) -> Vec { + let mut bytes = Vec::with_capacity(vector.len() * 4); + for value in vector { + bytes.extend_from_slice(&value.to_le_bytes()); + } + bytes +} + +/// Unpack little-endian bytes back to `f32`. `None` if the byte length is not a +/// multiple of 4 (a corrupt blob), so a bad row is skipped rather than panicking. +pub fn unpack_f32_le(bytes: &[u8]) -> Option> { + if !bytes.len().is_multiple_of(4) { + return None; + } + let mut out = Vec::with_capacity(bytes.len() / 4); + for chunk in bytes.chunks_exact(4) { + out.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])); + } + Some(out) +} + /// One candidate row returned from the vector index for a kNN read (ADR-0155): /// an entity and its packed vector for one `(tenant, type, decl, model_tag)` /// partition. The kernel — not the store — computes the metric over these in the diff --git a/crates/temper-server/src/vector_index.rs b/crates/temper-server/src/vector_index.rs index 8cb11ed6..b2a19f95 100644 --- a/crates/temper-server/src/vector_index.rs +++ b/crates/temper-server/src/vector_index.rs @@ -12,6 +12,10 @@ //! simulation where app-side similarity never was. use temper_runtime::persistence::EntityVectorCandidate; +// The blob encoders live beside `EntityVectorRow` in temper-runtime so every store +// and the kernel ranking share one byte layout; re-exported here for callers that +// reach for them through the vector-index module. +pub use temper_runtime::persistence::{pack_f32_le, unpack_f32_le}; /// The similarity metric declared on a `[[vector]]` path. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -38,29 +42,6 @@ impl VectorMetric { } } -/// Pack an `f32` slice to little-endian bytes — the on-disk `entity_vector_index` -/// blob encoding. -pub fn pack_f32_le(vector: &[f32]) -> Vec { - let mut bytes = Vec::with_capacity(vector.len() * 4); - for value in vector { - bytes.extend_from_slice(&value.to_le_bytes()); - } - bytes -} - -/// Unpack little-endian bytes back to `f32`. `None` if the byte length is not a -/// multiple of 4 (a corrupt blob), so a bad row is skipped rather than panicking. -pub fn unpack_f32_le(bytes: &[u8]) -> Option> { - if !bytes.len().is_multiple_of(4) { - return None; - } - let mut out = Vec::with_capacity(bytes.len() / 4); - for chunk in bytes.chunks_exact(4) { - out.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])); - } - Some(out) -} - /// Parse an entity's vector property into exactly `dims` `f32`s. /// /// Accepts either a JSON array of numbers (`[0.1, 0.2, …]`) or a JSON **string** diff --git a/crates/temper-store-postgres/migrations/0012_entity_vector_index.sql b/crates/temper-store-postgres/migrations/0012_entity_vector_index.sql new file mode 100644 index 00000000..5991dead --- /dev/null +++ b/crates/temper-store-postgres/migrations/0012_entity_vector_index.sql @@ -0,0 +1,45 @@ +-- ADR-0155: declared vector access path — the exact-scan kNN index. +-- +-- One row per (declared vector path, model tag, entity), co-committed with the +-- journal append in the same transaction (like entity_key_index, unlike the +-- eventually-consistent entity_field_index). Unlike a key row this has NO +-- uniqueness constraint beyond the primary key identity — vectors are derived, +-- rebuildable ranking state, and two entities may hold identical vectors. +-- +-- `vector` is packed little-endian f32 (the journal keeps the human-readable JSON +-- on the action event; this blob is the derived copy). `model_tag` partitions the +-- space: a kNN query resolves against exactly one tag, so vectors from different +-- embedding models are never compared. `sequence_nr` carries the journal position. +CREATE TABLE IF NOT EXISTS entity_vector_index ( + tenant TEXT NOT NULL, + entity_type TEXT NOT NULL, + decl_name TEXT NOT NULL, + model_tag TEXT NOT NULL, + entity_id TEXT NOT NULL, + vector BYTEA NOT NULL, + sequence_nr BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (tenant, entity_type, decl_name, model_tag, entity_id) +); + +-- The kNN candidate scan: every vector in one (tenant, type, decl, model) partition, +-- read in entity_id order for deterministic ranking. +CREATE INDEX IF NOT EXISTS idx_evi_partition + ON entity_vector_index (tenant, entity_type, decl_name, model_tag, entity_id); + +-- Reverse lookup: all vector rows for an entity, so the write path can drop an +-- entity's prior rows for a decl when its vector or model tag changes. +CREATE INDEX IF NOT EXISTS idx_evi_entity + ON entity_vector_index (tenant, entity_type, entity_id); + +-- Per-(tenant, entity_type) backfill watermark (ADR-0155), mirroring +-- key_index_backfill_watermark. A row asserts every existing entity of the type has +-- had its declared vectors indexed, and records the covered vector-path set so a +-- newly declared path is detected as a set change and re-indexed. vector_set is the +-- sorted, comma-joined declared vector-path names the backfill covered. +CREATE TABLE IF NOT EXISTS vector_index_backfill_watermark ( + tenant TEXT NOT NULL, + entity_type TEXT NOT NULL, + vector_set TEXT NOT NULL DEFAULT '', + completed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (tenant, entity_type) +); diff --git a/crates/temper-store-postgres/src/store.rs b/crates/temper-store-postgres/src/store.rs index 9b344d04..d4f3d205 100644 --- a/crates/temper-store-postgres/src/store.rs +++ b/crates/temper-store-postgres/src/store.rs @@ -8,8 +8,8 @@ use std::time::Instant; use sqlx::{Acquire, PgPool}; use temper_runtime::persistence::{ - EventMetadata, EventStore, PersistenceAppend, PersistenceAppendResult, PersistenceEnvelope, - PersistenceError, + EntityVectorCandidate, EntityVectorRow, EventMetadata, EventStore, PersistenceAppend, + PersistenceAppendResult, PersistenceEnvelope, PersistenceError, pack_f32_le, unpack_f32_le, }; use temper_runtime::tenant::parse_persistence_id_parts; @@ -63,16 +63,17 @@ impl EventStore for PostgresEventStore { expected_sequence: u64, events: &[PersistenceEnvelope], ) -> Result { - self.append_with_keys(persistence_id, expected_sequence, events, &[]) + self.append_with_index_rows(persistence_id, expected_sequence, events, &[], &[]) .await } - async fn append_with_keys( + async fn append_with_index_rows( &self, persistence_id: &str, expected_sequence: u64, events: &[PersistenceEnvelope], key_rows: &[temper_runtime::persistence::EntityKeyRow], + vector_rows: &[EntityVectorRow], ) -> Result { let (tenant, entity_type, entity_id) = parse_persistence_id_parts(persistence_id).map_err(PersistenceError::Storage)?; @@ -243,6 +244,38 @@ impl EventStore for PostgresEventStore { .map_err(|e| PersistenceError::Storage(e.to_string()))?; } + // ADR-0155: co-commit the derived vector-index rows in THIS transaction. + // No uniqueness constraint — drop the entity's prior rows for each decl (its + // vector or model tag may have changed), then insert the current row. + for row in vector_rows { + crate::dbm::postgres_query!( + "DELETE FROM entity_vector_index \ + WHERE tenant = $1 AND entity_type = $2 AND decl_name = $3 AND entity_id = $4", + ) + .bind(tenant) + .bind(entity_type) + .bind(&row.decl_name) + .bind(entity_id) + .execute(&mut *tx) + .await + .map_err(|e| PersistenceError::Storage(e.to_string()))?; + crate::dbm::postgres_query!( + "INSERT INTO entity_vector_index \ + (tenant, entity_type, decl_name, model_tag, entity_id, vector, sequence_nr) \ + VALUES ($1, $2, $3, $4, $5, $6, $7)", + ) + .bind(tenant) + .bind(entity_type) + .bind(&row.decl_name) + .bind(&row.model_tag) + .bind(entity_id) + .bind(pack_f32_le(&row.vector)) + .bind(new_seq as i64) + .execute(&mut *tx) + .await + .map_err(|e| PersistenceError::Storage(e.to_string()))?; + } + let commit_started = Instant::now(); tx.commit().await.map_err(|e| { record_postgres_transaction_commit_duration( @@ -430,6 +463,153 @@ impl EventStore for PostgresEventStore { Ok(row.map(|(id,)| id)) } + async fn backfill_entity_vectors( + &self, + tenant: &str, + entity_type: &str, + entity_id: &str, + vector_rows: &[EntityVectorRow], + ) -> Result<(), PersistenceError> { + if vector_rows.is_empty() { + return Ok(()); + } + let mut tx = self + .pool + .begin() + .await + .map_err(|e| PersistenceError::Storage(e.to_string()))?; + for row in vector_rows { + crate::dbm::postgres_query!( + "DELETE FROM entity_vector_index \ + WHERE tenant = $1 AND entity_type = $2 AND decl_name = $3 AND entity_id = $4", + ) + .bind(tenant) + .bind(entity_type) + .bind(&row.decl_name) + .bind(entity_id) + .execute(&mut *tx) + .await + .map_err(|e| PersistenceError::Storage(e.to_string()))?; + crate::dbm::postgres_query!( + "INSERT INTO entity_vector_index \ + (tenant, entity_type, decl_name, model_tag, entity_id, vector, sequence_nr) \ + VALUES ($1, $2, $3, $4, $5, $6, 0)", + ) + .bind(tenant) + .bind(entity_type) + .bind(&row.decl_name) + .bind(&row.model_tag) + .bind(entity_id) + .bind(pack_f32_le(&row.vector)) + .execute(&mut *tx) + .await + .map_err(|e| PersistenceError::Storage(e.to_string()))?; + } + tx.commit() + .await + .map_err(|e| PersistenceError::Storage(e.to_string()))?; + Ok(()) + } + + async fn vector_candidates( + &self, + tenant: &str, + entity_type: &str, + decl_name: &str, + model_tag: &str, + ) -> Result, PersistenceError> { + let mut conn = self + .pool + .acquire() + .await + .map_err(|e| PersistenceError::Storage(e.to_string()))?; + let rows: Vec<(String, Vec)> = crate::dbm::postgres_query_as!( + "SELECT entity_id, vector FROM entity_vector_index \ + WHERE tenant = $1 AND entity_type = $2 AND decl_name = $3 AND model_tag = $4 \ + ORDER BY entity_id", + ) + .bind(tenant) + .bind(entity_type) + .bind(decl_name) + .bind(model_tag) + .fetch_all(&mut *conn) + .await + .map_err(|e| PersistenceError::Storage(e.to_string()))?; + Ok(rows + .into_iter() + .filter_map(|(entity_id, bytes)| { + unpack_f32_le(&bytes).map(|vector| EntityVectorCandidate { entity_id, vector }) + }) + .collect()) + } + + async fn mark_vector_index_backfilled( + &self, + tenant: &str, + entity_type: &str, + vector_set: &str, + ) -> Result<(), PersistenceError> { + let mut conn = self + .pool + .acquire() + .await + .map_err(|e| PersistenceError::Storage(e.to_string()))?; + crate::dbm::postgres_query!( + "INSERT INTO vector_index_backfill_watermark (tenant, entity_type, vector_set) \ + VALUES ($1, $2, $3) \ + ON CONFLICT (tenant, entity_type) \ + DO UPDATE SET vector_set = EXCLUDED.vector_set, completed_at = now()", + ) + .bind(tenant) + .bind(entity_type) + .bind(vector_set) + .execute(&mut *conn) + .await + .map_err(|e| PersistenceError::Storage(e.to_string()))?; + Ok(()) + } + + async fn vector_index_backfilled_types( + &self, + tenant: &str, + ) -> Result, PersistenceError> { + let mut conn = self + .pool + .acquire() + .await + .map_err(|e| PersistenceError::Storage(e.to_string()))?; + let rows: Vec<(String, String)> = crate::dbm::postgres_query_as!( + "SELECT entity_type, vector_set FROM vector_index_backfill_watermark WHERE tenant = $1", + ) + .bind(tenant) + .fetch_all(&mut *conn) + .await + .map_err(|e| PersistenceError::Storage(e.to_string()))?; + Ok(rows.into_iter().collect()) + } + + async fn vectored_entity_ids_for_type( + &self, + tenant: &str, + entity_type: &str, + ) -> Result, PersistenceError> { + let mut conn = self + .pool + .acquire() + .await + .map_err(|e| PersistenceError::Storage(e.to_string()))?; + let rows: Vec<(String,)> = crate::dbm::postgres_query_as!( + "SELECT DISTINCT entity_id FROM entity_vector_index \ + WHERE tenant = $1 AND entity_type = $2", + ) + .bind(tenant) + .bind(entity_type) + .fetch_all(&mut *conn) + .await + .map_err(|e| PersistenceError::Storage(e.to_string()))?; + Ok(rows.into_iter().map(|(entity_id,)| entity_id).collect()) + } + /// Atomically append to multiple entity journals in one PostgreSQL /// transaction. Used as the storage foundation for cross-actor Composite /// transactions: every stream's optimistic-concurrency check must pass From c64679e694cb2fdb312ab00b03127a924cb77aaf Mon Sep 17 00:00:00 2001 From: Rita Agafonova <36133358+rita-aga@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:45:05 -0700 Subject: [PATCH 5/9] =?UTF-8?q?feat(vector):=20turso=20backend=20=E2=80=94?= =?UTF-8?q?=20write-behind=20index=20+=20watermark=20(ARN-159)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 4 of ARN-159 (ADR-0155). Turso now maintains entity_vector_index: - schema adds entity_vector_index (BLOB vector, partition + reverse indexes) and vector_index_backfill_watermark, created at store init. - append_with_index_rows is overridden write-behind: the event append runs first (with its retry logic), then the derived vector rows follow in a separate write. Safe for vectors (no uniqueness constraint) where it is not for keys — a lagging or failed index write only makes a ranking temporarily incomplete, which the backfill watermark accounts for; it can never corrupt a keyed absence. - vector_candidates (ORDER BY entity_id) / backfill_entity_vectors (upsert) / mark_vector_index_backfilled / vector_index_backfilled_types / vectored_entity_ids_for_type implemented; TenantStoreRouter forwards them all so kNN works on the routed production deployment. - a local-SQLite turso test proves write-behind candidates, model partitioning, upsert-replace, and the watermark/id-listing roundtrip. Co-Authored-By: Claude Fable 5 --- crates/temper-store-turso/src/router.rs | 88 +++++++++ crates/temper-store-turso/src/schema.rs | 2 + .../src/schema/query_plane.rs | 41 ++++ .../src/store/event_store.rs | 183 +++++++++++++++++- crates/temper-store-turso/src/store/mod.rs | 15 ++ .../temper-store-turso/src/store/tests/mod.rs | 87 ++++++++- 6 files changed, 413 insertions(+), 3 deletions(-) diff --git a/crates/temper-store-turso/src/router.rs b/crates/temper-store-turso/src/router.rs index a29fb992..da44d66b 100644 --- a/crates/temper-store-turso/src/router.rs +++ b/crates/temper-store-turso/src/router.rs @@ -739,6 +739,94 @@ impl EventStore for TenantStoreRouter { let store = self.store_for_tenant(tenant).await?; store.list_entity_ids_by_type(tenant, entity_type).await } + + // ADR-0155: forward the vector-index surface to the per-tenant store so kNN works + // on the routed Turso deployment. (Keys deliberately fall through to the no-op + // defaults — Turso does not maintain entity_key_index live; see event_store.rs.) + #[instrument(skip_all, fields(persistence_id, otel.name = "router.append_with_index_rows"))] + async fn append_with_index_rows( + &self, + persistence_id: &str, + expected_sequence: u64, + events: &[PersistenceEnvelope], + key_rows: &[temper_runtime::persistence::EntityKeyRow], + vector_rows: &[temper_runtime::persistence::EntityVectorRow], + ) -> Result { + let (tenant, _, _) = + parse_persistence_id_parts(persistence_id).map_err(PersistenceError::Storage)?; + let store = self.store_for_tenant(tenant).await?; + store + .append_with_index_rows( + persistence_id, + expected_sequence, + events, + key_rows, + vector_rows, + ) + .await + } + + #[instrument(skip_all, fields(tenant, entity_type, otel.name = "router.backfill_entity_vectors"))] + async fn backfill_entity_vectors( + &self, + tenant: &str, + entity_type: &str, + entity_id: &str, + vector_rows: &[temper_runtime::persistence::EntityVectorRow], + ) -> Result<(), PersistenceError> { + let store = self.store_for_tenant(tenant).await?; + store + .backfill_entity_vectors(tenant, entity_type, entity_id, vector_rows) + .await + } + + #[instrument(skip_all, fields(tenant, entity_type, otel.name = "router.vector_candidates"))] + async fn vector_candidates( + &self, + tenant: &str, + entity_type: &str, + decl_name: &str, + model_tag: &str, + ) -> Result, PersistenceError> { + let store = self.store_for_tenant(tenant).await?; + store + .vector_candidates(tenant, entity_type, decl_name, model_tag) + .await + } + + #[instrument(skip_all, fields(tenant, entity_type, otel.name = "router.mark_vector_index_backfilled"))] + async fn mark_vector_index_backfilled( + &self, + tenant: &str, + entity_type: &str, + vector_set: &str, + ) -> Result<(), PersistenceError> { + let store = self.store_for_tenant(tenant).await?; + store + .mark_vector_index_backfilled(tenant, entity_type, vector_set) + .await + } + + #[instrument(skip_all, fields(tenant, otel.name = "router.vector_index_backfilled_types"))] + async fn vector_index_backfilled_types( + &self, + tenant: &str, + ) -> Result, PersistenceError> { + let store = self.store_for_tenant(tenant).await?; + store.vector_index_backfilled_types(tenant).await + } + + #[instrument(skip_all, fields(tenant, entity_type, otel.name = "router.vectored_entity_ids_for_type"))] + async fn vectored_entity_ids_for_type( + &self, + tenant: &str, + entity_type: &str, + ) -> Result, PersistenceError> { + let store = self.store_for_tenant(tenant).await?; + store + .vectored_entity_ids_for_type(tenant, entity_type) + .await + } } #[cfg(test)] diff --git a/crates/temper-store-turso/src/schema.rs b/crates/temper-store-turso/src/schema.rs index b0228a37..1012b2e0 100644 --- a/crates/temper-store-turso/src/schema.rs +++ b/crates/temper-store-turso/src/schema.rs @@ -11,6 +11,8 @@ pub use query_plane::{ CREATE_ENTITY_CATALOG_TYPE_INDEX, CREATE_ENTITY_FIELD_INDEX_LOOKUP, CREATE_ENTITY_FIELD_INDEX_STATUS, CREATE_ENTITY_FIELD_INDEX_TABLE, CREATE_ENTITY_KEY_INDEX_ENTITY, CREATE_ENTITY_KEY_INDEX_TABLE, + CREATE_ENTITY_VECTOR_INDEX_ENTITY, CREATE_ENTITY_VECTOR_INDEX_PARTITION, + CREATE_ENTITY_VECTOR_INDEX_TABLE, CREATE_VECTOR_INDEX_BACKFILL_WATERMARK, }; pub const CREATE_EVENTS_TABLE: &str = "\ diff --git a/crates/temper-store-turso/src/schema/query_plane.rs b/crates/temper-store-turso/src/schema/query_plane.rs index 4ba9c590..827e3b8d 100644 --- a/crates/temper-store-turso/src/schema/query_plane.rs +++ b/crates/temper-store-turso/src/schema/query_plane.rs @@ -74,3 +74,44 @@ CREATE TABLE IF NOT EXISTS entity_key_index ( pub const CREATE_ENTITY_KEY_INDEX_ENTITY: &str = "\ CREATE INDEX IF NOT EXISTS idx_eki_entity ON entity_key_index(tenant, entity_type, entity_id);"; + +/// ADR-0155: declared vector access path — the exact-scan kNN index. One row per +/// (declared vector path, model tag, entity). `vector` is packed little-endian +/// f32; `model_tag` partitions the space. Unlike keys, Turso maintains this +/// **write-behind** (the event append is followed by the index write, not +/// co-committed) — safe because a vector row carries no uniqueness constraint; the +/// backfill watermark gates when the index is authoritatively complete. +pub const CREATE_ENTITY_VECTOR_INDEX_TABLE: &str = "\ +CREATE TABLE IF NOT EXISTS entity_vector_index ( + tenant TEXT NOT NULL, + entity_type TEXT NOT NULL, + decl_name TEXT NOT NULL, + model_tag TEXT NOT NULL, + entity_id TEXT NOT NULL, + vector BLOB NOT NULL, + sequence_nr INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (tenant, entity_type, decl_name, model_tag, entity_id) +);"; + +/// The kNN candidate scan: every vector in one partition, in entity_id order. +pub const CREATE_ENTITY_VECTOR_INDEX_PARTITION: &str = "\ +CREATE INDEX IF NOT EXISTS idx_evi_partition + ON entity_vector_index(tenant, entity_type, decl_name, model_tag, entity_id);"; + +/// Reverse lookup: all vector rows for an entity, so the write path can replace an +/// entity's rows for a decl when its vector or model tag changes. +pub const CREATE_ENTITY_VECTOR_INDEX_ENTITY: &str = "\ +CREATE INDEX IF NOT EXISTS idx_evi_entity + ON entity_vector_index(tenant, entity_type, entity_id);"; + +/// Per-(tenant, entity_type) vector-index backfill watermark (ADR-0155): records +/// the covered vector-path set so a keyed read knows when the index is complete and +/// re-indexes on a set change. Mirrors `key_index_backfill_watermark`. +pub const CREATE_VECTOR_INDEX_BACKFILL_WATERMARK: &str = "\ +CREATE TABLE IF NOT EXISTS vector_index_backfill_watermark ( + tenant TEXT NOT NULL, + entity_type TEXT NOT NULL, + vector_set TEXT NOT NULL DEFAULT '', + completed_at TEXT NOT NULL DEFAULT '', + PRIMARY KEY (tenant, entity_type) +);"; diff --git a/crates/temper-store-turso/src/store/event_store.rs b/crates/temper-store-turso/src/store/event_store.rs index f02f7a30..f9f4f877 100644 --- a/crates/temper-store-turso/src/store/event_store.rs +++ b/crates/temper-store-turso/src/store/event_store.rs @@ -3,8 +3,9 @@ use libsql::{TransactionBehavior, Value, params, params_from_iter}; use std::time::Duration; use temper_runtime::persistence::{ - EventMetadata, EventStore, PersistenceAppend, PersistenceAppendResult, PersistenceEnvelope, - PersistenceError, storage_error, + EntityVectorCandidate, EntityVectorRow, EventMetadata, EventStore, PersistenceAppend, + PersistenceAppendResult, PersistenceEnvelope, PersistenceError, pack_f32_le, storage_error, + unpack_f32_le, }; use temper_runtime::tenant::parse_persistence_id_parts; use tracing::{instrument, warn}; @@ -147,6 +148,184 @@ impl EventStore for TursoEventStore { // DST. Giving Turso the keyed oracle requires first implementing live co-commit // (completing ADR-0153 phase 2 for Turso) — tracked separately. + // ADR-0155: Turso maintains `entity_vector_index` **write-behind** — the event is + // appended first (with retries), then the derived vector rows follow in a separate + // write. This is safe for vectors (unlike keys) because a vector row carries no + // uniqueness constraint and a lagging/failed index write only makes a ranking + // temporarily incomplete, which the backfill watermark accounts for; it can never + // corrupt a keyed absence. So Turso implements the full vector surface below. + async fn append_with_index_rows( + &self, + persistence_id: &str, + expected_sequence: u64, + events: &[PersistenceEnvelope], + _key_rows: &[temper_runtime::persistence::EntityKeyRow], + vector_rows: &[EntityVectorRow], + ) -> Result { + // The journal append is the durable event (keys are not maintained on Turso, + // per the note above). + let new_seq = self + .append(persistence_id, expected_sequence, events) + .await?; + // Write-behind: the event is durable; the derived vector index follows. A + // failure here is logged, not fatal — the backfill reconciles the partition. + if !vector_rows.is_empty() + && let Ok((tenant, entity_type, entity_id)) = parse_persistence_id_parts(persistence_id) + && let Err(error) = self + .backfill_entity_vectors(tenant, entity_type, entity_id, vector_rows) + .await + { + warn!( + persistence_id, + error = %error, + "turso vector-index write-behind failed; backfill will reconcile the partition" + ); + } + Ok(new_seq) + } + + async fn backfill_entity_vectors( + &self, + tenant: &str, + entity_type: &str, + entity_id: &str, + vector_rows: &[EntityVectorRow], + ) -> Result<(), PersistenceError> { + if vector_rows.is_empty() { + return Ok(()); + } + let _write_permit = self + .acquire_write_permit("turso.backfill_entity_vectors", WritePriority::Low) + .await?; + let conn = self.configured_connection().await?; + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .await + .map_err(storage_error)?; + for row in vector_rows { + // Upsert: drop the entity's prior rows for this decl (its vector or model + // tag may have changed), then insert the current row. + tx.execute( + "DELETE FROM entity_vector_index \ + WHERE tenant = ?1 AND entity_type = ?2 AND decl_name = ?3 AND entity_id = ?4", + params![tenant, entity_type, row.decl_name.as_str(), entity_id], + ) + .await + .map_err(storage_error)?; + tx.execute( + "INSERT INTO entity_vector_index \ + (tenant, entity_type, decl_name, model_tag, entity_id, vector, sequence_nr) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0)", + params![ + tenant, + entity_type, + row.decl_name.as_str(), + row.model_tag.as_str(), + entity_id, + Value::Blob(pack_f32_le(&row.vector)), + ], + ) + .await + .map_err(storage_error)?; + } + tx.commit().await.map_err(storage_error)?; + Ok(()) + } + + async fn vector_candidates( + &self, + tenant: &str, + entity_type: &str, + decl_name: &str, + model_tag: &str, + ) -> Result, PersistenceError> { + let conn = self.configured_connection().await?; + let mut rows = conn + .query( + "SELECT entity_id, vector FROM entity_vector_index \ + WHERE tenant = ?1 AND entity_type = ?2 AND decl_name = ?3 AND model_tag = ?4 \ + ORDER BY entity_id", + params![tenant, entity_type, decl_name, model_tag], + ) + .await + .map_err(storage_error)?; + let mut out = Vec::new(); + while let Some(row) = rows.next().await.map_err(storage_error)? { + let entity_id: String = row.get(0).map_err(storage_error)?; + let bytes: Vec = row.get(1).map_err(storage_error)?; + if let Some(vector) = unpack_f32_le(&bytes) { + out.push(EntityVectorCandidate { entity_id, vector }); + } + } + Ok(out) + } + + async fn mark_vector_index_backfilled( + &self, + tenant: &str, + entity_type: &str, + vector_set: &str, + ) -> Result<(), PersistenceError> { + let _write_permit = self + .acquire_write_permit("turso.mark_vector_index_backfilled", WritePriority::Low) + .await?; + let conn = self.configured_connection().await?; + let completed_at = temper_runtime::scheduler::sim_now().to_rfc3339(); + conn.execute( + "INSERT INTO vector_index_backfill_watermark (tenant, entity_type, vector_set, completed_at) \ + VALUES (?1, ?2, ?3, ?4) \ + ON CONFLICT(tenant, entity_type) \ + DO UPDATE SET vector_set = excluded.vector_set, completed_at = excluded.completed_at", + params![tenant, entity_type, vector_set, completed_at.as_str()], + ) + .await + .map_err(storage_error)?; + Ok(()) + } + + async fn vector_index_backfilled_types( + &self, + tenant: &str, + ) -> Result, PersistenceError> { + let conn = self.configured_connection().await?; + let mut rows = conn + .query( + "SELECT entity_type, vector_set FROM vector_index_backfill_watermark \ + WHERE tenant = ?1", + params![tenant], + ) + .await + .map_err(storage_error)?; + let mut out = Vec::new(); + while let Some(row) = rows.next().await.map_err(storage_error)? { + let entity_type: String = row.get(0).map_err(storage_error)?; + let vector_set: String = row.get(1).map_err(storage_error)?; + out.push((entity_type, vector_set)); + } + Ok(out) + } + + async fn vectored_entity_ids_for_type( + &self, + tenant: &str, + entity_type: &str, + ) -> Result, PersistenceError> { + let conn = self.configured_connection().await?; + let mut rows = conn + .query( + "SELECT DISTINCT entity_id FROM entity_vector_index \ + WHERE tenant = ?1 AND entity_type = ?2", + params![tenant, entity_type], + ) + .await + .map_err(storage_error)?; + let mut out = Vec::new(); + while let Some(row) = rows.next().await.map_err(storage_error)? { + out.push(row.get::(0).map_err(storage_error)?); + } + Ok(out) + } + #[instrument(skip_all, fields(otel.name = "turso.append_batch"))] async fn append_batch( &self, diff --git a/crates/temper-store-turso/src/store/mod.rs b/crates/temper-store-turso/src/store/mod.rs index 89eb8e81..fba0d839 100644 --- a/crates/temper-store-turso/src/store/mod.rs +++ b/crates/temper-store-turso/src/store/mod.rs @@ -391,6 +391,21 @@ impl TursoEventStore { .await .map_err(storage_error)?; + // Entity vector index (ADR-0155) — declared vector paths for exact-scan kNN, + // maintained write-behind (the event append is followed by the index write). + conn.execute(schema::CREATE_ENTITY_VECTOR_INDEX_TABLE, ()) + .await + .map_err(storage_error)?; + conn.execute(schema::CREATE_ENTITY_VECTOR_INDEX_PARTITION, ()) + .await + .map_err(storage_error)?; + conn.execute(schema::CREATE_ENTITY_VECTOR_INDEX_ENTITY, ()) + .await + .map_err(storage_error)?; + conn.execute(schema::CREATE_VECTOR_INDEX_BACKFILL_WATERMARK, ()) + .await + .map_err(storage_error)?; + Ok(()) } diff --git a/crates/temper-store-turso/src/store/tests/mod.rs b/crates/temper-store-turso/src/store/tests/mod.rs index 892fcac9..595a8186 100644 --- a/crates/temper-store-turso/src/store/tests/mod.rs +++ b/crates/temper-store-turso/src/store/tests/mod.rs @@ -2,7 +2,8 @@ use libsql::params; use temper_runtime::persistence::{ - EventMetadata, EventStore, PersistenceAppend, PersistenceEnvelope, PersistenceError, + EntityVectorRow, EventMetadata, EventStore, PersistenceAppend, PersistenceEnvelope, + PersistenceError, }; use super::{PublishedArtifactUpsert, QueryProjectionUpsert, TursoEventStore}; @@ -65,6 +66,90 @@ async fn append_and_read_events_roundtrip() { assert_eq!(events[1].event_type, "OrderApproved"); } +#[tokio::test] +async fn vector_index_write_behind_candidates_and_partitioning() { + // ADR-0155: Turso maintains entity_vector_index write-behind (event first, index + // follows). A candidate scan returns the partition's vectors in entity_id order, + // partitioned by model tag; a raw kNN read never sees another model's vectors. + let store = make_store("vector-index").await; + let row = |decl: &str, model: &str, v: Vec| EntityVectorRow { + decl_name: decl.to_string(), + model_tag: model.to_string(), + vector: v, + }; + + store + .append_with_index_rows( + "t:Item:item-b", + 0, + &[test_envelope("Create", serde_json::json!({}))], + &[], + &[row("embed", "m1", vec![0.0, 1.0])], + ) + .await + .unwrap(); + store + .append_with_index_rows( + "t:Item:item-a", + 0, + &[test_envelope("Create", serde_json::json!({}))], + &[], + &[row("embed", "m1", vec![1.0, 0.0])], + ) + .await + .unwrap(); + // A different model tag — must not appear in an m1 scan. + store + .append_with_index_rows( + "t:Item:item-c", + 0, + &[test_envelope("Create", serde_json::json!({}))], + &[], + &[row("embed", "m2", vec![1.0, 0.0])], + ) + .await + .unwrap(); + + let candidates = store + .vector_candidates("t", "Item", "embed", "m1") + .await + .unwrap(); + // Two m1 items, in entity_id order (a before b) with their vectors intact. + assert_eq!(candidates.len(), 2); + assert_eq!(candidates[0].entity_id, "item-a"); + assert_eq!(candidates[0].vector, vec![1.0, 0.0]); + assert_eq!(candidates[1].entity_id, "item-b"); + assert_eq!(candidates[1].vector, vec![0.0, 1.0]); + + // Upsert: re-writing item-a's vector replaces (no duplicate row). + store + .backfill_entity_vectors("t", "Item", "item-a", &[row("embed", "m1", vec![0.5, 0.5])]) + .await + .unwrap(); + let candidates = store + .vector_candidates("t", "Item", "embed", "m1") + .await + .unwrap(); + assert_eq!(candidates.len(), 2); + assert_eq!(candidates[0].vector, vec![0.5, 0.5]); + + // Watermark roundtrip + resumable id listing. + store + .mark_vector_index_backfilled("t", "Item", "embed") + .await + .unwrap(); + assert_eq!( + store.vector_index_backfilled_types("t").await.unwrap(), + vec![("Item".to_string(), "embed".to_string())] + ); + let mut ids = store + .vectored_entity_ids_for_type("t", "Item") + .await + .unwrap(); + ids.sort(); + assert_eq!(ids, vec!["item-a", "item-b", "item-c"]); +} + #[tokio::test] async fn append_with_wrong_sequence_fails_with_concurrency_violation() { let store = make_store("concurrency").await; From bab309bf6fa650afca74476321e9ff49928237b6 Mon Sep 17 00:00:00 2001 From: Rita Agafonova <36133358+rita-aga@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:06:05 -0700 Subject: [PATCH 6/9] feat(vector): backfill driver + boot/spec-change wiring + HTTP e2e (ARN-159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 5 of ARN-159 (ADR-0155), completing kernel v1: - populate_vector_index_from_snapshots: the declared-vector backfill, mirroring the declared-key one — authoritative enumeration, strict per-entity state load, parse each declared [[vector]] to a row, idempotent upsert, and a per-(tenant,type) watermark set only when every existing entity was indexed or is skippable. Reuses a shared EntityLoadOutcome/load_entity_current_fields helper factored out of the key backfill (no duplication). - wired into boot (bootstrap spawns it per tenant after the key backfill) and into the post-spec-change reconcile (so a newly declared [[vector]] path re-indexes existing entities immediately), watermark-gated so unchanged types skip. - Temper.Nearest reads reference/filter fields from the temper OData body's nested `fields` object (fixed via a `fields`-aware, snake/Pascal-tolerant lookup). - HTTP end-to-end test through the real axum router: create entities (vectors co-commit), GET Temper.Nearest by reference (self-excluded, ordered, @temper.score) and by raw vector, plus a 400 for an unknown decl. Kernel v1 is complete across sim/postgres/turso; consumer cutovers are a separate effort. Co-Authored-By: Claude Fable 5 --- crates/temper-cli/src/serve/bootstrap.rs | 6 + .../temper-platform/src/os_apps/reconcile.rs | 4 + crates/temper-server/src/odata/nearest.rs | 47 ++-- crates/temper-server/src/state/entity_ops.rs | 8 + .../src/state/projection_backfill.rs | 55 +++++ .../state/projection_backfill/key_index.rs | 71 +----- .../state/projection_backfill/vector_index.rs | 220 ++++++++++++++++++ crates/temper-server/src/vector_index.rs | 14 ++ crates/temper-server/tests/nearest_odata.rs | 191 +++++++++++++++ 9 files changed, 533 insertions(+), 83 deletions(-) create mode 100644 crates/temper-server/src/state/projection_backfill/vector_index.rs create mode 100644 crates/temper-server/tests/nearest_odata.rs diff --git a/crates/temper-cli/src/serve/bootstrap.rs b/crates/temper-cli/src/serve/bootstrap.rs index d48913a1..2e206e45 100644 --- a/crates/temper-cli/src/serve/bootstrap.rs +++ b/crates/temper-cli/src/serve/bootstrap.rs @@ -267,6 +267,12 @@ pub(super) async fn hydrate_entities(state: &PlatformState, apps: &[(String, Str for tenant_id in &all_tenants { server.populate_key_index_from_snapshots(tenant_id).await; } + // ADR-0155: backfill the declared-vector index (parse + upsert one row per + // declared path per entity), so pre-existing / write-behind entities are + // rankable by Temper.Nearest and the per-type watermark is set. + for tenant_id in &all_tenants { + server.populate_vector_index_from_snapshots(tenant_id).await; + } // Then the broad field index for OData filter push-down. for tenant_id in all_tenants { server.populate_field_index_from_snapshots(&tenant_id).await; diff --git a/crates/temper-platform/src/os_apps/reconcile.rs b/crates/temper-platform/src/os_apps/reconcile.rs index 296f199d..cc617c80 100644 --- a/crates/temper-platform/src/os_apps/reconcile.rs +++ b/crates/temper-platform/src/os_apps/reconcile.rs @@ -513,5 +513,9 @@ pub(super) fn spawn_key_index_rekey_after_spec_change(state: &PlatformState, ten let tenant = tenant_id.clone(); tokio::spawn(async move { server.populate_key_index_from_snapshots(&tenant).await; + // ADR-0155: same race for a newly declared [[vector]] path — a late reconcile + // registers it after boot, so re-index existing entities here (watermark-gated, + // unchanged types skip) so they are immediately rankable by Temper.Nearest. + server.populate_vector_index_from_snapshots(&tenant).await; }); } diff --git a/crates/temper-server/src/odata/nearest.rs b/crates/temper-server/src/odata/nearest.rs index e08be4ba..df39aa08 100644 --- a/crates/temper-server/src/odata/nearest.rs +++ b/crates/temper-server/src/odata/nearest.rs @@ -36,6 +36,28 @@ fn arg<'a>(params: &'a [(String, String)], name: &str) -> Option<&'a str> { .map(|(_, value)| value.as_str()) } +/// Read a property off a materialized entity body. The temper OData body nests state +/// properties under a `"fields"` object (`body["fields"]["Embedding"]`); some catalog +/// shapes also flatten them to the top level. Check `fields` first, then top level, +/// each tolerant of the snake/Pascal field-name split the rest of the read plane +/// accommodates. +fn entity_field<'a>(body: &'a serde_json::Value, name: &str) -> Option<&'a serde_json::Value> { + let snake = temper_spec::to_snake_case(name); + let pascal = temper_spec::to_pascal_case(name); + if let Some(fields) = body.get("fields").and_then(|f| f.as_object()) + && let Some(value) = fields + .get(name) + .or_else(|| fields.get(&snake)) + .or_else(|| fields.get(&pascal)) + { + return Some(value); + } + let obj = body.as_object()?; + obj.get(name) + .or_else(|| obj.get(&snake)) + .or_else(|| obj.get(&pascal)) +} + fn bad_request(message: &str) -> Response { odata_error(StatusCode::BAD_REQUEST, "InvalidNearestQuery", message).into_response() } @@ -145,8 +167,7 @@ pub(super) async fn handle_nearest( ) .into_response(); }; - let Some(vector) = body - .get(&decl.property) + let Some(vector) = entity_field(&body, &decl.property) .and_then(|v| parse_vector_property(v, decl.dims)) else { return bad_request(&format!( @@ -156,8 +177,7 @@ pub(super) async fn handle_nearest( }; let model_tag = match model_arg { Some(tag) => tag.to_string(), - None => match body - .get(&decl.model_property) + None => match entity_field(&body, &decl.model_property) .and_then(|v| v.as_str()) .filter(|tag| !tag.is_empty()) { @@ -308,20 +328,13 @@ pub(super) async fn handle_nearest( /// Whether a materialized entity body satisfies every equality predicate. A /// missing field matches only `field eq null`. Values compare structurally (the -/// filter literals are already JSON), tolerant of the snake/Pascal field-name -/// split the rest of the read plane accommodates. +/// filter literals are already JSON). Field lookup is `fields`-nesting aware and +/// tolerant of the snake/Pascal split, via [`entity_field`]. fn body_matches_equality(body: &serde_json::Value, pairs: &[(String, serde_json::Value)]) -> bool { - let Some(obj) = body.as_object() else { - return false; - }; - pairs.iter().all(|(field, expected)| { - let actual = obj - .get(field) - .or_else(|| obj.get(&temper_spec::to_snake_case(field))) - .or_else(|| obj.get(&temper_spec::to_pascal_case(field))); - match actual { + pairs + .iter() + .all(|(field, expected)| match entity_field(body, field) { Some(value) => value == expected, None => expected.is_null(), - } - }) + }) } diff --git a/crates/temper-server/src/state/entity_ops.rs b/crates/temper-server/src/state/entity_ops.rs index b1276054..633df47b 100644 --- a/crates/temper-server/src/state/entity_ops.rs +++ b/crates/temper-server/src/state/entity_ops.rs @@ -481,6 +481,14 @@ impl ServerState { projection_backfill::populate_key_index_from_snapshots(self, tenant).await; } + /// ADR-0155: backfill `entity_vector_index` for pre-existing entities of every + /// vector-declaring type and record the watermark. Idempotent; entities written + /// after boot maintain their vectors inline (co-commit) or write-behind. + #[instrument(skip_all, fields(otel.name = "entity.populate_vector_index", tenant = %tenant))] + pub async fn populate_vector_index_from_snapshots(&self, tenant: &TenantId) { + projection_backfill::populate_vector_index_from_snapshots(self, tenant).await; + } + /// Hydrate the per-tenant `entity_key_index` watermark cache once from the durable /// watermark (ADR-0153). Safe to call repeatedly; conservative on any failure (leaves /// the type uncovered → a keyed miss falls back to the scan, never a wrong "absent"). diff --git a/crates/temper-server/src/state/projection_backfill.rs b/crates/temper-server/src/state/projection_backfill.rs index 75835392..72f511c8 100644 --- a/crates/temper-server/src/state/projection_backfill.rs +++ b/crates/temper-server/src/state/projection_backfill.rs @@ -10,9 +10,11 @@ use super::ServerState; mod key_index; mod replay_parity; +mod vector_index; pub(super) use key_index::populate_key_index_from_snapshots; pub(super) use replay_parity::verify_query_projection_replay_parity; +pub(super) use vector_index::populate_vector_index_from_snapshots; pub(super) fn transition_table_for( state: &ServerState, @@ -33,6 +35,59 @@ pub(super) fn transition_table_for( }) } +/// Outcome of loading one entity's current state for an index backfill (ADR-0153, +/// ADR-0155). Shared by the key and vector backfills so they classify entities the +/// same way — the distinction is the watermark soundness gate. +pub(super) enum EntityLoadOutcome { + /// Loaded — index it from these fields. + Fields(serde_json::Value), + /// Definitively skippable: deleted, or a phantom with no events. Correctly NOT + /// indexed, and NOT a failure (it must not block the watermark). + Skip, + /// The entity exists (it was enumerated from the durable store) but its current + /// state could not be loaded — no transition table to replay with, an unreadable + /// snapshot, or a replay error. Indexing it is impossible, so the type must NOT be + /// watermarked; otherwise a read would treat a present-but-unindexed entity as + /// authoritatively covered. This is the soundness gate. + LoadFailed, +} + +/// Load one entity's CURRENT state for an index backfill: snapshot if present and +/// readable, else strict event replay (so a field mutated after the last snapshot is +/// indexed at its current value, and a journal read failure fails the watermark +/// rather than silently "starting fresh"). +pub(super) async fn load_entity_current_fields( + tenant: &TenantId, + entity_type: &str, + entity_id: &str, + table: Option<&temper_jit::TransitionTable>, + store: &crate::storage::BoxedEventStore, + backend: crate::storage::BackendLabel, + blob_store: Option<&crate::blob_store::BlobStore>, +) -> EntityLoadOutcome { + let Some(table) = table else { + return EntityLoadOutcome::LoadFailed; + }; + match recover_entity_state_from_store( + tenant.as_str(), + entity_type, + entity_id, + table, + store, + backend, + &serde_json::json!({}), + blob_store, + true, // strict: a journal read failure → Err → LoadFailed (don't watermark) + ) + .await + { + Err(_) => EntityLoadOutcome::LoadFailed, + Ok(state) if state.status == "Deleted" => EntityLoadOutcome::Skip, + Ok(state) if state.total_event_count == 0 => EntityLoadOutcome::Skip, + Ok(state) => EntityLoadOutcome::Fields(state.fields), + } +} + /// Backfill the broad `entity_field_index` (every field of every entity) so the native /// AND-equality candidate pushdown can bound any non-keyed point lookup (e.g. `Path eq /// '/souls' and WorkspaceId eq …`) instead of full-scanning and 413ing at tenant scale diff --git a/crates/temper-server/src/state/projection_backfill/key_index.rs b/crates/temper-server/src/state/projection_backfill/key_index.rs index 4ca8870b..22b47a40 100644 --- a/crates/temper-server/src/state/projection_backfill/key_index.rs +++ b/crates/temper-server/src/state/projection_backfill/key_index.rs @@ -7,24 +7,8 @@ use std::collections::BTreeSet; use temper_runtime::tenant::TenantId; use crate::ServerState; -use crate::entity_actor::recover_entity_state_from_store; -use super::transition_table_for; - -/// Outcome of loading one entity's current state for keying. -enum LoadOutcome { - /// Loaded — key it from these fields. - Fields(serde_json::Value), - /// Definitively skippable: deleted, or a phantom with no events. Correctly NOT - /// keyed, and NOT a failure (it must not block the watermark). - Skip, - /// The entity exists (it was enumerated from the durable store) but its current - /// state could not be loaded — no transition table to replay with, an unreadable - /// snapshot, or a replay error. Keying it is impossible, so the type must NOT be - /// watermarked: otherwise a keyed miss for this present entity would wrongly read - /// as authoritative absence. This is the soundness gate. - LoadFailed, -} +use super::{EntityLoadOutcome, load_entity_current_fields, transition_table_for}; /// Backfill `entity_key_index` for existing entities, then record the watermark. /// @@ -146,7 +130,7 @@ pub(in crate::state) async fn populate_key_index_from_snapshots( already += 1; continue; } - match load_entity_fields( + match load_entity_current_fields( tenant, entity_type, entity_id, @@ -157,7 +141,7 @@ pub(in crate::state) async fn populate_key_index_from_snapshots( ) .await { - LoadOutcome::Fields(fields) => { + EntityLoadOutcome::Fields(fields) => { let Some(field_map) = fields.as_object() else { skipped += 1; continue; @@ -195,8 +179,8 @@ pub(in crate::state) async fn populate_key_index_from_snapshots( } } } - LoadOutcome::Skip => skipped += 1, - LoadOutcome::LoadFailed => { + EntityLoadOutcome::Skip => skipped += 1, + EntityLoadOutcome::LoadFailed => { failed += 1; tracing::warn!( entity_type = %entity_type, entity_id = %entity_id, @@ -228,48 +212,3 @@ pub(in crate::state) async fn populate_key_index_from_snapshots( } } } - -/// Load one entity's current state for keying: snapshot if present and readable, -/// else event replay. Distinguishes "key it" from "definitively skip" from "exists -/// but unloadable" (the last fails the watermark — see [`LoadOutcome`]). -async fn load_entity_fields( - tenant: &TenantId, - entity_type: &str, - entity_id: &str, - table: Option<&temper_jit::TransitionTable>, - store: &crate::storage::BoxedEventStore, - backend: crate::storage::BackendLabel, - blob_store: Option<&crate::blob_store::BlobStore>, -) -> LoadOutcome { - // Recover CURRENT state via snapshot + event-tail (so a field mutated after the - // last snapshot — e.g. a Directory rename/move changing `Name`/`ParentId` — is - // keyed at its current value, not a stale snapshot value). The STRICT path makes a - // journal read failure propagate as `Err` instead of silently "starting fresh", so - // we can tell "no events" apart from "could not read the journal". Without a - // transition table we cannot replay → the entity is unresolved. - let Some(table) = table else { - return LoadOutcome::LoadFailed; - }; - match recover_entity_state_from_store( - tenant.as_str(), - entity_type, - entity_id, - table, - store, - backend, - &serde_json::json!({}), - blob_store, - true, // strict: a journal read failure → Err → LoadFailed (don't watermark) - ) - .await - { - // Read failed (strict), replay budget exceeded, etc. — cannot key it, so the - // type must NOT be watermarked. - Err(_) => LoadOutcome::LoadFailed, - Ok(state) if state.status == "Deleted" => LoadOutcome::Skip, - // Strict guarantees the journal read succeeded, so zero events is a genuine - // phantom (a catalog/field-index row with no journal) — unkeyable; safe to skip. - Ok(state) if state.total_event_count == 0 => LoadOutcome::Skip, - Ok(state) => LoadOutcome::Fields(state.fields), - } -} diff --git a/crates/temper-server/src/state/projection_backfill/vector_index.rs b/crates/temper-server/src/state/projection_backfill/vector_index.rs new file mode 100644 index 00000000..366d443c --- /dev/null +++ b/crates/temper-server/src/state/projection_backfill/vector_index.rs @@ -0,0 +1,220 @@ +//! ADR-0155 declared-vector backfill: populate `entity_vector_index` for entities +//! that existed before the `[[vector]]` path was declared (or, on a write-behind +//! backend, that lag the index), and record the per-(tenant, entity_type) watermark. +//! +//! Mirrors the declared-key backfill (`key_index.rs`): authoritative enumeration +//! (registry types + `store.list_entity_ids_by_type`), strict state load, per-decl +//! vector parse, idempotent upsert, and a watermark only when every existing entity +//! was indexed or is definitively skippable. + +use std::collections::BTreeSet; + +use temper_runtime::tenant::TenantId; + +use crate::ServerState; + +use super::{EntityLoadOutcome, load_entity_current_fields, transition_table_for}; + +/// Backfill `entity_vector_index` for existing entities, then record the watermark. +/// +/// Idempotent; entities written after the vector path was declared already maintain +/// their vectors at write time (co-commit on postgres/sim, write-behind on turso). +/// Runs as a cooperative background task off the boot path. +pub(in crate::state) async fn populate_vector_index_from_snapshots( + state: &ServerState, + tenant: &TenantId, +) { + let Some((store, backend)) = state.event_journal() else { + return; + }; + + // Types with a declared vector path, from the registry (os-app entities live here). + let vectored_types: Vec<(String, Vec)> = { + let registry = state.registry.read().unwrap(); + registry + .entity_types(tenant) + .into_iter() + .filter_map(|entity_type| { + let table = registry.get_table(tenant, entity_type)?; + if table.vectors.is_empty() { + None + } else { + Some((entity_type.to_string(), table.vectors.clone())) + } + }) + .collect() + }; + if vectored_types.is_empty() { + return; + } + + // The covered vector-path set per type (empty map on any failure — treat as + // never-backfilled, which is safe: it re-indexes, never skips wrongly). + let covered: std::collections::BTreeMap = store + .vector_index_backfilled_types(tenant.as_str()) + .await + .unwrap_or_default() + .into_iter() + .collect(); + + for (entity_type, vectors) in &vectored_types { + let current_set = crate::vector_index::declared_vector_set_signature(vectors); + // Already complete for the CURRENT declared vector-set: the write path keeps + // the index whole (co-commit) or write-behind + this backfill did, so skip. + if covered.get(entity_type).map(String::as_str) == Some(current_set.as_str()) { + continue; + } + // A watermark covering a DIFFERENT set means a vector path was declared after + // the first backfill; re-index every existing entity under all current paths. + let force_full_reindex = covered.contains_key(entity_type); + if force_full_reindex { + tracing::info!( + tenant = %tenant, entity_type = %entity_type, + covered_set = covered.get(entity_type).map(String::as_str).unwrap_or(""), + current_set = %current_set, + "vector index backfill: declared vector-set changed — re-indexing every existing entity of this type (one-time)" + ); + } + + let entity_ids = match store + .list_entity_ids_by_type(tenant.as_str(), entity_type) + .await + { + Ok(ids) => ids, + Err(e) => { + tracing::error!( + tenant = %tenant, entity_type = %entity_type, error = %e, + "vector index backfill: failed to enumerate entities; type not watermarked" + ); + continue; + } + }; + + // Resumability: on a first-time backfill, skip entities already indexed. On a + // set change, re-index all (a newly declared path is not yet on them). + let already_indexed: BTreeSet = if force_full_reindex { + BTreeSet::new() + } else { + match store + .vectored_entity_ids_for_type(tenant.as_str(), entity_type) + .await + { + Ok(ids) => ids.into_iter().collect(), + Err(_) => BTreeSet::new(), + } + }; + + let table = transition_table_for(state, tenant, entity_type); + let blob_store = state.blob_store_for_tenant(tenant).ok(); + let total = entity_ids.len(); + let mut newly_indexed = 0usize; + let mut already = 0usize; + let mut skipped = 0usize; + let mut failed = 0usize; + + for entity_id in &entity_ids { + if already_indexed.contains(entity_id) { + already += 1; + continue; + } + match load_entity_current_fields( + tenant, + entity_type, + entity_id, + table.as_ref(), + &store, + backend, + blob_store.as_ref(), + ) + .await + { + EntityLoadOutcome::Fields(fields) => { + let Some(field_map) = fields.as_object() else { + skipped += 1; + continue; + }; + let mut vector_rows = Vec::new(); + for decl in vectors { + let Some(vector) = field_map + .get(&decl.property) + .and_then(|v| crate::vector_index::parse_vector_property(v, decl.dims)) + else { + continue; + }; + let Some(model_tag) = field_map + .get(&decl.model_property) + .and_then(|v| v.as_str()) + .filter(|tag| !tag.is_empty()) + else { + continue; + }; + vector_rows.push(temper_runtime::persistence::EntityVectorRow { + decl_name: decl.name.clone(), + model_tag: model_tag.to_string(), + vector, + }); + } + if vector_rows.is_empty() { + // No usable vector on this entity yet (unembedded) — not a + // failure; it is simply absent from the ranking until embedded. + skipped += 1; + continue; + } + match store + .backfill_entity_vectors( + tenant.as_str(), + entity_type, + entity_id, + &vector_rows, + ) + .await + { + Ok(()) => newly_indexed += 1, + Err(e) => { + failed += 1; + tracing::warn!( + error = %e, entity_type = %entity_type, entity_id = %entity_id, + "vector index backfill: upsert failed" + ); + } + } + } + EntityLoadOutcome::Skip => skipped += 1, + EntityLoadOutcome::LoadFailed => { + failed += 1; + tracing::warn!( + entity_type = %entity_type, entity_id = %entity_id, + "vector index backfill: existing entity could not be loaded; type will NOT be watermarked" + ); + } + } + tokio::task::yield_now().await; + } + + // Watermark only if nothing failed — every existing entity was indexed or is + // definitively skippable. Otherwise a later run resumes from the remainder. + if failed == 0 { + if let Some((store, _)) = state.event_journal() + && let Err(e) = store + .mark_vector_index_backfilled(tenant.as_str(), entity_type, ¤t_set) + .await + { + tracing::error!( + tenant = %tenant, entity_type = %entity_type, error = %e, + "vector index backfill: failed to persist watermark" + ); + } + tracing::info!( + tenant = %tenant, entity_type = %entity_type, vector_set = %current_set, + total, newly_indexed, already, skipped, + "entity_vector_index backfill complete; type watermarked" + ); + } else { + tracing::warn!( + tenant = %tenant, entity_type = %entity_type, + total, newly_indexed, already, skipped, failed, + "vector index backfill: {failed} entities unresolved; type NOT watermarked (will resume next run)" + ); + } + } +} diff --git a/crates/temper-server/src/vector_index.rs b/crates/temper-server/src/vector_index.rs index b2a19f95..4a695126 100644 --- a/crates/temper-server/src/vector_index.rs +++ b/crates/temper-server/src/vector_index.rs @@ -17,6 +17,20 @@ use temper_runtime::persistence::EntityVectorCandidate; // reach for them through the vector-index module. pub use temper_runtime::persistence::{pack_f32_le, unpack_f32_le}; +/// The stable signature of a type's declared vector-path set: the sorted, +/// comma-joined path NAMES (ADR-0155). Recorded in the vector-index backfill +/// watermark and compared on the next backfill, so declaring an ADDITIONAL vector +/// path (a changed signature) re-indexes the type instead of being treated as +/// already complete. Deterministic (sorted, no map iteration). Mirrors +/// `declared_key_set_signature`. +pub fn declared_vector_set_signature( + vectors: &[temper_jit::table::types::DeclaredVector], +) -> String { + let mut names: Vec<&str> = vectors.iter().map(|v| v.name.as_str()).collect(); + names.sort(); + names.join(",") +} + /// The similarity metric declared on a `[[vector]]` path. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum VectorMetric { diff --git a/crates/temper-server/tests/nearest_odata.rs b/crates/temper-server/tests/nearest_odata.rs new file mode 100644 index 00000000..419fab03 --- /dev/null +++ b/crates/temper-server/tests/nearest_odata.rs @@ -0,0 +1,191 @@ +//! Integration test for the ADR-0155 `Temper.Nearest` bound function, driven +//! end to end through the real axum OData router: register a vector-declaring +//! entity, create entities (whose vectors co-commit), then GET the kNN function +//! and assert the OData list shape, ranking order, per-row `@temper.score`, and +//! self-exclusion. + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use temper_runtime::ActorSystem; +use temper_runtime::tenant::TenantId; +use temper_server::build_router; +use temper_server::registry::SpecRegistry; +use temper_server::request_context::AgentContext; +use temper_server::{ServerState, StorageStack}; +use temper_spec::csdl::parse_csdl; +use temper_store_sim::SimEventStore; +use tower::ServiceExt; + +const VEC_ITEM_IOA: &str = r#" +[automaton] +name = "VecItem" +states = ["New", "Ready"] +initial = "New" + +[[state]] +name = "Embedding" +type = "string" +initial = "" + +[[state]] +name = "EmbeddingModel" +type = "string" +initial = "" + +[[vector]] +name = "embed" +property = "Embedding" +model_property = "EmbeddingModel" +dims = 4 +metric = "cosine" + +[[action]] +name = "Create" +kind = "input" +from = ["New"] +to = "Ready" +params = ["Embedding", "EmbeddingModel"] +"#; + +const CSDL_XML: &str = r#" + + + + + + + + + + + + + + + + + + +"#; + +fn build_state() -> ServerState { + let mut registry = SpecRegistry::new(); + let csdl = parse_csdl(CSDL_XML).expect("CSDL parse"); + registry.register_tenant( + "default", + csdl, + CSDL_XML.to_string(), + &[("VecItem", VEC_ITEM_IOA)], + ); + let system = ActorSystem::new("nearest-odata"); + let mut state = ServerState::from_registry(system, registry); + state.set_storage_stack(StorageStack::from_sim(SimEventStore::no_faults(7), None)); + state +} + +async fn create_item( + state: &ServerState, + tenant: &TenantId, + id: &str, + embedding: &[f32], + model: &str, +) { + let embedding_json = serde_json::to_string(embedding).unwrap(); + let response = state + .dispatch_tenant_action( + tenant, + "VecItem", + id, + "Create", + serde_json::json!({ "Embedding": embedding_json, "EmbeddingModel": model }), + &AgentContext::default(), + ) + .await + .expect("dispatch Create"); + assert!(response.success, "Create {id} failed: {:?}", response.error); +} + +async fn get_json(state: &ServerState, path: &str) -> (StatusCode, serde_json::Value) { + let router = build_router(state.clone()); + let req = Request::builder().uri(path).body(Body::empty()).unwrap(); + let resp = router.oneshot(req).await.unwrap(); + let status = resp.status(); + let bytes = axum::body::to_bytes(resp.into_body(), 1_000_000) + .await + .unwrap(); + let body = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); + (status, body) +} + +#[tokio::test] +async fn nearest_bound_function_ranks_and_scores_over_http() { + let state = build_state(); + let tenant = TenantId::from("default"); + + // item-a is the query reference; item-b is close to it, item-c orthogonal. + create_item(&state, &tenant, "item-a", &[1.0, 0.0, 0.0, 0.0], "m1").await; + create_item(&state, &tenant, "item-b", &[0.9, 0.1, 0.0, 0.0], "m1").await; + create_item(&state, &tenant, "item-c", &[0.0, 1.0, 0.0, 0.0], "m1").await; + + let (status, body) = get_json( + &state, + "/tdata/VecItems/Temper.Nearest(decl='embed',to='item-a',k=5)", + ) + .await; + + assert_eq!(status, StatusCode::OK, "body: {body}"); + let value = body["value"].as_array().expect("value array"); + // item-a is the reference and is excluded from its own results. + assert_eq!( + value.len(), + 2, + "expected two ranked neighbours, got: {body}" + ); + + let ids: Vec<&str> = value + .iter() + .map(|e| e["entity_id"].as_str().unwrap_or("")) + .collect(); + assert_eq!( + ids, + vec!["item-b", "item-c"], + "ranking order (nearest first)" + ); + + // Every row carries a numeric @temper.score, descending (nearest first). + let score0 = value[0]["@temper.score"].as_f64().expect("score 0"); + let score1 = value[1]["@temper.score"].as_f64().expect("score 1"); + assert!( + score0 > score1, + "scores must be descending: {score0} !> {score1}" + ); + + // A raw-vector query with a model tag ranks the same partition. + let (status, body) = get_json( + &state, + "/tdata/VecItems/Temper.Nearest(decl='embed',vector='%5B1,0,0,0%5D',k=1,model='m1')", + ) + .await; + assert_eq!(status, StatusCode::OK, "raw-vector body: {body}"); + let value = body["value"].as_array().expect("value array"); + assert_eq!(value.len(), 1); + assert_eq!( + value[0]["entity_id"].as_str(), + Some("item-a"), + "nearest to [1,0,0,0] is item-a" + ); +} + +#[tokio::test] +async fn nearest_rejects_unknown_decl() { + let state = build_state(); + let tenant = TenantId::from("default"); + create_item(&state, &tenant, "item-a", &[1.0, 0.0, 0.0, 0.0], "m1").await; + + let (status, body) = get_json( + &state, + "/tdata/VecItems/Temper.Nearest(decl='nope',to='item-a',k=5)", + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "body: {body}"); +} From a7e04041011931ed03b51e0793bebf649bdf80ac Mon Sep 17 00:00:00 2001 From: Rita Agafonova <36133358+rita-aga@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:16:23 -0700 Subject: [PATCH 7/9] chore(vector): drop needless clippy allow; record forwarder growth in ratchet (ARN-159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vector EventStore surface adds six methods, each needing the dyn-adapter forwarding trio (DynEventStore trait decl + blanket impl + BoxedEventStore wrapper) in storage/mod.rs — unavoidable boilerplate parallel to the existing key-index methods. This grows the file past the readability ratchet's file-size thresholds, so the baseline is intentionally updated (the sanctioned path); the snapshot also locks in pre-existing improvements (lower println / dead-code / unwrap counts). `append_with_index_rows` has six args, under clippy's threshold, so its `#[allow(clippy::too_many_arguments)]` was removed. Co-Authored-By: Claude Fable 5 --- .ci/readability-baseline.env | 14 +++++++------- crates/temper-server/src/storage/mod.rs | 1 - 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/.ci/readability-baseline.env b/.ci/readability-baseline.env index 7a44cffb..986b8ed0 100644 --- a/.ci/readability-baseline.env +++ b/.ci/readability-baseline.env @@ -1,11 +1,11 @@ # Generated by scripts/readability-ratchet.sh PROD_RS_TOTAL=523 -PROD_FILES_GT300=205 +PROD_FILES_GT300=208 PROD_FILES_GT500=83 -PROD_FILES_GT1000=21 -PROD_MAX_FILE_LINES=2679 -PROD_MAX_FILE_PATH=crates/temper-platform/src/os_apps/mod.rs +PROD_FILES_GT1000=22 +PROD_MAX_FILE_LINES=2820 +PROD_MAX_FILE_PATH=crates/temper-server/src/storage/mod.rs ALLOW_CLIPPY_COUNT=35 -ALLOW_DEAD_CODE_COUNT=15 -PROD_PRINTLN_COUNT=287 -PROD_UNWRAP_CI_OK_COUNT=133 +ALLOW_DEAD_CODE_COUNT=14 +PROD_PRINTLN_COUNT=247 +PROD_UNWRAP_CI_OK_COUNT=132 diff --git a/crates/temper-server/src/storage/mod.rs b/crates/temper-server/src/storage/mod.rs index 1a9ef953..10be7d78 100644 --- a/crates/temper-server/src/storage/mod.rs +++ b/crates/temper-server/src/storage/mod.rs @@ -79,7 +79,6 @@ pub trait DynEventStore: Send + Sync { key_rows: &'a [temper_runtime::persistence::EntityKeyRow], ) -> EventStoreFuture<'a, Result>; - #[allow(clippy::too_many_arguments)] fn append_with_index_rows<'a>( &'a self, persistence_id: &'a str, From 36fed143a29a3421936b0cf33849997d10c88a72 Mon Sep 17 00:00:00 2001 From: Rita Agafonova <36133358+rita-aga@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:50:28 -0700 Subject: [PATCH 8/9] =?UTF-8?q?fix(vector):=20address=20code=20review=20?= =?UTF-8?q?=E2=80=94=20deletion,=20authz,=20durability,=20NaN,=20budget=20?= =?UTF-8?q?(ARN-159)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes all findings from the PR #341 review (ADR-0155 updated with the details): F1/F4 — deleted/cleared entities were ranked forever and ate the candidate budget. The write path now RECONCILES an entity's vector rows (delete all, insert current) whenever its type declares vectors, gated by a new `reconcile_vectors` flag on `append_with_index_rows`; a soft-deleted entity (`status="Deleted"`) emits no rows so it is purged, and a cleared vector/model property drops that path. `backfill_entity_vectors` is now a reconcile (empty rows purge); the backfill purges deleted/phantom entities it loads. Defense in depth: the Nearest walk skips any `Deleted` body and `to=''` returns 404. F2 — the `to=` reference entity is now read-authorized (same Cedar gate as a normal read) before its existence/embedding or the ranking it seeds is disclosed. F3 — Turso vector maintenance is retried (the event-append retry primitives), not a warn-once one-shot; on exhaustion it logs loudly. Reconcile handles the turso-side delete. Kept in the EventStore layer (not the field-index queue) because that queue is spec-agnostic while vector parsing needs the spec, and routing there would double-maintain on Postgres. Fixed the trait doc drift. F5 — metric accumulation moved to f64 and the score is dropped if the narrowed f32 is non-finite; the blob decoder rejects non-finite components. An overflowing/corrupt vector declines instead of producing a NaN that would sort first. F6 — `vector_candidates` applies `LIMIT budget+1` in the store, so an over-budget partition 413s without loading it all; the N-at-most walk posture is documented. F7 — `Temper.Nearest` rejects any OData system query option ($top/$select/$filter/…) with 400 instead of silently ignoring it. Suggestions: reject duplicate named args; dispatch by the fully-qualified name (`Temper.Nearest`) so a same-named function elsewhere can't route here; the backfill signature now includes property/model_property/dims/metric so an in-place edit re-indexes; `$metadata` advertisement deferred (documented). Tests added: delete→Nearest exclusion + to=deleted 404; equality-filter e2e (filter before top-k); reference-authz-denied 403 + walk-row read-denied skipped; query-option and duplicate-arg rejection; turso delete/purge reconcile; overflow/NaN ranking. Full temper-server suite green under workspace features (the 2 spec-validate cases need the observe feature the workspace unifies). Co-Authored-By: Claude Fable 5 --- crates/temper-odata/src/path.rs | 20 +- crates/temper-runtime/src/persistence/mod.rs | 66 ++++-- .../temper-server/src/entity_actor/actor.rs | 17 +- crates/temper-server/src/odata/nearest.rs | 86 ++++++- crates/temper-server/src/odata/read.rs | 6 +- .../state/projection_backfill/vector_index.rs | 19 +- crates/temper-server/src/storage/mod.rs | 11 +- crates/temper-server/src/vector_index.rs | 132 ++++++++--- .../tests/dst_entity_vector_index.rs | 4 +- crates/temper-server/tests/nearest_odata.rs | 211 +++++++++++++++++- crates/temper-store-postgres/src/store.rs | 78 ++++--- crates/temper-store-sim/src/lib.rs | 40 ++-- crates/temper-store-turso/src/router.rs | 5 +- .../src/store/event_store.rs | 88 +++++--- .../temper-store-turso/src/store/tests/mod.rs | 73 +++++- docs/adrs/0155-declared-vector-access-path.md | 40 ++++ 16 files changed, 737 insertions(+), 159 deletions(-) diff --git a/crates/temper-odata/src/path.rs b/crates/temper-odata/src/path.rs index d8e5a27f..d0378d59 100644 --- a/crates/temper-odata/src/path.rs +++ b/crates/temper-odata/src/path.rs @@ -209,13 +209,16 @@ fn parse_continuation_segment(segment: &str, parent: ODataPath) -> Result Result, ODataE if args_str.trim().is_empty() { return Ok(Vec::new()); } - let mut params = Vec::new(); + let mut params: Vec<(String, String)> = Vec::new(); for part in split_composite_key(args_str)? { let part = part.trim(); let Some(eq_pos) = part.find('=') else { @@ -318,6 +321,13 @@ fn parse_function_params(args_str: &str) -> Result, ODataE message: format!("bound function argument '{part}' has an empty name"), }); } + // Reject a repeated argument name rather than silently taking the first (or + // last) — a caller who wrote `k=5,k=10` gets a clear error, not a guess. + if params.iter().any(|(existing, _)| existing == &name) { + return Err(ODataError::InvalidPath { + message: format!("bound function argument '{name}' is given more than once"), + }); + } let value = parse_key_literal(part[eq_pos + 1..].trim())?; params.push((name, value)); } @@ -530,7 +540,7 @@ mod tests { "Orders".into(), KeyValue::Single("abc-123".into()) )), - function: "GetOrderTotal".into(), + function: "Temper.Example.GetOrderTotal".into(), params: vec![], } ); @@ -545,7 +555,7 @@ mod tests { result, ODataPath::BoundFunction { parent: Box::new(ODataPath::EntitySet("DesignLanguages".into())), - function: "Nearest".into(), + function: "Temper.Nearest".into(), params: vec![ ("decl".into(), "taste".into()), ("to".into(), "en-1".into()), diff --git a/crates/temper-runtime/src/persistence/mod.rs b/crates/temper-runtime/src/persistence/mod.rs index 3f66a6a7..80c236ad 100644 --- a/crates/temper-runtime/src/persistence/mod.rs +++ b/crates/temper-runtime/src/persistence/mod.rs @@ -124,14 +124,20 @@ pub fn pack_f32_le(vector: &[f32]) -> Vec { } /// Unpack little-endian bytes back to `f32`. `None` if the byte length is not a -/// multiple of 4 (a corrupt blob), so a bad row is skipped rather than panicking. +/// multiple of 4, or if any component is not finite (both signal a corrupt blob), +/// so a bad row is skipped rather than panicking or feeding a `NaN`/`inf` into the +/// kNN ranking — where a `NaN` would sort ahead of every real score. pub fn unpack_f32_le(bytes: &[u8]) -> Option> { if !bytes.len().is_multiple_of(4) { return None; } let mut out = Vec::with_capacity(bytes.len() / 4); for chunk in bytes.chunks_exact(4) { - out.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])); + let value = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); + if !value.is_finite() { + return None; + } + out.push(value); } Some(out) } @@ -160,10 +166,10 @@ pub trait EventStore: Send + Sync + 'static { ) -> impl std::future::Future> + Send; /// Append events and co-commit declared key-index rows (ADR-0153) in the - /// **same transaction** as the journal append. Now a thin forwarder to - /// [`EventStore::append_with_index_rows`] with no vector rows, so callers that - /// only maintain keys are unchanged. Overriding `append_with_index_rows` is - /// what a co-committing backend does; this method is kept for existing callers. + /// **same transaction** as the journal append. A thin forwarder to + /// [`EventStore::append_with_index_rows`] with no vector rows and no vector + /// reconcile, so callers that only maintain keys are unchanged. The co-commit + /// logic lives in `append_with_index_rows`, which query-plane backends override. fn append_with_keys( &self, persistence_id: &str, @@ -171,17 +177,28 @@ pub trait EventStore: Send + Sync + 'static { events: &[PersistenceEnvelope], key_rows: &[EntityKeyRow], ) -> impl std::future::Future> + Send { - self.append_with_index_rows(persistence_id, expected_sequence, events, key_rows, &[]) + self.append_with_index_rows( + persistence_id, + expected_sequence, + events, + key_rows, + &[], + false, + ) } /// Append events and co-commit BOTH declared key-index rows (ADR-0153) and /// derived vector-index rows (ADR-0155) in the **same transaction** as the /// journal append. This is the single co-commit entry point the entity actor - /// calls. The default ignores both index kinds and delegates to - /// [`EventStore::append`] — only stores with a query plane that co-commit - /// (postgres, sim) override it. Turso keeps the default (its query plane, - /// including vectors, is maintained write-behind by the projection, not - /// co-committed). The sequence and atomicity contract is identical to `append`. + /// calls. The default ignores the index kinds and delegates to + /// [`EventStore::append`] — stores with a query plane that co-commit (postgres, + /// sim) override it; Turso also overrides it to maintain the vector index + /// write-behind (event first, index follows). When `reconcile_vectors` is true + /// (the entity's type declares ≥1 `[[vector]]` path) the store first DELETES all + /// of the entity's vector rows, then inserts `vector_rows` — so a delete + /// transition or a cleared vector/model property purges the stale rows instead of + /// leaving them to be ranked forever. The sequence and atomicity contract is + /// identical to `append`. fn append_with_index_rows( &self, persistence_id: &str, @@ -189,17 +206,19 @@ pub trait EventStore: Send + Sync + 'static { events: &[PersistenceEnvelope], key_rows: &[EntityKeyRow], vector_rows: &[EntityVectorRow], + reconcile_vectors: bool, ) -> impl std::future::Future> + Send { - let _ = (key_rows, vector_rows); + let _ = (key_rows, vector_rows, reconcile_vectors); self.append(persistence_id, expected_sequence, events) } - /// Backfill derived vector-index rows for an **existing** entity (ADR-0155), - /// without appending a journal event. Idempotent (upsert): re-running yields - /// the same rows. Used to populate `entity_vector_index` for entities written - /// before the vector path was declared, or (on write-behind backends) to catch - /// the index up. The default is a no-op (non-indexing backends); query-plane - /// stores upsert the rows. + /// Reconcile the derived vector-index rows for an **existing** entity to exactly + /// `vector_rows` (ADR-0155), without appending a journal event: DELETE every + /// existing row for `(tenant, entity_type, entity_id)`, then INSERT `vector_rows`. + /// Idempotent, and an empty `vector_rows` PURGES the entity (used to clean up a + /// deleted or un-embedded entity). Used by the backfill and by the Turso + /// write-behind path. The default is a no-op (non-indexing backends); query-plane + /// stores implement it. fn backfill_entity_vectors( &self, tenant: &str, @@ -213,17 +232,20 @@ pub trait EventStore: Send + Sync + 'static { /// The candidate `(entity_id, vector)` rows for one vector-index partition /// `(tenant, entity_type, decl_name, model_tag)`, in **deterministic entity-id - /// order** (ADR-0155). The kernel ranks these; the store only supplies the - /// packed vectors. Default empty (non-indexing backends have no vector index). + /// order** (ADR-0155), capped at `limit` rows. The kernel ranks these; the store + /// only supplies the packed vectors, and applies `LIMIT` so an over-budget + /// partition is detected (caller passes `budget + 1`) without loading the whole + /// partition into memory. Default empty (non-indexing backends have no index). fn vector_candidates( &self, tenant: &str, entity_type: &str, decl_name: &str, model_tag: &str, + limit: usize, ) -> impl std::future::Future, PersistenceError>> + Send { - let _ = (tenant, entity_type, decl_name, model_tag); + let _ = (tenant, entity_type, decl_name, model_tag, limit); async { Ok(Vec::new()) } } diff --git a/crates/temper-server/src/entity_actor/actor.rs b/crates/temper-server/src/entity_actor/actor.rs index f571d48a..cbf46c9a 100644 --- a/crates/temper-server/src/entity_actor/actor.rs +++ b/crates/temper-server/src/entity_actor/actor.rs @@ -354,8 +354,13 @@ impl EntityActor { // ADR-0153/0155: derive the declared key rows AND the vector-index rows from // the new state and co-commit them with the journal append, so a keyed read // is correct without a scan and a kNN read reflects the write deterministically. - let (key_rows, vector_rows) = { + let (key_rows, vector_rows, reconcile_vectors) = { let table = self.table.read().expect("table lock poisoned"); + // The type declares vector paths → the store reconciles this entity's + // vector rows (delete stale + insert current) even when no row is emitted + // this write (a delete transition or a cleared property), so stale rows are + // purged instead of being ranked forever (ADR-0155). + let reconcile_vectors = !table.vectors.is_empty(); let mut key_rows = Vec::new(); let mut vector_rows = Vec::new(); if let Some(field_map) = state.fields.as_object() { @@ -369,7 +374,12 @@ impl EntityActor { }); } } - for decl in &table.vectors { + // A soft-deleted (tombstone) entity is never indexed — it emits no + // vector rows, so the reconcile below PURGES any it had, even though + // its embedding field may still be present. Mirrors how the field-index + // projection removes a deleted entity. + let index_vectors = state.status != "Deleted"; + for decl in table.vectors.iter().filter(|_| index_vectors) { // A vector is indexed only when its property parses to `dims` // floats AND its model tag is a non-empty string — otherwise the // path indexes nothing for this entity (like an incomplete key). @@ -393,7 +403,7 @@ impl EntityActor { }); } } - (key_rows, vector_rows) + (key_rows, vector_rows, reconcile_vectors) }; let append_start = Instant::now(); let result = store @@ -403,6 +413,7 @@ impl EntityActor { &[envelope], &key_rows, &vector_rows, + reconcile_vectors, ) .await; crate::runtime_metrics::record_event_store_append_wait( diff --git a/crates/temper-server/src/odata/nearest.rs b/crates/temper-server/src/odata/nearest.rs index df39aa08..984d86d5 100644 --- a/crates/temper-server/src/odata/nearest.rs +++ b/crates/temper-server/src/odata/nearest.rs @@ -62,6 +62,37 @@ fn bad_request(message: &str) -> Response { odata_error(StatusCode::BAD_REQUEST, "InvalidNearestQuery", message).into_response() } +/// The name of the first OData system query option that is set on the request, or +/// `None` if none are. `Temper.Nearest` supports none of them in v1. +fn unsupported_query_option(options: &QueryOptions) -> Option<&'static str> { + if options.filter.is_some() { + Some("$filter") + } else if options.select.is_some() { + Some("$select") + } else if options.expand.is_some() { + Some("$expand") + } else if options.orderby.is_some() { + Some("$orderby") + } else if options.top.is_some() { + Some("$top") + } else if options.skip.is_some() { + Some("$skip") + } else if options.count.is_some() { + Some("$count") + } else if options.skiptoken.is_some() { + Some("$skiptoken") + } else { + None + } +} + +/// Whether a materialized entity body is a tombstone (soft-deleted) — such rows must +/// never be served by `Temper.Nearest` even if their vector index row has not yet +/// been purged (defense in depth alongside the write-path reconcile, ADR-0155). +fn is_deleted(body: &serde_json::Value) -> bool { + body.get("status").and_then(|s| s.as_str()) == Some("Deleted") +} + /// Handle `GET //Temper.Nearest(...)`. pub(super) async fn handle_nearest( state: &ServerState, @@ -69,7 +100,7 @@ pub(super) async fn handle_nearest( security_ctx: &SecurityContext, parent: &ODataPath, params: &[(String, String)], - _query_options: &QueryOptions, + query_options: &QueryOptions, ) -> Response { // Temper.Nearest is collection-bound: the parent must be an entity set. let set_name = match parent { @@ -80,6 +111,16 @@ pub(super) async fn handle_nearest( ); } }; + + // v1 does not layer OData system query options over the ranked result — `k` + // already bounds the output and the function args are the whole interface. + // Reject them explicitly rather than silently ignoring (ADR-0155): a caller who + // wrote `$filter`/`$top`/`$select` would otherwise get a wrong, unfiltered answer. + if let Some(unsupported) = unsupported_query_option(query_options) { + return bad_request(&format!( + "Temper.Nearest does not accept the OData system query option '{unsupported}'; use the function arguments (decl, to/vector, k, model, filter)" + )); + } let entity_type = match resolve_entity_type(state, tenant, &set_name) { Some(et) => et, None => { @@ -167,6 +208,31 @@ pub(super) async fn handle_nearest( ) .into_response(); }; + // A soft-deleted reference is treated as absent — a tombstone must not + // seed a query, and `to=''` returns 404. + if is_deleted(&body) { + return odata_error( + StatusCode::NOT_FOUND, + "ResourceNotFound", + &format!("reference entity '{reference_id}' not found"), + ) + .into_response(); + } + // Read-authorize the reference entity with the same gate a normal single + // read uses, BEFORE disclosing anything about it (existence, embedding, or + // — via the ranking it seeds — a similarity oracle). Deny → the Cedar + // denial response. + if let Err(response) = authorize_read( + state, + tenant, + security_ctx, + READ_ACTION, + &entity_type, + reference_id, + &body, + ) { + return *response; + } let Some(vector) = entity_field(&body, &decl.property) .and_then(|v| parse_vector_property(v, decl.dims)) else { @@ -234,8 +300,17 @@ pub(super) async fn handle_nearest( ) .into_response(); }; + // Cap the scan at `budget + 1` rows in the store (a LIMIT), so an over-budget + // partition is detected without loading the whole thing into memory (ADR-0155). + let candidate_budget = budget.candidate_budget(); let candidates = match store - .vector_candidates(tenant.as_str(), &entity_type, decl_name, &model_tag) + .vector_candidates( + tenant.as_str(), + &entity_type, + decl_name, + &model_tag, + candidate_budget.saturating_add(1), + ) .await { Ok(candidates) => candidates, @@ -248,7 +323,7 @@ pub(super) async fn handle_nearest( .into_response(); } }; - if candidates.len() > budget.candidate_budget() { + if candidates.len() > candidate_budget { return odata_error( StatusCode::PAYLOAD_TOO_LARGE, "QueryTooLarge", @@ -287,6 +362,11 @@ pub(super) async fn handle_nearest( let Some(mut body) = materialized.entities.into_iter().next() else { continue; }; + // Defense in depth: never serve a soft-deleted entity, even if its vector row + // has not yet been purged by the write-path reconcile / backfill. + if is_deleted(&body) { + continue; + } if let Some(pairs) = &equality_filter && !body_matches_equality(&body, pairs) { diff --git a/crates/temper-server/src/odata/read.rs b/crates/temper-server/src/odata/read.rs index 5b5d9be8..3a7e36e1 100644 --- a/crates/temper-server/src/odata/read.rs +++ b/crates/temper-server/src/odata/read.rs @@ -663,8 +663,10 @@ pub(super) async fn handle_odata_get_for_tenant( function, params, } => { - if function == "Nearest" { - // ADR-0155: the collection-bound exact-scan kNN function. + if function == "Temper.Nearest" { + // ADR-0155: the collection-bound exact-scan kNN function, dispatched by + // its fully-qualified name so a same-named function in another + // namespace never routes here. super::nearest::handle_nearest( &state, &tenant, diff --git a/crates/temper-server/src/state/projection_backfill/vector_index.rs b/crates/temper-server/src/state/projection_backfill/vector_index.rs index 366d443c..44d0de6f 100644 --- a/crates/temper-server/src/state/projection_backfill/vector_index.rs +++ b/crates/temper-server/src/state/projection_backfill/vector_index.rs @@ -179,7 +179,24 @@ pub(in crate::state) async fn populate_vector_index_from_snapshots( } } } - EntityLoadOutcome::Skip => skipped += 1, + EntityLoadOutcome::Skip => { + // A deleted (or phantom) entity must hold no vector rows — purge + // any it still has so a soft-deleted entity is never ranked + // (reconcile with an empty row set). Harmless when there is nothing + // to purge. + if let Err(e) = store + .backfill_entity_vectors(tenant.as_str(), entity_type, entity_id, &[]) + .await + { + failed += 1; + tracing::warn!( + error = %e, entity_type = %entity_type, entity_id = %entity_id, + "vector index backfill: purge of deleted/phantom entity failed" + ); + } else { + skipped += 1; + } + } EntityLoadOutcome::LoadFailed => { failed += 1; tracing::warn!( diff --git a/crates/temper-server/src/storage/mod.rs b/crates/temper-server/src/storage/mod.rs index 10be7d78..f689b7db 100644 --- a/crates/temper-server/src/storage/mod.rs +++ b/crates/temper-server/src/storage/mod.rs @@ -86,6 +86,7 @@ pub trait DynEventStore: Send + Sync { events: &'a [PersistenceEnvelope], key_rows: &'a [temper_runtime::persistence::EntityKeyRow], vector_rows: &'a [temper_runtime::persistence::EntityVectorRow], + reconcile_vectors: bool, ) -> EventStoreFuture<'a, Result>; fn backfill_entity_vectors<'a>( @@ -102,6 +103,7 @@ pub trait DynEventStore: Send + Sync { entity_type: &'a str, decl_name: &'a str, model_tag: &'a str, + limit: usize, ) -> EventStoreFuture< 'a, Result, PersistenceError>, @@ -246,6 +248,7 @@ where events: &'a [PersistenceEnvelope], key_rows: &'a [temper_runtime::persistence::EntityKeyRow], vector_rows: &'a [temper_runtime::persistence::EntityVectorRow], + reconcile_vectors: bool, ) -> EventStoreFuture<'a, Result> { Box::pin(EventStore::append_with_index_rows( self, @@ -254,6 +257,7 @@ where events, key_rows, vector_rows, + reconcile_vectors, )) } @@ -279,6 +283,7 @@ where entity_type: &'a str, decl_name: &'a str, model_tag: &'a str, + limit: usize, ) -> EventStoreFuture< 'a, Result, PersistenceError>, @@ -289,6 +294,7 @@ where entity_type, decl_name, model_tag, + limit, )) } @@ -513,6 +519,7 @@ impl BoxedEventStore { events: &[PersistenceEnvelope], key_rows: &[temper_runtime::persistence::EntityKeyRow], vector_rows: &[temper_runtime::persistence::EntityVectorRow], + reconcile_vectors: bool, ) -> Result { self.0 .append_with_index_rows( @@ -521,6 +528,7 @@ impl BoxedEventStore { events, key_rows, vector_rows, + reconcile_vectors, ) .await } @@ -543,9 +551,10 @@ impl BoxedEventStore { entity_type: &str, decl_name: &str, model_tag: &str, + limit: usize, ) -> Result, PersistenceError> { self.0 - .vector_candidates(tenant, entity_type, decl_name, model_tag) + .vector_candidates(tenant, entity_type, decl_name, model_tag, limit) .await } diff --git a/crates/temper-server/src/vector_index.rs b/crates/temper-server/src/vector_index.rs index 4a695126..ddc2d609 100644 --- a/crates/temper-server/src/vector_index.rs +++ b/crates/temper-server/src/vector_index.rs @@ -17,18 +17,29 @@ use temper_runtime::persistence::EntityVectorCandidate; // reach for them through the vector-index module. pub use temper_runtime::persistence::{pack_f32_le, unpack_f32_le}; -/// The stable signature of a type's declared vector-path set: the sorted, -/// comma-joined path NAMES (ADR-0155). Recorded in the vector-index backfill -/// watermark and compared on the next backfill, so declaring an ADDITIONAL vector -/// path (a changed signature) re-indexes the type instead of being treated as -/// already complete. Deterministic (sorted, no map iteration). Mirrors -/// `declared_key_set_signature`. +/// The stable signature of a type's declared vector-path set (ADR-0155): each path +/// rendered as `name:property:model_property:dims:metric`, sorted by name and +/// semicolon-joined. Recorded in the vector-index backfill watermark and compared +/// on the next backfill, so ANY change — a new path, or an in-place edit to a +/// path's property/model_property/dims/metric — changes the signature and re-indexes +/// the type instead of being treated as already complete. Including `dims` matters: +/// an edited `dims` makes every existing row the wrong length (they would be dropped +/// at read time as corrupt), so the type must be re-embedded/reconciled. Deterministic +/// (sorted, no map iteration). Mirrors `declared_key_set_signature`. pub fn declared_vector_set_signature( vectors: &[temper_jit::table::types::DeclaredVector], ) -> String { - let mut names: Vec<&str> = vectors.iter().map(|v| v.name.as_str()).collect(); - names.sort(); - names.join(",") + let mut entries: Vec = vectors + .iter() + .map(|v| { + format!( + "{}:{}:{}:{}:{}", + v.name, v.property, v.model_property, v.dims, v.metric + ) + }) + .collect(); + entries.sort(); + entries.join(";") } /// The similarity metric declared on a `[[vector]]` path. @@ -97,46 +108,60 @@ pub struct ScoredEntity { /// The closeness score for `query` vs `candidate` under `metric` — higher is /// nearer for every metric: cosine similarity, dot product, and **negated** L2 -/// distance. f32 accumulation proceeds in index order. `None` if the lengths -/// differ (a corrupt row); a zero-magnitude vector scores 0 under cosine rather -/// than producing a `NaN`. +/// distance. +/// +/// Accumulation is in **f64** in fixed index order, then narrowed to f32. f64 has +/// the range to hold the sums of squares/products of finite f32 inputs without +/// overflowing to `inf` — an f32 accumulator can overflow, and the resulting +/// `inf/inf = NaN` sorts ABOVE every real score (`total_cmp`), so a pair of large +/// but finite vectors would rank first. Doing the math in f64 keeps the order +/// still deterministic (fixed order, f64 is exact enough and identical across +/// backends). Returns `None` if the lengths differ (a corrupt row) or the score is +/// not finite; a zero-magnitude vector scores 0 under cosine rather than `NaN`. fn closeness(metric: VectorMetric, query: &[f32], candidate: &[f32]) -> Option { if query.len() != candidate.len() { return None; } - match metric { + let score: f64 = match metric { VectorMetric::Dot => { - let mut dot = 0.0f32; + let mut dot = 0.0f64; for (q, c) in query.iter().zip(candidate.iter()) { - dot += q * c; + dot += f64::from(*q) * f64::from(*c); } - Some(dot) + dot } VectorMetric::Cosine => { - let mut dot = 0.0f32; - let mut q_norm = 0.0f32; - let mut c_norm = 0.0f32; + let mut dot = 0.0f64; + let mut q_norm = 0.0f64; + let mut c_norm = 0.0f64; for (q, c) in query.iter().zip(candidate.iter()) { + let (q, c) = (f64::from(*q), f64::from(*c)); dot += q * c; q_norm += q * q; c_norm += c * c; } let denom = q_norm.sqrt() * c_norm.sqrt(); - if denom == 0.0 { - Some(0.0) - } else { - Some(dot / denom) - } + if denom == 0.0 { 0.0 } else { dot / denom } } VectorMetric::L2 => { - let mut sum_sq = 0.0f32; + let mut sum_sq = 0.0f64; for (q, c) in query.iter().zip(candidate.iter()) { - let d = q - c; + let d = f64::from(*q) - f64::from(*c); sum_sq += d * d; } // Negated so nearest (smallest distance) is the largest score. - Some(-sum_sq.sqrt()) + -sum_sq.sqrt() } + }; + // Narrow to f32, then require the NARROWED value to be finite — this catches + // both a NaN/inf f64 and an f64 that is finite but outside f32 range (which + // narrows to inf). Either way a non-finite score declines the row, so NaN/inf + // can never rank (a NaN sorts ahead of every real score under total_cmp). + let narrowed = score as f32; + if narrowed.is_finite() { + Some(narrowed) + } else { + None } } @@ -145,7 +170,8 @@ fn closeness(metric: VectorMetric, query: &[f32], candidate: &[f32]) -> Option`, so it is never its own top result). Candidates -/// whose length does not match `query` are skipped (corrupt rows never rank). +/// whose length does not match `query`, or whose score is not finite, are skipped — +/// a corrupt or overflowing row never ranks (a NaN would otherwise sort first). pub fn rank_nearest( metric: VectorMetric, query: &[f32], @@ -274,4 +300,54 @@ mod tests { let ranked = rank_nearest(VectorMetric::Cosine, &query, &candidates, 1, None); assert_eq!(ranked[0].score, 0.0); } + + #[test] + fn large_finite_vectors_do_not_overflow_to_nan_or_rank_first() { + // Under cosine (bounded [-1,1]) even huge-magnitude vectors rank by direction, + // never producing a NaN that would sort ahead of a genuinely-similar row. + let query = vec![1.0f32, 0.0]; + let candidates = vec![ + candidate("huge_same_dir", vec![f32::MAX, 0.0]), + candidate("near", vec![1.0, 0.01]), + candidate("huge_orthogonal", vec![0.0, f32::MAX]), + ]; + let ranked = rank_nearest(VectorMetric::Cosine, &query, &candidates, 3, None); + assert!( + ranked.iter().all(|r| r.score.is_finite()), + "no NaN/inf scores" + ); + // The huge same-direction vector (cosine 1.0) is nearest; the huge orthogonal + // one (cosine 0) is last — magnitude does not let a row jump the ranking. + assert_eq!(ranked[0].entity_id, "huge_same_dir"); + assert_eq!(ranked.last().unwrap().entity_id, "huge_orthogonal"); + } + + #[test] + fn dot_overflow_declines_row_rather_than_ranking_inf_first() { + // A dot product that overflows f32 range narrows to inf and is dropped, so it + // cannot sort ahead of a finite, genuinely-scored row. The query is moderate + // so only the pathological candidate overflows (its dot ~2e60 exceeds f32 + // range), while the finite one (dot ~2e30) is well within range. + let query = vec![1e30f32, 1e30]; + let candidates = vec![ + candidate("overflows", vec![1e30, 1e30]), + candidate("finite", vec![1.0, 1.0]), + ]; + let ranked = rank_nearest(VectorMetric::Dot, &query, &candidates, 5, None); + assert!(ranked.iter().all(|r| r.score.is_finite())); + assert!( + ranked.iter().all(|r| r.entity_id != "overflows"), + "an overflowing (non-finite) score must be dropped, not ranked first" + ); + assert_eq!(ranked.len(), 1); + assert_eq!(ranked[0].entity_id, "finite"); + } + + #[test] + fn unpack_rejects_nonfinite_components() { + use temper_runtime::persistence::{pack_f32_le, unpack_f32_le}; + assert!(unpack_f32_le(&pack_f32_le(&[1.0, 2.0])).is_some()); + assert!(unpack_f32_le(&pack_f32_le(&[1.0, f32::NAN])).is_none()); + assert!(unpack_f32_le(&pack_f32_le(&[f32::INFINITY, 0.0])).is_none()); + } } diff --git a/crates/temper-server/tests/dst_entity_vector_index.rs b/crates/temper-server/tests/dst_entity_vector_index.rs index 3d17c2e1..f26baa21 100644 --- a/crates/temper-server/tests/dst_entity_vector_index.rs +++ b/crates/temper-server/tests/dst_entity_vector_index.rs @@ -108,7 +108,7 @@ async fn dst_nearest_ranking_is_reproducible_across_seeds() { // Co-commit: the m1 partition holds exactly the four m1 items right after // the writes (no async projection lag on the co-committing sim store). let candidates = store - .vector_candidates("default", "Item", "embed", "m1") + .vector_candidates("default", "Item", "embed", "m1", 1000) .await .expect("vector candidates"); assert_eq!( @@ -161,7 +161,7 @@ async fn dst_nearest_by_reference_excludes_self() { // Rank against item-a's own vector, excluding item-a (the "related to X" // shape) — its nearest neighbour is item-b, and item-a is absent. let candidates = store - .vector_candidates("default", "Item", "embed", "m1") + .vector_candidates("default", "Item", "embed", "m1", 1000) .await .expect("vector candidates"); let a_vector = candidates diff --git a/crates/temper-server/tests/nearest_odata.rs b/crates/temper-server/tests/nearest_odata.rs index 419fab03..240569bd 100644 --- a/crates/temper-server/tests/nearest_odata.rs +++ b/crates/temper-server/tests/nearest_odata.rs @@ -19,7 +19,7 @@ use tower::ServiceExt; const VEC_ITEM_IOA: &str = r#" [automaton] name = "VecItem" -states = ["New", "Ready"] +states = ["New", "Ready", "Deleted"] initial = "New" [[state]] @@ -32,6 +32,11 @@ name = "EmbeddingModel" type = "string" initial = "" +[[state]] +name = "Category" +type = "string" +initial = "" + [[vector]] name = "embed" property = "Embedding" @@ -44,7 +49,13 @@ name = "Create" kind = "input" from = ["New"] to = "Ready" -params = ["Embedding", "EmbeddingModel"] +params = ["Embedding", "EmbeddingModel", "Category"] + +[[action]] +name = "Delete" +kind = "input" +from = ["Ready"] +to = "Deleted" "#; const CSDL_XML: &str = r#" @@ -58,6 +69,7 @@ const CSDL_XML: &str = r#" + @@ -89,6 +101,17 @@ async fn create_item( id: &str, embedding: &[f32], model: &str, +) { + create_item_cat(state, tenant, id, embedding, model, "std").await; +} + +async fn create_item_cat( + state: &ServerState, + tenant: &TenantId, + id: &str, + embedding: &[f32], + model: &str, + category: &str, ) { let embedding_json = serde_json::to_string(embedding).unwrap(); let response = state @@ -97,7 +120,7 @@ async fn create_item( "VecItem", id, "Create", - serde_json::json!({ "Embedding": embedding_json, "EmbeddingModel": model }), + serde_json::json!({ "Embedding": embedding_json, "EmbeddingModel": model, "Category": category }), &AgentContext::default(), ) .await @@ -105,6 +128,21 @@ async fn create_item( assert!(response.success, "Create {id} failed: {:?}", response.error); } +async fn delete_item(state: &ServerState, tenant: &TenantId, id: &str) { + let response = state + .dispatch_tenant_action( + tenant, + "VecItem", + id, + "Delete", + serde_json::json!({}), + &AgentContext::default(), + ) + .await + .expect("dispatch Delete"); + assert!(response.success, "Delete {id} failed: {:?}", response.error); +} + async fn get_json(state: &ServerState, path: &str) -> (StatusCode, serde_json::Value) { let router = build_router(state.clone()); let req = Request::builder().uri(path).body(Body::empty()).unwrap(); @@ -189,3 +227,170 @@ async fn nearest_rejects_unknown_decl() { .await; assert_eq!(status, StatusCode::BAD_REQUEST, "body: {body}"); } + +#[tokio::test] +async fn nearest_excludes_deleted_and_404s_on_deleted_reference() { + let state = build_state(); + let tenant = TenantId::from("default"); + create_item(&state, &tenant, "item-a", &[1.0, 0.0, 0.0, 0.0], "m1").await; + create_item(&state, &tenant, "item-b", &[0.9, 0.1, 0.0, 0.0], "m1").await; + create_item(&state, &tenant, "item-c", &[0.0, 1.0, 0.0, 0.0], "m1").await; + // Soft-delete item-b — its vector row is purged at write time (the actor emits no + // row for a Deleted status), and the read-side status filter is the backstop. + delete_item(&state, &tenant, "item-b").await; + + let (status, body) = get_json( + &state, + "/tdata/VecItems/Temper.Nearest(decl='embed',to='item-a',k=10)", + ) + .await; + assert_eq!(status, StatusCode::OK, "body: {body}"); + let ids: Vec<&str> = body["value"] + .as_array() + .unwrap() + .iter() + .map(|e| e["entity_id"].as_str().unwrap_or("")) + .collect(); + assert!( + !ids.contains(&"item-b"), + "a deleted entity must never be ranked; got {ids:?}" + ); + assert_eq!(ids, vec!["item-c"], "only the live neighbour remains"); + + // A deleted reference is treated as absent. + let (status, _body) = get_json( + &state, + "/tdata/VecItems/Temper.Nearest(decl='embed',to='item-b',k=5)", + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND, "to='' must 404"); +} + +#[tokio::test] +async fn nearest_applies_equality_filter_before_top_k() { + let state = build_state(); + let tenant = TenantId::from("default"); + // Two "red" and one "blue" — all near the query vector, so without the filter the + // blue one would rank; the filter must exclude it before top-k. + create_item_cat(&state, &tenant, "red-1", &[1.0, 0.0, 0.0, 0.0], "m1", "red").await; + create_item_cat( + &state, + &tenant, + "blue-1", + &[0.99, 0.01, 0.0, 0.0], + "m1", + "blue", + ) + .await; + create_item_cat(&state, &tenant, "red-2", &[0.9, 0.1, 0.0, 0.0], "m1", "red").await; + + let (status, body) = get_json( + &state, + "/tdata/VecItems/Temper.Nearest(decl='embed',vector='%5B1,0,0,0%5D',k=10,model='m1',filter='Category%20eq%20''red''')", + ) + .await; + assert_eq!(status, StatusCode::OK, "body: {body}"); + let ids: Vec<&str> = body["value"] + .as_array() + .unwrap() + .iter() + .map(|e| e["entity_id"].as_str().unwrap_or("")) + .collect(); + assert_eq!( + ids, + vec!["red-1", "red-2"], + "only red items, ranked; blue filtered out" + ); +} + +#[tokio::test] +async fn nearest_authorizes_reference_and_walk_rows() { + let state = build_state(); + let tenant = TenantId::from("default"); + create_item(&state, &tenant, "item-a", &[1.0, 0.0, 0.0, 0.0], "m1").await; + create_item( + &state, + &tenant, + "item-secret", + &[0.95, 0.05, 0.0, 0.0], + "m1", + ) + .await; + create_item(&state, &tenant, "item-c", &[0.0, 1.0, 0.0, 0.0], "m1").await; + + // Permit list + read, but forbid read on item-secret specifically. + state + .authz + .reload_tenant_policies( + tenant.as_str(), + r#" + permit(principal, action in [Action::"list", Action::"read"], resource is VecItem); + forbid(principal, action == Action::"read", resource == VecItem::"item-secret"); + "#, + ) + .expect("install Cedar policy"); + + // Walk: a row the caller may not read is skipped, not leaked, in the ranking. + let (status, body) = get_json( + &state, + "/tdata/VecItems/Temper.Nearest(decl='embed',to='item-a',k=10)", + ) + .await; + assert_eq!(status, StatusCode::OK, "body: {body}"); + let ids: Vec<&str> = body["value"] + .as_array() + .unwrap() + .iter() + .map(|e| e["entity_id"].as_str().unwrap_or("")) + .collect(); + assert!( + !ids.contains(&"item-secret"), + "a read-denied row must not be served by Nearest; got {ids:?}" + ); + assert_eq!(ids, vec!["item-c"]); + + // Reference: reading a forbidden entity as the query seed is denied, not disclosed. + let (status, _body) = get_json( + &state, + "/tdata/VecItems/Temper.Nearest(decl='embed',to='item-secret',k=5)", + ) + .await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "reading a forbidden reference entity must be denied" + ); +} + +#[tokio::test] +async fn nearest_rejects_system_query_options() { + let state = build_state(); + let tenant = TenantId::from("default"); + create_item(&state, &tenant, "item-a", &[1.0, 0.0, 0.0, 0.0], "m1").await; + + // $top is not layered over the ranked result — reject it, don't silently ignore. + let (status, body) = get_json( + &state, + "/tdata/VecItems/Temper.Nearest(decl='embed',to='item-a',k=5)?$top=1", + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "body: {body}"); +} + +#[tokio::test] +async fn nearest_rejects_duplicate_named_argument() { + let state = build_state(); + let tenant = TenantId::from("default"); + create_item(&state, &tenant, "item-a", &[1.0, 0.0, 0.0, 0.0], "m1").await; + + let (status, _body) = get_json( + &state, + "/tdata/VecItems/Temper.Nearest(decl='embed',to='item-a',k=5,k=10)", + ) + .await; + assert_eq!( + status, + StatusCode::BAD_REQUEST, + "duplicate 'k' must be rejected" + ); +} diff --git a/crates/temper-store-postgres/src/store.rs b/crates/temper-store-postgres/src/store.rs index d4f3d205..21aa5c71 100644 --- a/crates/temper-store-postgres/src/store.rs +++ b/crates/temper-store-postgres/src/store.rs @@ -63,7 +63,7 @@ impl EventStore for PostgresEventStore { expected_sequence: u64, events: &[PersistenceEnvelope], ) -> Result { - self.append_with_index_rows(persistence_id, expected_sequence, events, &[], &[]) + self.append_with_index_rows(persistence_id, expected_sequence, events, &[], &[], false) .await } @@ -74,6 +74,7 @@ impl EventStore for PostgresEventStore { events: &[PersistenceEnvelope], key_rows: &[temper_runtime::persistence::EntityKeyRow], vector_rows: &[EntityVectorRow], + reconcile_vectors: bool, ) -> Result { let (tenant, entity_type, entity_id) = parse_persistence_id_parts(persistence_id).map_err(PersistenceError::Storage)?; @@ -245,35 +246,39 @@ impl EventStore for PostgresEventStore { } // ADR-0155: co-commit the derived vector-index rows in THIS transaction. - // No uniqueness constraint — drop the entity's prior rows for each decl (its - // vector or model tag may have changed), then insert the current row. - for row in vector_rows { + // When the entity's type declares vector paths (`reconcile_vectors`), DELETE + // all of the entity's rows first, then insert the current ones — so a delete + // transition or a cleared vector/model property (empty `vector_rows`) purges + // the stale rows instead of leaving them to rank forever. No uniqueness + // constraint; vectors are derived ranking state. + if reconcile_vectors { crate::dbm::postgres_query!( "DELETE FROM entity_vector_index \ - WHERE tenant = $1 AND entity_type = $2 AND decl_name = $3 AND entity_id = $4", - ) - .bind(tenant) - .bind(entity_type) - .bind(&row.decl_name) - .bind(entity_id) - .execute(&mut *tx) - .await - .map_err(|e| PersistenceError::Storage(e.to_string()))?; - crate::dbm::postgres_query!( - "INSERT INTO entity_vector_index \ - (tenant, entity_type, decl_name, model_tag, entity_id, vector, sequence_nr) \ - VALUES ($1, $2, $3, $4, $5, $6, $7)", + WHERE tenant = $1 AND entity_type = $2 AND entity_id = $3", ) .bind(tenant) .bind(entity_type) - .bind(&row.decl_name) - .bind(&row.model_tag) .bind(entity_id) - .bind(pack_f32_le(&row.vector)) - .bind(new_seq as i64) .execute(&mut *tx) .await .map_err(|e| PersistenceError::Storage(e.to_string()))?; + for row in vector_rows { + crate::dbm::postgres_query!( + "INSERT INTO entity_vector_index \ + (tenant, entity_type, decl_name, model_tag, entity_id, vector, sequence_nr) \ + VALUES ($1, $2, $3, $4, $5, $6, $7)", + ) + .bind(tenant) + .bind(entity_type) + .bind(&row.decl_name) + .bind(&row.model_tag) + .bind(entity_id) + .bind(pack_f32_le(&row.vector)) + .bind(new_seq as i64) + .execute(&mut *tx) + .await + .map_err(|e| PersistenceError::Storage(e.to_string()))?; + } } let commit_started = Instant::now(); @@ -470,26 +475,25 @@ impl EventStore for PostgresEventStore { entity_id: &str, vector_rows: &[EntityVectorRow], ) -> Result<(), PersistenceError> { - if vector_rows.is_empty() { - return Ok(()); - } + // Reconcile: DELETE all of the entity's rows, then insert the current ones. + // Empty `vector_rows` purges the entity (deleted / un-embedded). Always runs + // the delete (even for empty rows) so a purge is honored. let mut tx = self .pool .begin() .await .map_err(|e| PersistenceError::Storage(e.to_string()))?; + crate::dbm::postgres_query!( + "DELETE FROM entity_vector_index \ + WHERE tenant = $1 AND entity_type = $2 AND entity_id = $3", + ) + .bind(tenant) + .bind(entity_type) + .bind(entity_id) + .execute(&mut *tx) + .await + .map_err(|e| PersistenceError::Storage(e.to_string()))?; for row in vector_rows { - crate::dbm::postgres_query!( - "DELETE FROM entity_vector_index \ - WHERE tenant = $1 AND entity_type = $2 AND decl_name = $3 AND entity_id = $4", - ) - .bind(tenant) - .bind(entity_type) - .bind(&row.decl_name) - .bind(entity_id) - .execute(&mut *tx) - .await - .map_err(|e| PersistenceError::Storage(e.to_string()))?; crate::dbm::postgres_query!( "INSERT INTO entity_vector_index \ (tenant, entity_type, decl_name, model_tag, entity_id, vector, sequence_nr) \ @@ -517,6 +521,7 @@ impl EventStore for PostgresEventStore { entity_type: &str, decl_name: &str, model_tag: &str, + limit: usize, ) -> Result, PersistenceError> { let mut conn = self .pool @@ -526,12 +531,13 @@ impl EventStore for PostgresEventStore { let rows: Vec<(String, Vec)> = crate::dbm::postgres_query_as!( "SELECT entity_id, vector FROM entity_vector_index \ WHERE tenant = $1 AND entity_type = $2 AND decl_name = $3 AND model_tag = $4 \ - ORDER BY entity_id", + ORDER BY entity_id LIMIT $5", ) .bind(tenant) .bind(entity_type) .bind(decl_name) .bind(model_tag) + .bind(limit as i64) .fetch_all(&mut *conn) .await .map_err(|e| PersistenceError::Storage(e.to_string()))?; diff --git a/crates/temper-store-sim/src/lib.rs b/crates/temper-store-sim/src/lib.rs index c1e5c397..d52a9664 100644 --- a/crates/temper-store-sim/src/lib.rs +++ b/crates/temper-store-sim/src/lib.rs @@ -354,7 +354,7 @@ impl EventStore for SimEventStore { expected_sequence: u64, events: &[PersistenceEnvelope], ) -> Result { - self.append_with_index_rows(persistence_id, expected_sequence, events, &[], &[]) + self.append_with_index_rows(persistence_id, expected_sequence, events, &[], &[], false) .await } @@ -365,6 +365,7 @@ impl EventStore for SimEventStore { events: &[PersistenceEnvelope], key_rows: &[temper_runtime::persistence::EntityKeyRow], vector_rows: &[EntityVectorRow], + reconcile_vectors: bool, ) -> Result { let append_delay = { let mut inner = self @@ -554,21 +555,20 @@ impl EventStore for SimEventStore { } // ADR-0155: co-commit the derived vector-index rows under the SAME lock as - // the journal write. No uniqueness constraint — vectors are derived ranking - // state. Drop the entity's prior rows for each decl (its model_tag or vector - // may have changed), then claim the current (decl, model_tag) -> vector. - if !vector_rows.is_empty() { + // the journal write. When the entity's type declares vector paths + // (`reconcile_vectors`), DELETE all of the entity's rows first, then insert + // the current ones — so a delete transition or a cleared vector/model + // property (empty `vector_rows`) purges the stale rows instead of leaving + // them to rank forever. No uniqueness constraint — vectors are derived state. + if reconcile_vectors { let mut parts = persistence_id.splitn(3, ':'); let tenant = parts.next().unwrap_or(""); let entity_type = parts.next().unwrap_or(""); let entity_id = parts.next().unwrap_or(""); + inner.vector_index.retain(|(t, et, _, _, eid), _| { + !(t.as_str() == tenant && et.as_str() == entity_type && eid.as_str() == entity_id) + }); for row in vector_rows { - inner.vector_index.retain(|(t, et, decl, _, eid), _| { - !(t.as_str() == tenant - && et.as_str() == entity_type - && decl.as_str() == row.decl_name.as_str() - && eid.as_str() == entity_id) - }); inner.vector_index.insert( ( tenant.to_string(), @@ -687,15 +687,12 @@ impl EventStore for SimEventStore { vector_rows: &[EntityVectorRow], ) -> Result<(), PersistenceError> { let mut inner = self.inner.lock().expect("SimEventStore lock poisoned"); // ci-ok: infallible lock + // Reconcile: drop ALL of the entity's rows, then insert the current ones. + // Empty `vector_rows` purges the entity (deleted / un-embedded). Idempotent. + inner.vector_index.retain(|(t, et, _, _, eid), _| { + !(t.as_str() == tenant && et.as_str() == entity_type && eid == entity_id) + }); for row in vector_rows { - // Upsert: drop the entity's prior rows for this decl, then claim the - // current (decl, model_tag) -> vector. Idempotent. - inner.vector_index.retain(|(t, et, decl, _, eid), _| { - !(t.as_str() == tenant - && et.as_str() == entity_type - && decl.as_str() == row.decl_name.as_str() - && eid == entity_id) - }); inner.vector_index.insert( ( tenant.to_string(), @@ -716,10 +713,12 @@ impl EventStore for SimEventStore { entity_type: &str, decl_name: &str, model_tag: &str, + limit: usize, ) -> Result, PersistenceError> { let inner = self.inner.lock().expect("SimEventStore lock poisoned"); // ci-ok: infallible lock // BTreeMap iteration is ordered by key, so `entity_id` (the last key // component within a fixed partition) yields deterministic candidate order. + // Cap at `limit` so an over-budget partition is detected without copying it all. let mut out = Vec::new(); for ((t, et, decl, tag, entity_id), vector) in inner.vector_index.iter() { if t.as_str() == tenant @@ -727,6 +726,9 @@ impl EventStore for SimEventStore { && decl.as_str() == decl_name && tag.as_str() == model_tag { + if out.len() >= limit { + break; + } out.push(EntityVectorCandidate { entity_id: entity_id.clone(), vector: vector.clone(), diff --git a/crates/temper-store-turso/src/router.rs b/crates/temper-store-turso/src/router.rs index da44d66b..3c7e1fe0 100644 --- a/crates/temper-store-turso/src/router.rs +++ b/crates/temper-store-turso/src/router.rs @@ -751,6 +751,7 @@ impl EventStore for TenantStoreRouter { events: &[PersistenceEnvelope], key_rows: &[temper_runtime::persistence::EntityKeyRow], vector_rows: &[temper_runtime::persistence::EntityVectorRow], + reconcile_vectors: bool, ) -> Result { let (tenant, _, _) = parse_persistence_id_parts(persistence_id).map_err(PersistenceError::Storage)?; @@ -762,6 +763,7 @@ impl EventStore for TenantStoreRouter { events, key_rows, vector_rows, + reconcile_vectors, ) .await } @@ -787,10 +789,11 @@ impl EventStore for TenantStoreRouter { entity_type: &str, decl_name: &str, model_tag: &str, + limit: usize, ) -> Result, PersistenceError> { let store = self.store_for_tenant(tenant).await?; store - .vector_candidates(tenant, entity_type, decl_name, model_tag) + .vector_candidates(tenant, entity_type, decl_name, model_tag, limit) .await } diff --git a/crates/temper-store-turso/src/store/event_store.rs b/crates/temper-store-turso/src/store/event_store.rs index f9f4f877..84d23fb0 100644 --- a/crates/temper-store-turso/src/store/event_store.rs +++ b/crates/temper-store-turso/src/store/event_store.rs @@ -8,7 +8,7 @@ use temper_runtime::persistence::{ unpack_f32_le, }; use temper_runtime::tenant::parse_persistence_id_parts; -use tracing::{instrument, warn}; +use tracing::{error, instrument, warn}; use super::TursoEventStore; use super::append_config::{append_attempt_timeout, append_max_attempts}; @@ -149,11 +149,11 @@ impl EventStore for TursoEventStore { // (completing ADR-0153 phase 2 for Turso) — tracked separately. // ADR-0155: Turso maintains `entity_vector_index` **write-behind** — the event is - // appended first (with retries), then the derived vector rows follow in a separate - // write. This is safe for vectors (unlike keys) because a vector row carries no - // uniqueness constraint and a lagging/failed index write only makes a ranking - // temporarily incomplete, which the backfill watermark accounts for; it can never - // corrupt a keyed absence. So Turso implements the full vector surface below. + // appended first (with retries), then the derived vector rows follow in a separate, + // also-retried write. This is safe for vectors (unlike keys) because a vector row + // carries no uniqueness constraint and a lagging index write only makes a ranking + // temporarily incomplete; it can never corrupt a keyed absence. So Turso implements + // the full vector surface below. async fn append_with_index_rows( &self, persistence_id: &str, @@ -161,25 +161,52 @@ impl EventStore for TursoEventStore { events: &[PersistenceEnvelope], _key_rows: &[temper_runtime::persistence::EntityKeyRow], vector_rows: &[EntityVectorRow], + reconcile_vectors: bool, ) -> Result { // The journal append is the durable event (keys are not maintained on Turso, // per the note above). let new_seq = self .append(persistence_id, expected_sequence, events) .await?; - // Write-behind: the event is durable; the derived vector index follows. A - // failure here is logged, not fatal — the backfill reconciles the partition. - if !vector_rows.is_empty() + // Write-behind vector maintenance: reconcile the entity's rows (delete stale, + // insert current — an empty `vector_rows` purges a deleted/cleared entity), + // RETRIED like the event append rather than a warn-once one-shot, so a + // transient failure does not silently drop the write. On final exhaustion the + // error is logged loudly; the partition then lags until the next backfill + // reconcile runs. Only runs when the type declares vector paths. + if reconcile_vectors && let Ok((tenant, entity_type, entity_id)) = parse_persistence_id_parts(persistence_id) - && let Err(error) = self - .backfill_entity_vectors(tenant, entity_type, entity_id, vector_rows) - .await { - warn!( - persistence_id, - error = %error, - "turso vector-index write-behind failed; backfill will reconcile the partition" - ); + let total_attempts = append_max_attempts(); + let mut last_err: Option = None; + for attempt in 0..total_attempts { + if attempt > 0 { + tokio::time::sleep(Duration::from_millis(retry_delay_ms(attempt - 1))).await; + } + match self + .backfill_entity_vectors(tenant, entity_type, entity_id, vector_rows) + .await + { + Ok(()) => { + last_err = None; + break; + } + Err(err) => { + let transient = matches!(&err, PersistenceError::Storage(msg) if is_transient_write_error(msg)); + last_err = Some(err); + if !transient { + break; + } + } + } + } + if let Some(error) = last_err { + error!( + persistence_id, + error = %error, + "turso vector-index write-behind failed after retries; partition lags until the next backfill reconcile" + ); + } } Ok(new_seq) } @@ -191,9 +218,9 @@ impl EventStore for TursoEventStore { entity_id: &str, vector_rows: &[EntityVectorRow], ) -> Result<(), PersistenceError> { - if vector_rows.is_empty() { - return Ok(()); - } + // Reconcile: DELETE all of the entity's rows, then insert the current ones. + // Empty `vector_rows` purges the entity (deleted / un-embedded). Always runs + // the delete so a purge is honored. let _write_permit = self .acquire_write_permit("turso.backfill_entity_vectors", WritePriority::Low) .await?; @@ -202,16 +229,14 @@ impl EventStore for TursoEventStore { .transaction_with_behavior(TransactionBehavior::Immediate) .await .map_err(storage_error)?; + tx.execute( + "DELETE FROM entity_vector_index \ + WHERE tenant = ?1 AND entity_type = ?2 AND entity_id = ?3", + params![tenant, entity_type, entity_id], + ) + .await + .map_err(storage_error)?; for row in vector_rows { - // Upsert: drop the entity's prior rows for this decl (its vector or model - // tag may have changed), then insert the current row. - tx.execute( - "DELETE FROM entity_vector_index \ - WHERE tenant = ?1 AND entity_type = ?2 AND decl_name = ?3 AND entity_id = ?4", - params![tenant, entity_type, row.decl_name.as_str(), entity_id], - ) - .await - .map_err(storage_error)?; tx.execute( "INSERT INTO entity_vector_index \ (tenant, entity_type, decl_name, model_tag, entity_id, vector, sequence_nr) \ @@ -238,14 +263,15 @@ impl EventStore for TursoEventStore { entity_type: &str, decl_name: &str, model_tag: &str, + limit: usize, ) -> Result, PersistenceError> { let conn = self.configured_connection().await?; let mut rows = conn .query( "SELECT entity_id, vector FROM entity_vector_index \ WHERE tenant = ?1 AND entity_type = ?2 AND decl_name = ?3 AND model_tag = ?4 \ - ORDER BY entity_id", - params![tenant, entity_type, decl_name, model_tag], + ORDER BY entity_id LIMIT ?5", + params![tenant, entity_type, decl_name, model_tag, limit as i64], ) .await .map_err(storage_error)?; diff --git a/crates/temper-store-turso/src/store/tests/mod.rs b/crates/temper-store-turso/src/store/tests/mod.rs index 595a8186..f75aa4c0 100644 --- a/crates/temper-store-turso/src/store/tests/mod.rs +++ b/crates/temper-store-turso/src/store/tests/mod.rs @@ -85,6 +85,7 @@ async fn vector_index_write_behind_candidates_and_partitioning() { &[test_envelope("Create", serde_json::json!({}))], &[], &[row("embed", "m1", vec![0.0, 1.0])], + true, ) .await .unwrap(); @@ -95,6 +96,7 @@ async fn vector_index_write_behind_candidates_and_partitioning() { &[test_envelope("Create", serde_json::json!({}))], &[], &[row("embed", "m1", vec![1.0, 0.0])], + true, ) .await .unwrap(); @@ -106,12 +108,13 @@ async fn vector_index_write_behind_candidates_and_partitioning() { &[test_envelope("Create", serde_json::json!({}))], &[], &[row("embed", "m2", vec![1.0, 0.0])], + true, ) .await .unwrap(); let candidates = store - .vector_candidates("t", "Item", "embed", "m1") + .vector_candidates("t", "Item", "embed", "m1", 1000) .await .unwrap(); // Two m1 items, in entity_id order (a before b) with their vectors intact. @@ -127,7 +130,7 @@ async fn vector_index_write_behind_candidates_and_partitioning() { .await .unwrap(); let candidates = store - .vector_candidates("t", "Item", "embed", "m1") + .vector_candidates("t", "Item", "embed", "m1", 1000) .await .unwrap(); assert_eq!(candidates.len(), 2); @@ -150,6 +153,72 @@ async fn vector_index_write_behind_candidates_and_partitioning() { assert_eq!(ids, vec!["item-a", "item-b", "item-c"]); } +#[tokio::test] +async fn vector_index_reconcile_purges_on_delete_and_empty_rows() { + // ADR-0155: a delete/clear reconciles to an empty row set, purging the entity's + // vector rows (the turso-side "remove" cleanup) so it is never ranked again. + let store = make_store("vector-purge").await; + let row = |v: Vec| EntityVectorRow { + decl_name: "embed".to_string(), + model_tag: "m1".to_string(), + vector: v, + }; + + // Write-behind reconcile with a row, then a delete transition (empty rows). + store + .append_with_index_rows( + "t:Item:item-a", + 0, + &[test_envelope("Create", serde_json::json!({}))], + &[], + std::slice::from_ref(&row(vec![1.0, 0.0])), + true, + ) + .await + .unwrap(); + assert_eq!( + store + .vector_candidates("t", "Item", "embed", "m1", 10) + .await + .unwrap() + .len(), + 1 + ); + // A Deleted transition emits no vector rows but still reconciles (purge). + store + .append_with_index_rows( + "t:Item:item-a", + 1, + &[test_envelope("Delete", serde_json::json!({}))], + &[], + &[], + true, + ) + .await + .unwrap(); + assert!( + store + .vector_candidates("t", "Item", "embed", "m1", 10) + .await + .unwrap() + .is_empty(), + "the deleted entity's vector row must be purged" + ); + + // The explicit backfill purge (empty rows) is idempotent. + store + .backfill_entity_vectors("t", "Item", "item-a", &[]) + .await + .unwrap(); + assert!( + store + .vector_candidates("t", "Item", "embed", "m1", 10) + .await + .unwrap() + .is_empty() + ); +} + #[tokio::test] async fn append_with_wrong_sequence_fails_with_concurrency_violation() { let store = make_store("concurrency").await; diff --git a/docs/adrs/0155-declared-vector-access-path.md b/docs/adrs/0155-declared-vector-access-path.md index 96831697..3e427216 100644 --- a/docs/adrs/0155-declared-vector-access-path.md +++ b/docs/adrs/0155-declared-vector-access-path.md @@ -135,6 +135,46 @@ is the entire point of doing this in the kernel. reflects the journal deterministically. A DST drives seeded writes + `Nearest` reads and asserts identical ordering across all seeds. +## Correctness details (review hardening) + +- **Deletion and cleared vectors.** A write for a vector-declaring type *reconciles* + the entity's index rows: the store deletes all of the entity's rows, then inserts + the current ones. A soft-deleted (`status = "Deleted"`) entity emits no rows, so it + is purged even though its embedding field persists; a cleared vector/model property + drops that path's rows. As defense in depth the `Temper.Nearest` walk also skips + any `Deleted` body and `to=''` returns 404, so a stale row (e.g. one + written by a Turso write-behind that has not yet caught up) is never *served*. +- **Reference-entity authorization.** `to=''` read-authorizes the reference with + the same Cedar `read` gate a normal single read uses, before disclosing its + existence, embedding, or the similarity ranking it seeds. Every ranked row is + `read`-gated during materialization, so a denied row is skipped, never leaked. +- **Numeric safety.** Metric accumulation is done in f64 and the score is dropped if + the narrowed f32 is not finite; the blob decoder rejects non-finite components. An + overflowing or corrupt vector therefore declines rather than producing a `NaN`, + which would otherwise sort ahead of every real score. +- **Budget.** `vector_candidates` applies `LIMIT budget+1` in the store, so an + over-budget partition returns 413 without loading the whole partition. The ranked + walk then materializes candidates one at a time until `k` are accepted; at the ≤1k + target scale this N-at-most walk is microseconds, and a bounded batch materialization + is the optimization if a partition ever approaches the budget. +- **Turso durability.** Turso maintains the index write-behind (event first, index + follows), and that follow-up write is *retried* (same retry primitives as the event + append) rather than a warn-once one-shot; on exhaustion it logs loudly and the + partition lags until the next backfill reconcile. This is kept in the EventStore + layer (where co-commit lives on Postgres) rather than routed through the + field-index projection queue, because the queue is spec-agnostic (`QueryPlaneStore`) + while vector parsing needs the spec, and routing it there would double-maintain on + Postgres; the retry/remove substance is delivered in-layer. +- **Signature includes shape.** The backfill watermark records each path as + `name:property:model_property:dims:metric`, so an in-place edit to `dims` (which + makes every existing row the wrong length) — or to any other field — changes the + signature and re-indexes the type, rather than silently leaving mismatched rows. +- **Surface.** `Temper.Nearest` is dispatched by its fully-qualified name, rejects + unknown/duplicate arguments and any OData system query option (`$top`/`$select`/…), + and is a kernel bound function discoverable via this ADR; it is **not** advertised in + per-tenant `$metadata` (that is the producer's CSDL — kernel-side augmentation of the + metadata pipeline is deferred). + ## Non-Goals - Embedding generation (stays in post-transition integrations/WASM). - ANN / approximate indexes (a later declared `index = "hnsw"` opt-in). From dae571e663fdadba0e87e0bdfdaa1880a04ad29d Mon Sep 17 00:00:00 2001 From: Rita Agafonova <36133358+rita-aga@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:52:37 -0700 Subject: [PATCH 9/9] chore(vector): record review-fix growth in readability ratchet (ARN-159) The review fixes add correctness logic (reconcile/authz/status handling) and two more trait-forwarder parameters (reconcile_vectors, limit) to the dyn-adapter boilerplate, nudging the file-size ratchet. Intentional baseline update (the sanctioned path). Co-Authored-By: Claude Fable 5 --- .ci/readability-baseline.env | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.ci/readability-baseline.env b/.ci/readability-baseline.env index 986b8ed0..9de17adc 100644 --- a/.ci/readability-baseline.env +++ b/.ci/readability-baseline.env @@ -1,9 +1,9 @@ # Generated by scripts/readability-ratchet.sh PROD_RS_TOTAL=523 -PROD_FILES_GT300=208 -PROD_FILES_GT500=83 +PROD_FILES_GT300=209 +PROD_FILES_GT500=84 PROD_FILES_GT1000=22 -PROD_MAX_FILE_LINES=2820 +PROD_MAX_FILE_LINES=2829 PROD_MAX_FILE_PATH=crates/temper-server/src/storage/mod.rs ALLOW_CLIPPY_COUNT=35 ALLOW_DEAD_CODE_COUNT=14