Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions .ci/readability-baseline.env
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
# Generated by scripts/readability-ratchet.sh
PROD_RS_TOTAL=523
PROD_FILES_GT300=205
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_GT300=209
PROD_FILES_GT500=84
PROD_FILES_GT1000=22
PROD_MAX_FILE_LINES=2829
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
6 changes: 6 additions & 0 deletions crates/temper-cli/src/serve/bootstrap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions crates/temper-jit/src/shadow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions crates/temper-jit/src/swap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()],
Expand Down
11 changes: 11 additions & 0 deletions crates/temper-jit/src/table/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
22 changes: 22 additions & 0 deletions crates/temper-jit/src/table/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,20 @@ pub struct DeclaredKey {
pub properties: Vec<String>,
}

/// 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)]
Expand All @@ -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<DeclaredKey>,
/// 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<DeclaredVector>,
/// 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.
Expand Down Expand Up @@ -143,6 +161,8 @@ impl<'de> Deserialize<'de> for TransitionTable {
composite_actions: BTreeMap<String, CompositeActionMetadata>,
#[serde(default)]
keys: Vec<DeclaredKey>,
#[serde(default)]
vectors: Vec<DeclaredVector>,
}

let raw = TransitionTableRaw::deserialize(deserializer)?;
Expand All @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down
103 changes: 95 additions & 8 deletions crates/temper-odata/src/path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ODataPath>,
function: String,
params: Vec<(String, String)>,
},

/// The `$value` media stream of an entity, e.g. `/Files('f-1')/$value`.
Expand Down Expand Up @@ -197,17 +201,29 @@ fn parse_continuation_segment(segment: &str, parent: ODataPath) -> Result<ODataP

// Check if this is a qualified name (contains dot) — bound operation
if segment.contains('.') {
// Bound function: ends with ()
if let Some(qualified_name) = segment.strip_suffix("()") {
// Extract just the operation name (last part after final dot)
let function_name = qualified_name.rsplit('.').next().unwrap_or(qualified_name);
// Bound function: a qualified name with a parenthesized argument list,
// e.g. `Namespace.GetTotal()` or `Temper.Nearest(decl='taste',to='x',k=10)`.
if let Some(paren_start) = segment.find('(') {
if !segment.ends_with(')') {
return Err(ODataError::InvalidPath {
message: format!("bound function '{segment}' has unmatched parenthesis"),
});
}
// Keep the FULL qualified name (e.g. `Temper.Nearest`), unlike a bound
// action (which resolves to a short spec action name). A kernel function
// like `Temper.Nearest` is dispatched by its namespaced name, so a
// same-named function in another namespace does not collide with it.
let qualified_name = &segment[..paren_start];
let args_str = &segment[paren_start + 1..segment.len() - 1];
let params = parse_function_params(args_str)?;
return Ok(ODataPath::BoundFunction {
parent: Box::new(parent),
function: function_name.to_string(),
function: qualified_name.to_string(),
params,
});
}

// Bound action: qualified name without trailing ()
// Bound action: qualified name without parentheses.
let action_name = segment.rsplit('.').next().unwrap_or(segment);
return Ok(ODataPath::BoundAction {
parent: Box::new(parent),
Expand Down Expand Up @@ -281,6 +297,43 @@ fn parse_key_value(key_str: &str) -> Result<KeyValue, ODataError> {
}
}

/// 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<Vec<(String, String)>, ODataError> {
if args_str.trim().is_empty() {
return Ok(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 {
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"),
});
}
// 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));
}
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;
Expand Down Expand Up @@ -487,11 +540,45 @@ mod tests {
"Orders".into(),
KeyValue::Single("abc-123".into())
)),
function: "GetOrderTotal".into(),
function: "Temper.Example.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: "Temper.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();
Expand Down
4 changes: 4 additions & 0 deletions crates/temper-platform/src/os_apps/reconcile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
}
Loading
Loading