diff --git a/packages/cultnet-rs/src/contracts.rs b/packages/cultnet-rs/src/contracts.rs index b5c89ff7..451188c3 100644 --- a/packages/cultnet-rs/src/contracts.rs +++ b/packages/cultnet-rs/src/contracts.rs @@ -6,6 +6,8 @@ use serde::Deserialize; use serde::Serialize; use serde_json::Value; +use crate::{Edge, RawDocumentHeader, RecordRef, Selection, SelectionDocumentRecord}; + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum CultNetWireContract { #[serde(rename = "cultnet.schema.v0")] @@ -329,6 +331,56 @@ pub enum CultNetMessage { message_id: String, documents: Vec, }, + /// CultNet typed selection, Cut 1 (docs/cultnet-selection-cut.md section 2/7). Mirrors + /// `CultNetSnapshotRequestV1Message`. + #[serde(rename = "cultnet.snapshot_request.v1", rename_all = "camelCase")] + SnapshotRequestV1 { + message_id: String, + selection: Selection, + #[serde(default, skip_serializing_if = "Option::is_none")] + shard_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + shard_epoch: Option, + }, + /// Mirrors `CultNetDatabaseSubscribeV1Message`. + #[serde(rename = "cultnet.database_subscribe.v1", rename_all = "camelCase")] + DatabaseSubscribeV1 { + message_id: String, + subscription_id: String, + selection: Selection, + #[serde(default = "true_default")] + include_snapshot: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + consumer_runtime_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + body_ids: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + supported_body_transports: Option>, + }, + /// `cultnet.snapshot_response_raw.v1` (section 2: `SelectionPage`). Binary payload, so this is + /// carried through `encode_raw_cultnet_schema_message`/`parse_raw_cultnet_schema_message` like + /// `SnapshotResponseRaw` above - a JSON intermediate cannot losslessly carry `documents[].payload` + /// or `edges[].payload`. Mirrors `CultNetSnapshotResponseRawV1Message`. + #[serde(rename = "cultnet.snapshot_response_raw.v1", rename_all = "camelCase")] + SnapshotResponseRawV1 { + message_id: String, + matched: u32, + as_of: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + next: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + headers: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + documents: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + edges: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + shard_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + shard_epoch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + shard_log_sequence: Option, + }, #[serde(rename = "cultnet.schema_catalog_request.v0", rename_all = "camelCase")] SchemaCatalogRequest { message_id: String, @@ -395,6 +447,10 @@ fn messagepack_base64_encoding() -> String { "messagepack-base64".to_string() } +fn true_default() -> bool { + true +} + pub fn parse_cultnet_message( wire_value: &rmpv::Value, contract: CultNetWireContract, @@ -402,9 +458,11 @@ pub fn parse_cultnet_message( match contract { CultNetWireContract::CultNetSchemaV0 => { let message = match schema_version_from_wire_value(wire_value)? { - Some("cultnet.document_put_raw.v0" | "cultnet.snapshot_response_raw.v0") => { - parse_raw_cultnet_schema_message(wire_value)? - } + Some( + "cultnet.document_put_raw.v0" + | "cultnet.snapshot_response_raw.v0" + | "cultnet.snapshot_response_raw.v1", + ) => parse_raw_cultnet_schema_message(wire_value)?, _ => { let json_value: Value = rmp_serde::from_slice(&rmp_serde::to_vec(wire_value)?)?; serde_json::from_value(json_value)? @@ -424,7 +482,9 @@ pub fn encode_cultnet_message_for_wire( validate_message(message)?; match contract { CultNetWireContract::CultNetSchemaV0 => match message { - CultNetMessage::DocumentPutRaw { .. } | CultNetMessage::SnapshotResponseRaw { .. } => { + CultNetMessage::DocumentPutRaw { .. } + | CultNetMessage::SnapshotResponseRaw { .. } + | CultNetMessage::SnapshotResponseRawV1 { .. } => { encode_raw_cultnet_schema_message(message) } _ => Ok(rmp_serde::from_slice(&rmp_serde::to_vec( @@ -566,6 +626,57 @@ fn validate_message(message: &CultNetMessage) -> Result<()> { validate_raw_document_record(document)?; } } + CultNetMessage::SnapshotRequestV1 { + message_id, + // The selection's own declared-alias semantics need a schema registry this wire-level + // deserialization does not have; the C# reference does not validate CultNetSelection at + // this layer either (CultNetSchemaMessageSerialization.cs just deserializes it). A + // server-side caller runs `selection::validate` against its RowSet before evaluating. + selection: _, + shard_id, + shard_epoch: _, + } => { + require_non_empty(message_id, "messageId")?; + require_optional_non_empty(shard_id.as_deref(), "shardId")?; + } + CultNetMessage::DatabaseSubscribeV1 { + message_id, + subscription_id, + selection: _, + include_snapshot: _, + consumer_runtime_id, + body_ids, + supported_body_transports, + } => { + require_non_empty(message_id, "messageId")?; + require_non_empty(subscription_id, "subscriptionId")?; + require_optional_non_empty(consumer_runtime_id.as_deref(), "consumerRuntimeId")?; + require_optional_string_vec(body_ids.as_deref(), "bodyIds")?; + require_optional_string_vec( + supported_body_transports.as_deref(), + "supportedBodyTransports", + )?; + } + CultNetMessage::SnapshotResponseRawV1 { + message_id, + matched: _, + as_of: _, + next: _, + headers, + documents, + edges: _, + shard_id, + shard_epoch: _, + shard_log_sequence: _, + } => { + require_non_empty(message_id, "messageId")?; + if headers.is_some() == documents.is_some() { + return Err(anyhow!( + "CultNet field exactly one of headers/documents must be present" + )); + } + require_optional_non_empty(shard_id.as_deref(), "shardId")?; + } CultNetMessage::SchemaCatalogRequest { message_id, include_schema_json: _, @@ -803,6 +914,67 @@ fn parse_raw_cultnet_schema_message(input: &rmpv::Value) -> Result { + let headers = get("headers") + .filter(|value| !value.is_nil()) + .map(|value| { + value + .as_array() + .ok_or_else(|| anyhow!("headers must be an array"))? + .iter() + .enumerate() + .map(|(index, value)| { + require_raw_document_header(Some(value), &format!("headers[{index}]")) + }) + .collect::>>() + }) + .transpose()?; + let documents = get("documents") + .filter(|value| !value.is_nil()) + .map(|value| { + value + .as_array() + .ok_or_else(|| anyhow!("documents must be an array"))? + .iter() + .enumerate() + .map(|(index, value)| { + require_selection_document_record( + Some(value), + &format!("documents[{index}]"), + ) + }) + .collect::>>() + }) + .transpose()?; + let edges = get("edges") + .filter(|value| !value.is_nil()) + .map(|value| { + value + .as_array() + .ok_or_else(|| anyhow!("edges must be an array"))? + .iter() + .enumerate() + .map(|(index, value)| require_edge(Some(value), &format!("edges[{index}]"))) + .collect::>>() + }) + .transpose()?; + + Ok(CultNetMessage::SnapshotResponseRawV1 { + message_id: require_legacy_string(get("messageId"), "messageId")?, + matched: require_legacy_u32(get("matched"), "matched")?, + as_of: require_legacy_u64(get("asOf"), "asOf")?, + next: require_legacy_optional_string(get("next"), "next")?, + headers, + documents, + edges, + shard_id: require_legacy_optional_string(get("shardId"), "shardId")?, + shard_epoch: require_legacy_optional_i64(get("shardEpoch"), "shardEpoch")?, + shard_log_sequence: require_legacy_optional_i64( + get("shardLogSequence"), + "shardLogSequence", + )?, + }) + } _ => Err(anyhow!( "Unsupported raw cultnet.schema.v0 schemaVersion {schema_version}" )), @@ -845,6 +1017,74 @@ fn encode_raw_cultnet_schema_message(message: &CultNetMessage) -> Result vec![ + ( + rmpv::Value::from("schemaVersion"), + rmpv::Value::from("cultnet.snapshot_response_raw.v1"), + ), + ( + rmpv::Value::from("messageId"), + rmpv::Value::from(message_id.as_str()), + ), + (rmpv::Value::from("matched"), rmpv::Value::from(*matched)), + (rmpv::Value::from("asOf"), rmpv::Value::from(*as_of)), + ( + rmpv::Value::from("next"), + next.as_deref().map(rmpv::Value::from).unwrap_or(rmpv::Value::Nil), + ), + ( + rmpv::Value::from("headers"), + headers + .as_deref() + .map(|headers| { + rmpv::Value::Array(headers.iter().map(encode_raw_document_header).collect()) + }) + .unwrap_or(rmpv::Value::Nil), + ), + ( + rmpv::Value::from("documents"), + documents + .as_deref() + .map(|documents| { + rmpv::Value::Array( + documents.iter().map(encode_selection_document_record).collect(), + ) + }) + .unwrap_or(rmpv::Value::Nil), + ), + ( + rmpv::Value::from("edges"), + edges + .as_deref() + .map(|edges| rmpv::Value::Array(edges.iter().map(encode_edge).collect())) + .unwrap_or(rmpv::Value::Nil), + ), + ( + rmpv::Value::from("shardId"), + shard_id.as_deref().map(rmpv::Value::from).unwrap_or(rmpv::Value::Nil), + ), + ( + rmpv::Value::from("shardEpoch"), + shard_epoch.map(rmpv::Value::from).unwrap_or(rmpv::Value::Nil), + ), + ( + rmpv::Value::from("shardLogSequence"), + shard_log_sequence + .map(rmpv::Value::from) + .unwrap_or(rmpv::Value::Nil), + ), + ], _ => { return Err(anyhow!( "Message is not a raw cultnet.schema.v0 binary replication message" @@ -961,6 +1201,374 @@ fn encode_raw_document_record(document: &CultNetRawDocumentRecord) -> rmpv::Valu ]) } +// --- cultnet.snapshot_response_raw.v1 (docs/cultnet-selection-cut.md section 2) ------------- + +fn require_selection_document_record( + value: Option<&rmpv::Value>, + field_name: &str, +) -> Result { + let object = value + .and_then(rmpv::Value::as_map) + .ok_or_else(|| anyhow!("{field_name} must be an object"))?; + + let get = |name: &str| -> Option<&rmpv::Value> { + object.iter().find_map(|(key, value)| { + key.as_str() + .filter(|candidate| *candidate == name) + .map(|_| value) + }) + }; + + Ok(SelectionDocumentRecord { + schema_id: require_legacy_string(get("schemaId"), &format!("{field_name}.schemaId"))?, + schema_name: require_legacy_optional_string( + get("schemaName"), + &format!("{field_name}.schemaName"), + )?, + schema_version: require_legacy_optional_string( + get("schemaVersion"), + &format!("{field_name}.schemaVersion"), + )?, + schema_content_hash: require_legacy_optional_string( + get("schemaContentHash"), + &format!("{field_name}.schemaContentHash"), + )?, + record_key: require_legacy_string(get("recordKey"), &format!("{field_name}.recordKey"))?, + stored_at: require_legacy_string(get("storedAt"), &format!("{field_name}.storedAt"))?, + payload_encoding: require_legacy_string( + get("payloadEncoding"), + &format!("{field_name}.payloadEncoding"), + )?, + payload: get("payload") + .and_then(rmpv::Value::as_slice) + .map(|bytes| bytes.to_vec()) + .ok_or_else(|| anyhow!("{field_name}.payload must be binary MessagePack bytes"))?, + source_runtime_id: require_legacy_optional_string( + get("sourceRuntimeId"), + &format!("{field_name}.sourceRuntimeId"), + )?, + source_agent_id: require_legacy_optional_string( + get("sourceAgentId"), + &format!("{field_name}.sourceAgentId"), + )?, + source_role: require_legacy_optional_string( + get("sourceRole"), + &format!("{field_name}.sourceRole"), + )?, + tags: require_legacy_optional_string_array(get("tags"), &format!("{field_name}.tags"))?, + }) +} + +fn encode_selection_document_record(document: &SelectionDocumentRecord) -> rmpv::Value { + rmpv::Value::Map(vec![ + ( + rmpv::Value::from("schemaId"), + rmpv::Value::from(document.schema_id.as_str()), + ), + ( + rmpv::Value::from("schemaName"), + document + .schema_name + .as_deref() + .map(rmpv::Value::from) + .unwrap_or(rmpv::Value::Nil), + ), + ( + rmpv::Value::from("schemaVersion"), + document + .schema_version + .as_deref() + .map(rmpv::Value::from) + .unwrap_or(rmpv::Value::Nil), + ), + ( + rmpv::Value::from("schemaContentHash"), + document + .schema_content_hash + .as_deref() + .map(rmpv::Value::from) + .unwrap_or(rmpv::Value::Nil), + ), + ( + rmpv::Value::from("recordKey"), + rmpv::Value::from(document.record_key.as_str()), + ), + ( + rmpv::Value::from("storedAt"), + rmpv::Value::from(document.stored_at.as_str()), + ), + ( + rmpv::Value::from("payloadEncoding"), + rmpv::Value::from(document.payload_encoding.as_str()), + ), + ( + rmpv::Value::from("payload"), + rmpv::Value::Binary(document.payload.clone()), + ), + ( + rmpv::Value::from("sourceRuntimeId"), + document + .source_runtime_id + .as_deref() + .map(rmpv::Value::from) + .unwrap_or(rmpv::Value::Nil), + ), + ( + rmpv::Value::from("sourceAgentId"), + document + .source_agent_id + .as_deref() + .map(rmpv::Value::from) + .unwrap_or(rmpv::Value::Nil), + ), + ( + rmpv::Value::from("sourceRole"), + document + .source_role + .as_deref() + .map(rmpv::Value::from) + .unwrap_or(rmpv::Value::Nil), + ), + ( + rmpv::Value::from("tags"), + legacy_optional_string_array(document.tags.as_deref()), + ), + ]) +} + +fn require_raw_document_header( + value: Option<&rmpv::Value>, + field_name: &str, +) -> Result { + let object = value + .and_then(rmpv::Value::as_map) + .ok_or_else(|| anyhow!("{field_name} must be an object"))?; + + let get = |name: &str| -> Option<&rmpv::Value> { + object.iter().find_map(|(key, value)| { + key.as_str() + .filter(|candidate| *candidate == name) + .map(|_| value) + }) + }; + + Ok(RawDocumentHeader { + schema_id: require_legacy_string(get("schemaId"), &format!("{field_name}.schemaId"))?, + schema_name: require_legacy_optional_string( + get("schemaName"), + &format!("{field_name}.schemaName"), + )?, + schema_version: require_legacy_optional_string( + get("schemaVersion"), + &format!("{field_name}.schemaVersion"), + )?, + schema_content_hash: require_legacy_optional_string( + get("schemaContentHash"), + &format!("{field_name}.schemaContentHash"), + )?, + record_key: require_legacy_string(get("recordKey"), &format!("{field_name}.recordKey"))?, + stored_at: require_legacy_string(get("storedAt"), &format!("{field_name}.storedAt"))?, + source_runtime_id: require_legacy_optional_string( + get("sourceRuntimeId"), + &format!("{field_name}.sourceRuntimeId"), + )?, + source_agent_id: require_legacy_optional_string( + get("sourceAgentId"), + &format!("{field_name}.sourceAgentId"), + )?, + source_role: require_legacy_optional_string( + get("sourceRole"), + &format!("{field_name}.sourceRole"), + )?, + tags: require_legacy_optional_string_array(get("tags"), &format!("{field_name}.tags"))?, + }) +} + +fn encode_raw_document_header(header: &RawDocumentHeader) -> rmpv::Value { + rmpv::Value::Map(vec![ + ( + rmpv::Value::from("schemaId"), + rmpv::Value::from(header.schema_id.as_str()), + ), + ( + rmpv::Value::from("schemaName"), + header + .schema_name + .as_deref() + .map(rmpv::Value::from) + .unwrap_or(rmpv::Value::Nil), + ), + ( + rmpv::Value::from("schemaVersion"), + header + .schema_version + .as_deref() + .map(rmpv::Value::from) + .unwrap_or(rmpv::Value::Nil), + ), + ( + rmpv::Value::from("schemaContentHash"), + header + .schema_content_hash + .as_deref() + .map(rmpv::Value::from) + .unwrap_or(rmpv::Value::Nil), + ), + ( + rmpv::Value::from("recordKey"), + rmpv::Value::from(header.record_key.as_str()), + ), + ( + rmpv::Value::from("storedAt"), + rmpv::Value::from(header.stored_at.as_str()), + ), + ( + rmpv::Value::from("sourceRuntimeId"), + header + .source_runtime_id + .as_deref() + .map(rmpv::Value::from) + .unwrap_or(rmpv::Value::Nil), + ), + ( + rmpv::Value::from("sourceAgentId"), + header + .source_agent_id + .as_deref() + .map(rmpv::Value::from) + .unwrap_or(rmpv::Value::Nil), + ), + ( + rmpv::Value::from("sourceRole"), + header + .source_role + .as_deref() + .map(rmpv::Value::from) + .unwrap_or(rmpv::Value::Nil), + ), + ( + rmpv::Value::from("tags"), + legacy_optional_string_array(header.tags.as_deref()), + ), + ]) +} + +fn require_edge(value: Option<&rmpv::Value>, field_name: &str) -> Result { + let object = value + .and_then(rmpv::Value::as_map) + .ok_or_else(|| anyhow!("{field_name} must be an object"))?; + + let get = |name: &str| -> Option<&rmpv::Value> { + object.iter().find_map(|(key, value)| { + key.as_str() + .filter(|candidate| *candidate == name) + .map(|_| value) + }) + }; + + Ok(Edge { + from: require_record_ref(get("from"), &format!("{field_name}.from"))?, + role: require_legacy_string(get("role"), &format!("{field_name}.role"))?, + to: require_record_ref(get("to"), &format!("{field_name}.to"))?, + payload_encoding: require_legacy_optional_string( + get("payloadEncoding"), + &format!("{field_name}.payloadEncoding"), + )?, + payload: get("payload") + .filter(|value| !value.is_nil()) + .map(|value| { + value + .as_slice() + .map(|bytes| bytes.to_vec()) + .ok_or_else(|| anyhow!("{field_name}.payload must be binary MessagePack bytes")) + }) + .transpose()?, + }) +} + +fn encode_edge(edge: &Edge) -> rmpv::Value { + rmpv::Value::Map(vec![ + (rmpv::Value::from("from"), encode_record_ref(&edge.from)), + (rmpv::Value::from("role"), rmpv::Value::from(edge.role.as_str())), + (rmpv::Value::from("to"), encode_record_ref(&edge.to)), + ( + rmpv::Value::from("payloadEncoding"), + edge.payload_encoding + .as_deref() + .map(rmpv::Value::from) + .unwrap_or(rmpv::Value::Nil), + ), + ( + rmpv::Value::from("payload"), + edge.payload + .as_deref() + .map(|bytes| rmpv::Value::Binary(bytes.to_vec())) + .unwrap_or(rmpv::Value::Nil), + ), + ]) +} + +fn require_record_ref(value: Option<&rmpv::Value>, field_name: &str) -> Result { + let object = value + .and_then(rmpv::Value::as_map) + .ok_or_else(|| anyhow!("{field_name} must be an object"))?; + + let get = |name: &str| -> Option<&rmpv::Value> { + object.iter().find_map(|(key, value)| { + key.as_str() + .filter(|candidate| *candidate == name) + .map(|_| value) + }) + }; + + Ok(RecordRef { + schema_id: require_legacy_string(get("schemaId"), &format!("{field_name}.schemaId"))?, + record_key: require_legacy_string(get("recordKey"), &format!("{field_name}.recordKey"))?, + }) +} + +fn encode_record_ref(record_ref: &RecordRef) -> rmpv::Value { + rmpv::Value::Map(vec![ + ( + rmpv::Value::from("schemaId"), + rmpv::Value::from(record_ref.schema_id.as_str()), + ), + ( + rmpv::Value::from("recordKey"), + rmpv::Value::from(record_ref.record_key.as_str()), + ), + ]) +} + +fn require_legacy_u32(value: Option<&rmpv::Value>, field_name: &str) -> Result { + let number = value + .and_then(rmpv::Value::as_u64) + .ok_or_else(|| anyhow!("{field_name} must be a non-negative integer"))?; + u32::try_from(number).map_err(|_| anyhow!("{field_name} does not fit in u32")) +} + +fn require_legacy_u64(value: Option<&rmpv::Value>, field_name: &str) -> Result { + value + .and_then(rmpv::Value::as_u64) + .ok_or_else(|| anyhow!("{field_name} must be a non-negative integer")) +} + +fn require_legacy_optional_i64( + value: Option<&rmpv::Value>, + field_name: &str, +) -> Result> { + let Some(value) = value else { + return Ok(None); + }; + if value.is_nil() { + return Ok(None); + } + value + .as_i64() + .map(Some) + .ok_or_else(|| anyhow!("{field_name} must be an integer")) +} + fn parse_gamecult_networking_message(input: &rmpv::Value) -> Result { let items = input.as_array().ok_or_else(|| { anyhow!("gamecult.networking.v0 messages must be a 2-element union array") diff --git a/packages/cultnet-rs/src/lib.rs b/packages/cultnet-rs/src/lib.rs index 074c2a29..cf858647 100644 --- a/packages/cultnet-rs/src/lib.rs +++ b/packages/cultnet-rs/src/lib.rs @@ -15,6 +15,7 @@ mod replication; mod rudp; mod schema_discovery; mod security; +mod selection; mod shard_catalog; mod transport; @@ -35,5 +36,6 @@ pub use replication::*; pub use rudp::*; pub use schema_discovery::*; pub use security::*; +pub use selection::*; pub use shard_catalog::*; pub use transport::*; diff --git a/packages/cultnet-rs/src/selection.rs b/packages/cultnet-rs/src/selection.rs new file mode 100644 index 00000000..b4e74bf6 --- /dev/null +++ b/packages/cultnet-rs/src/selection.rs @@ -0,0 +1,1407 @@ +//! CultNet typed selection (docs/cultnet-selection-cut.md, sections 2/6/7): the Rust spelling of +//! the same vocabulary the C# reference (`GameCult.Networking/CultNetSelection.cs`, +//! `CultNetSelectionEvaluator.cs`) owns, at wire parity, pinned by the S12 parity vectors rather than +//! by matching shapes 1:1. +//! +//! D7: this evaluator carries no `cultcache-rs` knowledge. A row's declared values and edges come +//! entirely through the [`Row`] and [`RowSet`] traits; the consumer (Huginn, or a test fixture) is the +//! row owner and supplies them the way `CultCache` supplies the C# evaluator. Nothing here reflects +//! over a document, and no type here is a serde union that skips its own tag (section 10's +//! negative grep checks for the literal attribute spelling, which this file deliberately does not +//! quote even in prose). +//! +//! Q-J (section 2 "Numbers"): every comparison number on the wire and every row's declared numeric +//! value is a canonical decimal string, never a float. [`canonical_number`] is the door's grammar and +//! the comparator; it is a pure string algorithm with no `f64`/`f32` anywhere on the comparison path. + +use std::cmp::Ordering; +use std::collections::{HashMap, HashSet}; +use std::fmt; + +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +// --------------------------------------------------------------------------------------------- +// Canonical decimal numbers (Q-J) +// --------------------------------------------------------------------------------------------- + +/// The wire's canonical decimal grammar and comparator (docs/cultnet-selection-cut.md section 2 +/// "Numbers", operator ruling Q-J). A pure string algorithm - no numeric parse, no precision limit, +/// no `f64`/`f32` on the comparison path. Ported from `CultNetCanonicalNumber` +/// (`GameCult.Networking/CultNetSelection.cs`); the digit grammar is hand-rolled rather than pulled +/// in via a `regex` dependency this crate does not otherwise need. +pub mod canonical_number { + use super::Ordering; + + /// `true` for a canonical decimal string: matches `^-?(0|[1-9][0-9]*)(\.[0-9]*[1-9])?$` and is + /// not `"-0"` (excluded explicitly; the grammar alone matches it). Forbids leading zeros, a + /// trailing fractional zero or bare point, an explicit `+`, exponent notation, and whitespace. + pub fn is_canonical(value: &str) -> bool { + if value == "-0" { + return false; + } + let bytes = value.as_bytes(); + if bytes.is_empty() { + return false; + } + let mut i = 0usize; + if bytes[0] == b'-' { + i += 1; + } + if i >= bytes.len() || !bytes[i].is_ascii_digit() { + return false; + } + if bytes[i] == b'0' { + i += 1; + // A leading zero is canonical only as the whole integer part: "0" or "0.x", never "01". + if i < bytes.len() && bytes[i].is_ascii_digit() { + return false; + } + } else { + i += 1; + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + } + if i == bytes.len() { + return true; + } + if bytes[i] != b'.' { + return false; + } + i += 1; + let fraction_start = i; + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + if i != bytes.len() { + return false; + } + let fraction_len = i - fraction_start; + // (\.[0-9]*[1-9])?: at least one fraction digit, and the last one is never zero. + fraction_len > 0 && bytes[i - 1] != b'0' + } + + /// Compares two canonical decimal strings by sign, then the integer part's length, then its + /// digits, then the fraction digits padded on the right to equal length. Callers validate both + /// operands with [`is_canonical`] first; this does not re-validate. Mirrors + /// `CultNetCanonicalNumber.Compare` exactly (docs/cultnet-selection-cut.md section 2 "Numbers"). + pub fn compare(left: &str, right: &str) -> Ordering { + let (negative_left, integer_left, fraction_left) = decompose(left); + let (negative_right, integer_right, fraction_right) = decompose(right); + + if negative_left != negative_right { + return if negative_left { + Ordering::Less + } else { + Ordering::Greater + }; + } + + let magnitude = if integer_left.len() != integer_right.len() { + integer_left.len().cmp(&integer_right.len()) + } else { + let integer_cmp = integer_left.cmp(integer_right); + if integer_cmp != Ordering::Equal { + integer_cmp + } else { + let width = fraction_left.len().max(fraction_right.len()); + pad_right(fraction_left, width).cmp(&pad_right(fraction_right, width)) + } + }; + + if negative_left { + magnitude.reverse() + } else { + magnitude + } + } + + fn decompose(value: &str) -> (bool, &str, &str) { + let negative = value.starts_with('-'); + let unsigned = if negative { &value[1..] } else { value }; + match unsigned.find('.') { + Some(dot) => (negative, &unsigned[..dot], &unsigned[dot + 1..]), + None => (negative, unsigned, ""), + } + } + + fn pad_right(value: &str, width: usize) -> String { + let mut owned = value.to_string(); + while owned.len() < width { + owned.push('0'); + } + owned + } + + /// The one canonicalizer every rendered number passes through: strips leading integer zeros and + /// trailing fractional zeros, and collapses a negative value that reduces to zero into `"0"`. + /// Mirrors `CultCache.cs`'s `CanonicalizeDecimalDigits`. Exposed so a `Row` implementation can + /// render its own numeric values into this grammar without re-deriving the algorithm. + pub fn canonicalize_decimal_digits(signed_positional: &str) -> String { + let negative = signed_positional.starts_with('-'); + let unsigned = if negative { + &signed_positional[1..] + } else { + signed_positional + }; + let (mut integer_part, fraction_part) = match unsigned.find('.') { + Some(dot) => (&unsigned[..dot], &unsigned[dot + 1..]), + None => (unsigned, ""), + }; + integer_part = integer_part.trim_start_matches('0'); + if integer_part.is_empty() { + integer_part = "0"; + } + let fraction_part = fraction_part.trim_end_matches('0'); + + let is_zero = integer_part == "0" && fraction_part.is_empty(); + let result = if fraction_part.is_empty() { + integer_part.to_string() + } else { + format!("{integer_part}.{fraction_part}") + }; + if negative && !is_zero { + format!("-{result}") + } else { + result + } + } + + /// A `double`/`float` that .NET or Rust's shortest round-trip `Display` would render in + /// exponent notation (`"1E+20"`, `"1e-7"`) rewritten to positional digits, before + /// [`canonicalize_decimal_digits`] runs. Mirrors `CultCache.cs`'s `ExpandScientificNotation`. + pub fn expand_scientific_notation(value: &str) -> String { + if value.find(['e', 'E']).is_none() { + return value.to_string(); + } + let negative = value.starts_with('-'); + let unsigned = if negative { &value[1..] } else { value }; + let marker = unsigned.find(['e', 'E']).expect("marker found above"); + let mantissa = &unsigned[..marker]; + let exponent: i64 = unsigned[marker + 1..] + .parse() + .expect("Display never emits a non-numeric exponent"); + + let (digits, dot_index) = match mantissa.find('.') { + Some(dot) => (format!("{}{}", &mantissa[..dot], &mantissa[dot + 1..]), dot), + None => (mantissa.to_string(), mantissa.len()), + }; + let point_position = dot_index as i64 + exponent; + + let expanded = if point_position <= 0 { + format!("0.{}{}", "0".repeat((-point_position) as usize), digits) + } else if point_position as usize >= digits.len() { + format!( + "{digits}{}", + "0".repeat(point_position as usize - digits.len()) + ) + } else { + let split = point_position as usize; + format!("{}.{}", &digits[..split], &digits[split..]) + }; + + if negative { + format!("-{expanded}") + } else { + expanded + } + } + + /// Renders a signed 64-bit integer as its exact canonical decimal - no 2^53 limit, because this + /// never passes through a float. + pub fn render_i64(value: i64) -> String { + canonicalize_decimal_digits(&value.to_string()) + } + + /// Renders an unsigned 64-bit integer as its exact canonical decimal. + pub fn render_u64(value: u64) -> String { + canonicalize_decimal_digits(&value.to_string()) + } + + /// Renders a `f64` as its canonical decimal, or `None` for NaN/infinity ("a NaN or infinite + /// member matches no comparison"). Rust's `Display` for `f64` is the shortest round-trip form + /// and - verified by `probe_f64_display_never_uses_exponent_notation` in this module's tests, + /// per the map's instruction not to trust the claim - never emits exponent notation for a finite + /// value, so `expand_scientific_notation` is a no-op on this path today; it stays, because that + /// is a fact about the current standard library, not a language guarantee, and the C# side needs + /// it for real. + pub fn render_f64(value: f64) -> Option { + if value.is_nan() || value.is_infinite() { + return None; + } + Some(canonicalize_decimal_digits(&expand_scientific_notation( + &value.to_string(), + ))) + } + + /// Renders a `f32` as its canonical decimal, or `None` for NaN/infinity. + pub fn render_f32(value: f32) -> Option { + if value.is_nan() || value.is_infinite() { + return None; + } + Some(canonicalize_decimal_digits(&expand_scientific_notation( + &value.to_string(), + ))) + } + + #[cfg(test)] + mod tests { + // The map requires this claim probed, not trusted: Rust's Display for f64 is documented to + // never use scientific notation, unlike .NET's default ToString(). If a future toolchain + // changes this, expand_scientific_notation stops being a no-op and this probe starts failing + // loudly instead of a canonical number silently carrying an 'E'. + #[test] + fn probe_f64_display_never_uses_exponent_notation() { + let huge = 1e21_f64; + let tiny = 1e-7_f64; + let huge_text = huge.to_string(); + let tiny_text = tiny.to_string(); + assert!( + !huge_text.contains(['e', 'E']), + "expected no exponent notation, got {huge_text}" + ); + assert!( + !tiny_text.contains(['e', 'E']), + "expected no exponent notation, got {tiny_text}" + ); + assert_eq!(huge_text, "1000000000000000000000"); + assert_eq!(tiny_text, "0.0000001"); + } + + // Q-J: an i64 past 2^53 (where f64 loses integer precision) renders exactly - this is the + // row-rendering mutant docs/cultnet-selection-cut.md section 2 "Numbers" names: a value + // rendered through float64 would silently become "9007199254740992" here. + #[test] + fn render_i64_past_2_pow_53_is_exact() { + assert_eq!(super::render_i64(9_007_199_254_740_993), "9007199254740993"); + assert_eq!(super::render_i64(-9_007_199_254_740_993), "-9007199254740993"); + assert_eq!(super::render_i64(0), "0"); + } + + #[test] + fn render_u64_is_exact() { + assert_eq!(super::render_u64(18_446_744_073_709_551_615), "18446744073709551615"); + } + + #[test] + fn canonicalize_decimal_digits_strips_leading_and_trailing_zeros() { + assert_eq!(super::canonicalize_decimal_digits("13.500"), "13.5"); + assert_eq!(super::canonicalize_decimal_digits("007"), "7"); + assert_eq!(super::canonicalize_decimal_digits("0.000"), "0"); + assert_eq!(super::canonicalize_decimal_digits("-0.000"), "0"); + } + + #[test] + fn render_f64_rewrites_exponent_notation_to_positional_digits() { + assert_eq!(super::render_f64(1e21).unwrap(), "1000000000000000000000"); + assert_eq!(super::render_f64(1e-7).unwrap(), "0.0000001"); + assert_eq!(super::render_f64(3.5).unwrap(), "3.5"); + } + + // Exercised directly with hand-built exponent-bearing strings, independent of whether + // Display ever actually produces one for f64/f32 (probe_f64_display_never_uses_exponent_notation + // shows it currently does not - this function exists so the algorithm is correct and pinned + // regardless of that upstream fact, mirroring CultCache.cs's ExpandScientificNotation, which + // C#'s ToString() does exercise for real). + #[test] + fn expand_scientific_notation_rewrites_hand_built_exponent_forms() { + assert_eq!(super::expand_scientific_notation("1.5E3"), "1500"); + assert_eq!(super::expand_scientific_notation("1e-7"), "0.0000001"); + assert_eq!(super::expand_scientific_notation("-2.5e2"), "-250"); + assert_eq!(super::expand_scientific_notation("42"), "42"); + } + + #[test] + fn render_f64_and_f32_answer_none_for_nan_and_infinity() { + assert_eq!(super::render_f64(f64::NAN), None); + assert_eq!(super::render_f64(f64::INFINITY), None); + assert_eq!(super::render_f32(f32::NAN), None); + assert_eq!(super::render_f32(f32::NEG_INFINITY), None); + } + } +} + +// --------------------------------------------------------------------------------------------- +// Wire types (section 2) +// --------------------------------------------------------------------------------------------- + +/// The four comparison and one any-of operator a field predicate may carry. `FieldPredicate::op` is +/// the wire's raw string (never a serde-tagged union); this enum exists for evaluator ergonomics +/// only, mirroring `CultNetSelectionOperator`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SelectionOperator { + AnyOf, + Lt, + Le, + Ge, + Gt, +} + +impl SelectionOperator { + /// Parses a wire operator string; `None` for anything else. + pub fn parse(wire: &str) -> Option { + match wire { + "any_of" => Some(Self::AnyOf), + "lt" => Some(Self::Lt), + "le" => Some(Self::Le), + "ge" => Some(Self::Ge), + "gt" => Some(Self::Gt), + _ => None, + } + } + + /// The wire spelling of this operator. + pub fn as_wire_str(self) -> &'static str { + match self { + Self::AnyOf => "any_of", + Self::Lt => "lt", + Self::Le => "le", + Self::Ge => "ge", + Self::Gt => "gt", + } + } + + /// `true` for the four comparison operators (everything but `any_of`). + pub fn is_comparison(self) -> bool { + !matches!(self, Self::AnyOf) + } +} + +/// `"header"` under [`Selection::projection`]. +pub const PROJECTION_HEADER: &str = "header"; +/// `"document"` under [`Selection::projection`]. +pub const PROJECTION_DOCUMENT: &str = "document"; + +fn default_projection() -> String { + PROJECTION_HEADER.to_string() +} + +/// Identifies one row on the wire by schema and record key. +#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RecordRef { + pub schema_id: String, + pub record_key: String, +} + +impl RecordRef { + pub fn new(schema_id: impl Into, record_key: impl Into) -> Self { + Self { + schema_id: schema_id.into(), + record_key: record_key.into(), + } + } +} + +/// One predicate over a single declared index alias (section 2). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FieldPredicate { + /// A declared index alias, reachable on some schema the selection can reach. + pub index: String, + /// `"any_of"`, `"lt"`, `"le"`, `"ge"`, or `"gt"`. + pub op: String, + /// Present iff `op = any_of`; non-empty. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub values: Option>, + /// Present iff `op` is a comparison; a canonical decimal string (Q-J). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub number: Option, +} + +/// Selects rows whose declared reference names a target row. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Citation { + pub target: RecordRef, + /// The reference's declared alias; absent matches any declared reference. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub role: Option, +} + +/// Selects rows some other row does or does not name in `role` - the one negation. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Incoming { + pub role: String, + pub exists: bool, +} + +/// One typed selection, carried by `cultnet.snapshot_request.v1` and +/// `cultnet.database_subscribe.v1` (section 2). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Selection { + /// Kinds: schema ids. Absent = every schema. (No alias matching in this runtime - D7: Rust's + /// row owner has no schema-alias registry; a caller wanting an alias expands it before calling.) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub schemas: Option>, + /// Record-key allowlist. Absent = every key. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub keys: Option>, + /// Conjunction; each is any-of over one declared index alias. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fields: Option>, + /// Rows whose declared reference names `target`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cites: Option, + /// Rows some row does or does not name in `role`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cited: Option, + /// `"header"` or `"document"`; default `"header"`. + #[serde(default = "default_projection")] + pub projection: String, + /// Reverses the one order. + #[serde(default)] + pub descending: bool, + /// Clamped 1..=200. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, + /// Opaque; minted by the answering server. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + +impl Default for Selection { + fn default() -> Self { + Self { + schemas: None, + keys: None, + fields: None, + cites: None, + cited: None, + projection: default_projection(), + descending: false, + limit: None, + cursor: None, + } + } +} + +impl Selection { + /// `true` when this selection follows an edge (section 2: "Edges in the answer"). + pub fn has_hop(&self) -> bool { + self.cites.is_some() || self.cited.is_some() + } +} + +/// One edge a hop traversed, carried on a selection page. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Edge { + /// The citing row. + pub from: RecordRef, + /// The declared reference alias the citer used. + pub role: String, + /// The cited row, its schema resolved to the leaf actually stored. + pub to: RecordRef, + /// `"messagepack"` when `payload` is present. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub payload_encoding: Option, + /// The value attached to the edge (a dictionary reference's value); present under `document` + /// projection only. + #[serde(default, skip_serializing_if = "Option::is_none", with = "optional_bytes")] + pub payload: Option>, +} + +/// `serde_bytes` has no `Option>` submodule; this is the same MessagePack `bin`/`nil` +/// encoding, spelled for the optional case. +mod optional_bytes { + use serde::{Deserialize, Deserializer, Serializer}; + use serde_bytes::{ByteBuf, Bytes}; + + pub fn serialize(value: &Option>, serializer: S) -> Result { + match value { + Some(bytes) => serializer.serialize_some(Bytes::new(bytes)), + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result>, D::Error> { + Ok(Option::::deserialize(deserializer)?.map(ByteBuf::into_vec)) + } +} + +/// A header record: [`SelectionDocumentRecord`] minus its payload and payload encoding. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RawDocumentHeader { + pub schema_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub schema_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub schema_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub schema_content_hash: Option, + pub record_key: String, + pub stored_at: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_runtime_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_agent_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_role: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tags: Option>, +} + +/// The full record `cultnet.snapshot_response_raw.v1` carries under `document` projection. Distinct +/// from `contracts::CultNetRawDocumentRecord` (the v0 raw record), which lacks `schemaName`/ +/// `schemaVersion`/`schemaContentHash` - a pre-existing gap in the v0 Rust struct relative to the C# +/// reference's `CultNetRawDocumentRecord` that this cut does not touch (out of scope: v0's wire is +/// unchanged). v1 reuses the reference's richer, already-existing C# type, so this Rust type matches +/// that one, not v0's. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SelectionDocumentRecord { + pub schema_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub schema_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub schema_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub schema_content_hash: Option, + pub record_key: String, + pub stored_at: String, + #[serde(default = "messagepack_encoding")] + pub payload_encoding: String, + #[serde(with = "serde_bytes")] + pub payload: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_runtime_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_agent_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_role: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tags: Option>, +} + +fn messagepack_encoding() -> String { + "messagepack".to_string() +} + +impl RawDocumentHeader { + /// Builds a header by dropping a document record's payload. + pub fn from_document(document: &SelectionDocumentRecord) -> Self { + Self { + schema_id: document.schema_id.clone(), + schema_name: document.schema_name.clone(), + schema_version: document.schema_version.clone(), + schema_content_hash: document.schema_content_hash.clone(), + record_key: document.record_key.clone(), + stored_at: document.stored_at.clone(), + source_runtime_id: document.source_runtime_id.clone(), + source_agent_id: document.source_agent_id.clone(), + source_role: document.source_role.clone(), + tags: document.tags.clone(), + } + } +} + +// --------------------------------------------------------------------------------------------- +// Refusals (section 2/3, typed at the door) +// --------------------------------------------------------------------------------------------- + +/// Refused typed at the door: a name, list, or comparison the declared shape cannot support. +/// Mirrors `CultNetSelectionInvalidException`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SelectionInvalid { + /// The selection field that failed (`"schemas"`, `"keys"`, `"fields[i].index"`, ...). + pub field: String, + /// The offending value, when there is one string worth naming. + pub value: Option, + pub message: String, +} + +impl SelectionInvalid { + fn new(field: impl Into, value: Option, message: impl Into) -> Self { + Self { + field: field.into(), + value, + message: message.into(), + } + } +} + +impl fmt::Display for SelectionInvalid { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "selection_invalid[{}]: {}", self.field, self.message) + } +} + +impl std::error::Error for SelectionInvalid {} + +/// Every way `select`/`matches` can refuse rather than answer (section 2/3/8). +#[derive(Clone, Debug, PartialEq)] +pub enum SelectionRefusal { + Invalid(SelectionInvalid), + /// The cursor's `asOf` does not match the snapshot being answered. + CursorStale { as_of: u64, current: u64 }, + /// The cursor does not decode, or its digest is not this selection's. + CursorInvalid { message: String }, + /// A stored edge names a row whose schema is outside the reference's declared target (D9/S18). + ReferenceOutsideTarget { + from_schema_id: String, + from_key: String, + role: String, + to_schema_id: String, + to_key: String, + }, +} + +impl fmt::Display for SelectionRefusal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Invalid(inner) => write!(f, "{inner}"), + Self::CursorStale { as_of, current } => write!( + f, + "cursor_stale: the cursor was minted at asOf {as_of}; this server answers as of {current}." + ), + Self::CursorInvalid { message } => write!(f, "cursor_invalid: {message}"), + Self::ReferenceOutsideTarget { + from_schema_id, + from_key, + role, + to_schema_id, + to_key, + } => write!( + f, + "reference_outside_target: {from_schema_id}/{from_key} names {to_schema_id}/{to_key} through role \"{role}\", which is outside the reference's declared target." + ), + } + } +} + +impl std::error::Error for SelectionRefusal {} + +impl From for SelectionRefusal { + fn from(value: SelectionInvalid) -> Self { + Self::Invalid(value) + } +} + +// --------------------------------------------------------------------------------------------- +// Row / RowSet (D7): the consumer's declarations and values, reflected over nothing +// --------------------------------------------------------------------------------------------- + +/// One row available to the evaluator: what the row owner already knows about it. Implemented by +/// the consumer (Huginn, or a test fixture) - the Rust spelling of what `CultCache`'s public read +/// surface supplies the C# evaluator. +pub trait Row { + fn schema_id(&self) -> &str; + fn record_key(&self) -> &str; + /// The sequence of the commit that last wrote this row. The row owner supplies it; the + /// evaluator never reads a clock. + fn ordinal(&self) -> i64; + /// Every declared value at `index` on this row (usually zero or one; `any_of` matches + /// membership, matching the cache's own string-valued indexes). + fn values(&self, index: &str) -> Vec; + /// This row's declared numeric value at `index`, as its exact canonical decimal (Q-J) - never + /// `f64`. `None` when the row has no value, or the value is NaN/infinite. + fn number(&self, index: &str) -> Option; + /// Every declared reference edge this row carries: `(role, target, payload)`. A one-reference + /// yields one entry, a many-reference one per element, a dictionary reference one per key with + /// its value as the payload (D11). + fn references(&self) -> Vec<(String, RecordRef, Option>)>; +} + +impl Row for &T { + fn schema_id(&self) -> &str { + (**self).schema_id() + } + fn record_key(&self) -> &str { + (**self).record_key() + } + fn ordinal(&self) -> i64 { + (**self).ordinal() + } + fn values(&self, index: &str) -> Vec { + (**self).values(index) + } + fn number(&self, index: &str) -> Option { + (**self).number(index) + } + fn references(&self) -> Vec<(String, RecordRef, Option>)> { + (**self).references() + } +} + +/// The consumer's schema-level declarations: what `select`/`validate` need to know about the shape +/// of the rows, independent of any one row's values. +pub trait RowSet { + /// Every schema id this row set knows about - the universe `selection.schemas` narrows. + fn all_schema_ids(&self) -> Vec; + /// The declared index aliases a schema carries (inherited or own). + fn declared_indexes(&self, schema_id: &str) -> Vec; + /// The declared reference roles a schema carries. + fn declared_roles(&self, schema_id: &str) -> Vec; + /// `true` when `index` is declared numeric on `schema_id`. + fn is_numeric(&self, schema_id: &str, index: &str) -> bool; + /// The schema ids a reference named `role` may target. An empty result means the role carries + /// no restriction the hop must enforce (mirrors C#'s `TargetType == null` skipping the check) - + /// a `RowSet` with a real, closed target set returns it non-empty. + fn target_leaves(&self, role: &str) -> Vec; +} + +// --------------------------------------------------------------------------------------------- +// Validation (S1, the door) +// --------------------------------------------------------------------------------------------- + +/// Validates a [`Selection`] against the declared shape of the schemas it can reach - the door +/// refusals of section 2/3 (S1). Mirrors `CultNetSelectionValidation.Validate`. +pub fn validate(selection: &Selection, row_set: &impl RowSet) -> Result<(), SelectionInvalid> { + if matches!(&selection.schemas, Some(schemas) if schemas.is_empty()) { + return Err(SelectionInvalid::new( + "schemas", + None, + "selection.schemas is present and empty; omit it to reach every schema.", + )); + } + if matches!(&selection.keys, Some(keys) if keys.is_empty()) { + return Err(SelectionInvalid::new( + "keys", + None, + "selection.keys is present and empty; omit it to reach every key.", + )); + } + // Self's ruling, 2026-09-22 (docs/cultnet-selection-cut.md, commit 2 fix batch): section 2 + // already said an empty list is refused at the door; the door must refuse a blank entry + // inside a present list too, so the evaluator never has to decide what "" or " " means. + // `null` (Rust: `None`) means no filter, everywhere - an empty-or-blank list is never + // silently treated as "no filter" by the evaluator (that was CultNetSelectionEvaluator.cs's + // pre-fix bug, section 2's S2-3 finding, and this Rust evaluator never had it). + if let Some(schemas) = &selection.schemas { + for (index, schema) in schemas.iter().enumerate() { + if schema.trim().is_empty() { + return Err(SelectionInvalid::new( + format!("schemas[{index}]"), + Some(schema.clone()), + format!("schemas[{index}] is empty or whitespace."), + )); + } + } + } + if let Some(keys) = &selection.keys { + for (index, key) in keys.iter().enumerate() { + if key.trim().is_empty() { + return Err(SelectionInvalid::new( + format!("keys[{index}]"), + Some(key.clone()), + format!("keys[{index}] is empty or whitespace."), + )); + } + } + } + if selection.projection != PROJECTION_HEADER && selection.projection != PROJECTION_DOCUMENT { + return Err(SelectionInvalid::new( + "projection", + Some(selection.projection.clone()), + format!( + "selection.projection {:?} is neither \"header\" nor \"document\".", + selection.projection + ), + )); + } + + let reachable = reachable_schemas(selection, row_set); + + if let Some(fields) = &selection.fields { + for (index, field) in fields.iter().enumerate() { + validate_field(field, index, &reachable, row_set)?; + } + } + + if let Some(cites) = &selection.cites { + if cites.target.schema_id.is_empty() || cites.target.record_key.is_empty() { + return Err(SelectionInvalid::new( + "cites.target", + None, + "selection.cites.target requires schemaId and recordKey.", + )); + } + if let Some(role) = &cites.role + && !any_schema_declares_role(row_set, role) + { + return Err(SelectionInvalid::new( + "cites.role", + Some(role.clone()), + format!("selection.cites.role {role:?} is not declared by any schema."), + )); + } + } + + if let Some(cited) = &selection.cited { + if cited.role.is_empty() { + return Err(SelectionInvalid::new( + "cited.role", + None, + "selection.cited.role must be non-empty.", + )); + } + if !any_schema_declares_role(row_set, &cited.role) { + return Err(SelectionInvalid::new( + "cited.role", + Some(cited.role.clone()), + format!( + "selection.cited.role {:?} is not declared by any schema.", + cited.role + ), + )); + } + } + + Ok(()) +} + +/// The schemas [`Selection::schemas`] reaches - every schema when absent. +pub fn reachable_schemas(selection: &Selection, row_set: &impl RowSet) -> Vec { + let all = row_set.all_schema_ids(); + match &selection.schemas { + None => all, + Some(schemas) => all + .into_iter() + .filter(|id| schemas.iter().any(|s| s == id)) + .collect(), + } +} + +fn any_schema_declares_role(row_set: &impl RowSet, role: &str) -> bool { + row_set + .all_schema_ids() + .iter() + .any(|schema_id| row_set.declared_roles(schema_id).iter().any(|r| r == role)) +} + +fn validate_field( + field: &FieldPredicate, + index: usize, + reachable: &[String], + row_set: &impl RowSet, +) -> Result<(), SelectionInvalid> { + let prefix = format!("fields[{index}]"); + if field.index.is_empty() { + return Err(SelectionInvalid::new( + format!("{prefix}.index"), + None, + format!("{prefix}.index must be non-empty."), + )); + } + let Some(op) = SelectionOperator::parse(&field.op) else { + return Err(SelectionInvalid::new( + format!("{prefix}.op"), + Some(field.op.clone()), + format!("{prefix}.op {:?} is not any_of/lt/le/ge/gt.", field.op), + )); + }; + + if op == SelectionOperator::AnyOf { + match &field.values { + None => { + return Err(SelectionInvalid::new( + format!("{prefix}.values"), + None, + format!("{prefix}.values must be non-empty for op any_of."), + )); + } + Some(values) if values.is_empty() => { + return Err(SelectionInvalid::new( + format!("{prefix}.values"), + None, + format!("{prefix}.values must be non-empty for op any_of."), + )); + } + _ => {} + } + if field.number.is_some() { + return Err(SelectionInvalid::new( + format!("{prefix}.number"), + None, + format!("{prefix} carries both values and number; any_of takes only values."), + )); + } + } else { + match &field.number { + None => { + return Err(SelectionInvalid::new( + format!("{prefix}.number"), + None, + format!("{prefix}.number is required for op {}.", field.op), + )); + } + Some(number) if !canonical_number::is_canonical(number) => { + return Err(SelectionInvalid::new( + format!("{prefix}.number"), + Some(number.clone()), + format!( + "{prefix}.number {number:?} is not a canonical decimal string (Q-J): it must match {} and not be \"-0\".", + r"^-?(0|[1-9][0-9]*)(\.[0-9]*[1-9])?$" + ), + )); + } + _ => {} + } + if field.values.is_some() { + return Err(SelectionInvalid::new( + format!("{prefix}.values"), + None, + format!("{prefix} carries both values and number; a comparison takes only number."), + )); + } + } + + let declaring: Vec<&String> = reachable + .iter() + .filter(|schema_id| { + row_set + .declared_indexes(schema_id) + .iter() + .any(|alias| alias == &field.index) + }) + .collect(); + if declaring.is_empty() { + return Err(SelectionInvalid::new( + format!("{prefix}.index"), + Some(field.index.clone()), + format!( + "{prefix}.index {:?} is not declared by any schema this selection can reach.", + field.index + ), + )); + } + if op.is_comparison() { + for schema_id in &declaring { + if !row_set.is_numeric(schema_id, &field.index) { + return Err(SelectionInvalid::new( + format!("{prefix}.index"), + Some(field.index.clone()), + format!( + "{prefix}.index {:?} is declared non-numeric on a schema this selection can reach; comparisons need a numeric declaration everywhere the alias is reachable.", + field.index + ), + )); + } + } + } + + Ok(()) +} + +// --------------------------------------------------------------------------------------------- +// Evaluation (D6/D7: the single-row fast path and the full Select) +// --------------------------------------------------------------------------------------------- + +/// The lowest and highest limit a page may carry (section 2). +pub const LIMIT_MIN: u32 = 1; +pub const LIMIT_MAX: u32 = 200; + +/// The single-row fast path (D6): schemas, keys and fields only. A hop-bearing selection +/// ([`Selection::has_hop`]) is set-dependent and must not use this path - the caller reconciles the +/// full row set with [`select`] instead. +pub fn matches(row: &R, selection: &Selection) -> bool { + debug_assert!( + !selection.has_hop(), + "a hop-bearing selection (cites/cited) is set-dependent and cannot use the single-row \ + matches fast path; reconcile the full row set with select() instead" + ); + matches_schema_keys_fields(row, selection) +} + +fn matches_schema_keys_fields(row: &R, selection: &Selection) -> bool { + if let Some(schemas) = &selection.schemas + && !schemas.iter().any(|s| s == row.schema_id()) + { + return false; + } + if let Some(keys) = &selection.keys + && !keys.iter().any(|k| k == row.record_key()) + { + return false; + } + let Some(fields) = &selection.fields else { + return true; + }; + + for field in fields { + let Some(op) = SelectionOperator::parse(&field.op) else { + return false; + }; + if op == SelectionOperator::AnyOf { + let values = row.values(&field.index); + let Some(wanted) = &field.values else { + return false; + }; + if !values.iter().any(|v| wanted.iter().any(|w| w == v)) { + return false; + } + } else { + let Some(number) = row.number(&field.index) else { + return false; + }; + let Some(compared) = &field.number else { + return false; + }; + if !compare_number(&number, op, compared) { + return false; + } + } + } + true +} + +fn compare_number(left: &str, op: SelectionOperator, right: &str) -> bool { + let ordering = canonical_number::compare(left, right); + match op { + SelectionOperator::Lt => ordering == Ordering::Less, + SelectionOperator::Le => ordering != Ordering::Greater, + SelectionOperator::Ge => ordering != Ordering::Less, + SelectionOperator::Gt => ordering == Ordering::Greater, + SelectionOperator::AnyOf => unreachable!("any_of never reaches compare_number"), + } +} + +/// One edge a hop traversed, resolved against the row set in hand. +#[derive(Clone, Debug)] +pub struct EdgeMatch { + pub from: R, + pub role: String, + pub to: R, + pub payload: Option>, +} + +/// Evaluated, ordered, paged rows plus the edges a hop traversed. +#[derive(Clone, Debug)] +pub struct Evaluation { + pub rows: Vec, + pub edges: Vec>, + pub next_cursor: Option, +} + +/// Evaluates a selection over the full row set: schemas/keys/fields, the hop (`cites`/`cited`, with +/// its edges and the `reference_outside_target` refusal), order, cursor and paging. Mirrors +/// `CultNetSelectionEvaluator.Select`. +pub fn select( + row_set: &impl RowSet, + all_rows: &[R], + selection: &Selection, + as_of: u64, +) -> Result, SelectionRefusal> { + let by_key: HashMap<&str, &R> = all_rows.iter().map(|row| (row.record_key(), row)).collect(); + + let mut candidates: Vec<&R> = all_rows + .iter() + .filter(|row| matches_schema_keys_fields(*row, selection)) + .collect(); + + let mut edges: Vec> = Vec::new(); + + if let Some(cited) = &selection.cited { + let incoming = build_incoming_index(row_set, &by_key, &cited.role, &mut edges)?; + let exists = cited.exists; + candidates.retain(|row| incoming.contains(row.record_key()) == exists); + } + + if let Some(cites) = &selection.cites { + let mut kept = Vec::with_capacity(candidates.len()); + for row in candidates { + if matches_citation(row_set, &by_key, row, cites, &mut edges)? { + kept.push(row); + } + } + candidates = kept; + } + + let mut matched = candidates; + matched.sort_by(|a, b| order_rows(*a, *b, selection.descending)); + + let mut start_index = 0usize; + if let Some(cursor_text) = selection.cursor.as_deref() + && !cursor_text.is_empty() + { + let cursor = Cursor::parse(cursor_text)?; + if cursor.digest != Cursor::compute_digest(selection) { + return Err(SelectionRefusal::CursorInvalid { + message: "The cursor's selection digest does not match this selection.".into(), + }); + } + if cursor.as_of != as_of { + return Err(SelectionRefusal::CursorStale { + as_of: cursor.as_of, + current: as_of, + }); + } + start_index = find_cursor_position(&matched, &cursor, selection.descending); + } + + let limit = selection + .limit + .unwrap_or(LIMIT_MAX) + .clamp(LIMIT_MIN, LIMIT_MAX) as usize; + let page: Vec = matched + .iter() + .skip(start_index) + .take(limit) + .map(|row| (*row).clone()) + .collect(); + let has_next = start_index + page.len() < matched.len(); + let next_cursor = if has_next && !page.is_empty() { + Some(Cursor::mint(as_of, page.last().expect("checked non-empty"), selection)) + } else { + None + }; + + let page_keys: HashSet<&str> = page.iter().map(|row| row.record_key()).collect(); + let page_edges = if selection.has_hop() { + edges + .into_iter() + .filter(|edge| page_keys.contains(edge.from.record_key())) + .collect() + } else { + Vec::new() + }; + + Ok(Evaluation { + rows: page, + edges: page_edges, + next_cursor, + }) +} + +fn order_rows(a: &R, b: &R, descending: bool) -> Ordering { + let ordering = a + .ordinal() + .cmp(&b.ordinal()) + .then_with(|| a.schema_id().cmp(b.schema_id())) + .then_with(|| a.record_key().cmp(b.record_key())); + if descending { ordering.reverse() } else { ordering } +} + +fn find_cursor_position(ordered: &[R], cursor: &Cursor, descending: bool) -> usize { + for (index, row) in ordered.iter().enumerate() { + let position = compare_position(row, cursor); + let compares_after = if descending { + position == Ordering::Less + } else { + position == Ordering::Greater + }; + if compares_after { + return index; + } + } + ordered.len() +} + +fn compare_position(row: &R, cursor: &Cursor) -> Ordering { + row.ordinal() + .cmp(&cursor.ordinal) + .then_with(|| row.schema_id().cmp(cursor.schema_id.as_str())) + .then_with(|| row.record_key().cmp(cursor.record_key.as_str())) +} + +// D9: a reference's target set is every schema `RowSet::target_leaves` names for that role. A +// stored edge naming a row whose schema is outside that set refuses the selection (S18). +fn ensure_within_declared_target( + row_set: &impl RowSet, + role: &str, + from: &R, + to: &R, +) -> Result<(), SelectionRefusal> { + let leaves = row_set.target_leaves(role); + if leaves.is_empty() || leaves.iter().any(|leaf| leaf == to.schema_id()) { + return Ok(()); + } + Err(SelectionRefusal::ReferenceOutsideTarget { + from_schema_id: from.schema_id().to_string(), + from_key: from.record_key().to_string(), + role: role.to_string(), + to_schema_id: to.schema_id().to_string(), + to_key: to.record_key().to_string(), + }) +} + +fn matches_citation( + row_set: &impl RowSet, + by_key: &HashMap<&str, &R>, + citer: &R, + citation: &Citation, + edge_sink: &mut Vec>, +) -> Result { + let mut found = false; + for (role, target, payload) in citer.references() { + if let Some(wanted_role) = &citation.role + && *wanted_role != role + { + continue; + } + if target.record_key != citation.target.record_key { + continue; + } + let Some(resolved) = by_key.get(target.record_key.as_str()) else { + continue; + }; + ensure_within_declared_target(row_set, &role, citer, *resolved)?; + if resolved.schema_id() != citation.target.schema_id { + continue; + } + edge_sink.push(EdgeMatch { + from: citer.clone(), + role, + to: (*resolved).clone(), + payload, + }); + found = true; + } + Ok(found) +} + +fn build_incoming_index( + row_set: &impl RowSet, + by_key: &HashMap<&str, &R>, + role: &str, + edge_sink: &mut Vec>, +) -> Result, SelectionRefusal> { + let mut incoming = HashSet::new(); + for citer in by_key.values() { + for (edge_role, target, payload) in citer.references() { + if edge_role != role { + continue; + } + let Some(resolved) = by_key.get(target.record_key.as_str()) else { + continue; + }; + ensure_within_declared_target(row_set, &edge_role, *citer, *resolved)?; + incoming.insert(target.record_key.clone()); + edge_sink.push(EdgeMatch { + from: (*citer).clone(), + role: edge_role, + to: (*resolved).clone(), + payload, + }); + } + } + Ok(incoming) +} + +// --------------------------------------------------------------------------------------------- +// Cursor (section 2: "Snapshot") +// --------------------------------------------------------------------------------------------- + +/// The opaque cursor: `asOf`, the last `(ordinal, schemaId, recordKey)`, and a digest of the +/// selection with `cursor`/`limit` cleared. Minted only by the answering server. This runtime's own +/// mint/parse/digest algorithm is internal to it - a cursor is never handed across runtimes (each +/// server answers its own pages), so it need not (and does not) byte-match the C# reference's; the +/// *rule* it enforces (section 2 "Snapshot") does. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Cursor { + pub as_of: u64, + pub ordinal: i64, + pub schema_id: String, + pub record_key: String, + pub digest: String, +} + +const CURSOR_FIELD_SEPARATOR: char = '\u{1}'; +const DIGEST_FIELD_SEPARATOR: char = '\u{2}'; + +impl Cursor { + pub fn mint(as_of: u64, last_row: &R, selection: &Selection) -> String { + let digest = Self::compute_digest(selection); + let raw = [ + as_of.to_string(), + last_row.ordinal().to_string(), + last_row.schema_id().to_string(), + last_row.record_key().to_string(), + digest, + ] + .join(&CURSOR_FIELD_SEPARATOR.to_string()); + URL_SAFE_NO_PAD.encode(raw.as_bytes()) + } + + pub fn parse(cursor: &str) -> Result { + let invalid = || SelectionRefusal::CursorInvalid { + message: "The cursor does not decode.".into(), + }; + let bytes = URL_SAFE_NO_PAD.decode(cursor).map_err(|_| invalid())?; + let raw = String::from_utf8(bytes).map_err(|_| invalid())?; + let parts: Vec<&str> = raw.split(CURSOR_FIELD_SEPARATOR).collect(); + if parts.len() != 5 { + return Err(invalid()); + } + let as_of: u64 = parts[0].parse().map_err(|_| invalid())?; + let ordinal: i64 = parts[1].parse().map_err(|_| invalid())?; + Ok(Self { + as_of, + ordinal, + schema_id: parts[2].to_string(), + record_key: parts[3].to_string(), + digest: parts[4].to_string(), + }) + } + + /// A digest of the selection with `cursor` and `limit` cleared, so a cursor is bound to the + /// selection that minted it. + pub fn compute_digest(selection: &Selection) -> String { + let mut sorted_schemas = selection.schemas.clone().unwrap_or_default(); + sorted_schemas.sort(); + let mut sorted_keys = selection.keys.clone().unwrap_or_default(); + sorted_keys.sort(); + + let fields_text = selection + .fields + .as_deref() + .unwrap_or_default() + .iter() + .map(|field| { + format!( + "{}:{}:{}:{}", + field.index, + field.op, + field.values.as_deref().unwrap_or_default().join("|"), + field.number.as_deref().unwrap_or_default() + ) + }) + .collect::>() + .join(";"); + + let cites_text = selection + .cites + .as_ref() + .map(|cites| { + format!( + "{}/{}:{}", + cites.target.schema_id, + cites.target.record_key, + cites.role.as_deref().unwrap_or_default() + ) + }) + .unwrap_or_default(); + + let cited_text = selection + .cited + .as_ref() + .map(|cited| format!("{}:{}", cited.role, cited.exists)) + .unwrap_or_default(); + + let canonical = [ + sorted_schemas.join(","), + sorted_keys.join(","), + fields_text, + cites_text, + cited_text, + selection.projection.clone(), + selection.descending.to_string(), + ] + .join(&DIGEST_FIELD_SEPARATOR.to_string()); + + let mut hasher = Sha256::new(); + hasher.update(canonical.as_bytes()); + hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() + } +} diff --git a/packages/cultnet-rs/src/snapshot_query.rs b/packages/cultnet-rs/src/snapshot_query.rs index f6150d1d..bde654d0 100644 --- a/packages/cultnet-rs/src/snapshot_query.rs +++ b/packages/cultnet-rs/src/snapshot_query.rs @@ -1,7 +1,7 @@ use anyhow::{Result, anyhow}; use std::collections::{BTreeMap, BTreeSet}; -use crate::{CultNetDocumentRegistry, CultNetMessage, CultNetRawDocumentRecord}; +use crate::{CultNetDocumentRegistry, CultNetMessage, CultNetRawDocumentRecord, RecordRef, Row, Selection}; /// Read-only backing surface for a CultNet snapshot server. /// @@ -76,15 +76,25 @@ pub fn serve_read_only_raw_snapshot( return Err(anyhow!("expected cultnet.snapshot_request.v0")); }; require_non_empty(message_id, "message_id")?; + // Self's ruling, 2026-09-22 (docs/cultnet-selection-cut.md, commit 2 fix batch): the door + // refuses an empty or blank entry in a *present* Selection list, but v0's own lowering keeps + // v0's old cleaning - an empty v0 list, or one made only of blanks, lowers to `null` (no + // filter) rather than reaching the door as something to refuse. That is a v0 compatibility + // rule the lowering owns, not a meaning the evaluator or the door carries for v1 lists. + let schema_ids = lower_v0_list(schema_ids); + let record_keys = lower_v0_list(record_keys); reject_duplicates(schema_ids.as_deref(), "requested schema id")?; reject_duplicates(record_keys.as_deref(), "requested record key")?; - let requested_schemas = schema_ids - .as_ref() - .map(|values| values.iter().map(String::as_str).collect::>()); - let requested_keys = record_keys - .as_ref() - .map(|values| values.iter().map(String::as_str).collect::>()); + // D3/D4's v0 lowering (docs/cultnet-selection-cut.md section 4): the two allowlists are not a + // selector of their own, they are cultnet.snapshot_request.v0 lowered into a Selection with no + // fields/cites/cited, matched through the one evaluator (selection::matches) instead of a fifth + // hand-rolled copy of "is this schema/key requested". + let selection = Selection { + schemas: schema_ids.clone(), + keys: record_keys.clone(), + ..Selection::default() + }; let mut selected = Vec::new(); let mut seen = BTreeSet::new(); @@ -110,16 +120,7 @@ pub fn serve_read_only_raw_snapshot( if !policy.allows(&document.schema_id, &document.record_key) { continue; } - if requested_schemas - .as_ref() - .is_some_and(|schemas| !schemas.contains(document.schema_id.as_str())) - { - continue; - } - if requested_keys - .as_ref() - .is_some_and(|keys| !keys.contains(document.record_key.as_str())) - { + if !crate::matches(&RawSnapshotRow(&document), &selection) { continue; } selected.push(document); @@ -131,6 +132,34 @@ pub fn serve_read_only_raw_snapshot( }) } +/// A `CultNetRawDocumentRecord` carries only identity (schema id, record key) at this layer - no +/// declared index values, no ordinal, no reference edges, because it is already-serialized bytes +/// from a read-only source, not a live document. The v0 lowering above never sets `fields`, `cites` +/// or `cited`, so `matches_schema_keys_fields` never calls `ordinal`/`values`/`number`/`references` +/// on this row; the unreachable stubs exist only to satisfy the `Row` trait's shape. +struct RawSnapshotRow<'a>(&'a CultNetRawDocumentRecord); + +impl Row for RawSnapshotRow<'_> { + fn schema_id(&self) -> &str { + &self.0.schema_id + } + fn record_key(&self) -> &str { + &self.0.record_key + } + fn ordinal(&self) -> i64 { + unreachable!("v0's lowered selection carries no fields/cites/cited; ordinal is not read") + } + fn values(&self, _index: &str) -> Vec { + unreachable!("v0's lowered selection carries no fields; values is not read") + } + fn number(&self, _index: &str) -> Option { + unreachable!("v0's lowered selection carries no fields; number is not read") + } + fn references(&self) -> Vec<(String, RecordRef, Option>)> { + unreachable!("v0's lowered selection carries no cites/cited; references is not read") + } +} + #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct CultNetSnapshotSourceExpectation { pub runtime_id: Option, @@ -337,3 +366,16 @@ fn require_non_empty(value: &str, field: &str) -> Result<()> { } Ok(()) } + +/// v0's own cleaning (Self's ruling, 2026-09-22): an absent list, an empty list, or a list made +/// only of blank entries all lower to `None` (no filter) - a v0 compatibility rule the lowering +/// owns, not a meaning the door or the evaluator carries for v1's own lists (those refuse `[]` +/// and any blank entry outright, in `selection::validate`). +fn lower_v0_list(list: &Option>) -> Option> { + match list { + Some(values) if !values.is_empty() && values.iter().any(|value| !value.trim().is_empty()) => { + Some(values.clone()) + } + _ => None, + } +} diff --git a/packages/cultnet-rs/tests/cultnet.rs b/packages/cultnet-rs/tests/cultnet.rs index c63faa07..478682fa 100644 --- a/packages/cultnet-rs/tests/cultnet.rs +++ b/packages/cultnet-rs/tests/cultnet.rs @@ -18,6 +18,9 @@ use cultnet_rs::CultNetDocumentPutOptions; use cultnet_rs::CultNetDocumentRegistry; use cultnet_rs::CultNetMessage; use cultnet_rs::CultNetMutationAuthority; +use cultnet_rs::CultNetRawDocumentRecord; +use cultnet_rs::CultNetRawPayloadEncoding; +use cultnet_rs::CultNetReadOnlySnapshotPolicy; use cultnet_rs::CultNetReactiveDocumentOptions; use cultnet_rs::CultNetReconnectController; use cultnet_rs::CultNetReconnectPolicyOptions; @@ -43,6 +46,7 @@ use cultnet_rs::CultNetTransportDescriptor; use cultnet_rs::CultNetTransportFrame; use cultnet_rs::CultNetTransportOrdering; use cultnet_rs::CultNetTransportProfile; +use cultnet_rs::serve_read_only_raw_snapshot; use cultnet_rs::CultNetTransportProtocol; use cultnet_rs::CultNetWireContract; use cultnet_rs::LengthPrefixedMessageFramer; @@ -2034,6 +2038,69 @@ fn raw_snapshot_replication_preserves_messagepack_payload_bytes() -> Result<()> Ok(()) } +// Self's ruling, 2026-09-22 (docs/cultnet-selection-cut.md, commit 2 fix batch): v0's own +// cleaning - an empty schema_ids/record_keys list, or one made only of blanks, lowers to `null` +// (no filter) rather than reaching the door as something to refuse or the evaluator as "match +// nothing". A v1 Selection with the same shape is refused outright (validation_refuses_a_blank_entry_in_schemas_or_keys, +// validation_refuses_undeclared_index_role_and_empty_lists in tests/selection.rs) - this is v0's +// own compatibility rule, owned by the lowering in snapshot_query.rs, not a meaning v1 carries. +#[test] +fn serve_read_only_raw_snapshot_lowers_an_empty_or_blank_v0_schema_list_to_no_filter() -> Result<()> +{ + let mut registry = CultNetDocumentRegistry::new(); + registry.register(CultNetDocumentBinding::for_entry_with_schema_id::< + GhostlightAgentStateFixture, + >("ghostlight.agent-state".to_string(), None)); + + let mut policy = CultNetReadOnlySnapshotPolicy::new(); + policy.allow("ghostlight.agent-state", "row-1")?; + policy.allow("ghostlight.agent-state", "row-2")?; + + let source = vec![ + CultNetRawDocumentRecord { + schema_id: "ghostlight.agent-state".to_string(), + record_key: "row-1".to_string(), + stored_at: "now".to_string(), + payload_encoding: CultNetRawPayloadEncoding::Messagepack, + payload: vec![1, 2, 3], + source_runtime_id: None, + source_agent_id: None, + source_role: None, + tags: None, + }, + CultNetRawDocumentRecord { + schema_id: "ghostlight.agent-state".to_string(), + record_key: "row-2".to_string(), + stored_at: "now".to_string(), + payload_encoding: CultNetRawPayloadEncoding::Messagepack, + payload: vec![4, 5, 6], + source_runtime_id: None, + source_agent_id: None, + source_role: None, + tags: None, + }, + ]; + + for schema_ids in [None, Some(Vec::new()), Some(vec![" ".to_string()])] { + let request = CultNetMessage::SnapshotRequest { + message_id: "req-1".to_string(), + schema_ids: schema_ids.clone(), + record_keys: None, + }; + let response = serve_read_only_raw_snapshot(®istry, &policy, &source, &request)?; + let CultNetMessage::SnapshotResponseRaw { documents, .. } = response else { + panic!("expected a raw snapshot response"); + }; + assert_eq!( + documents.len(), + 2, + "schema_ids = {schema_ids:?} must lower to no filter, not zero rows" + ); + } + + Ok(()) +} + #[test] fn raw_snapshot_replication_hydrates_same_schema_rust_aliases() -> Result<()> { let temp = tempfile::tempdir()?; diff --git a/packages/cultnet-rs/tests/selection.rs b/packages/cultnet-rs/tests/selection.rs new file mode 100644 index 00000000..47af21de --- /dev/null +++ b/packages/cultnet-rs/tests/selection.rs @@ -0,0 +1,906 @@ +//! CultNet typed selection, Cut 1 commit 3 (docs/cultnet-selection-cut.md section 7/10). A toy row +//! set implementing `Row`/`RowSet` directly - D7: no `cultcache-rs` knowledge, matching S14's brief +//! ("select_over_a_toy_row_set_matches_orders_hops_pages_and_refuses") rather than reproducing every +//! one of C#'s S1-S23 fixtures 1:1. Section 10's negative greps and the parity vectors (S12, S13) are +//! here too. + +use std::fs; +use std::path::Path; + +use cultnet_rs::{ + Citation, FieldPredicate, Incoming, RecordRef, Row, RowSet, Selection, SelectionOperator, + SelectionRefusal, canonical_number, select, validate, +}; + +// ------------------------------------------------------------------------------------------ +// The fixture: two leaves under a conceptual abstract middle (mass declared on both, +// independently - Rust has no inheritance to share it through), a citer with four reference +// shapes (a single "Design" reference into the abstract middle's leaf set, a "related" many +// reference, a "components" many-dictionary reference with float payloads, and a "parent" +// single reference), and a narrow citer whose "narrow_ref" targets only leaf_a - the edge S18 +// needs to reach outside its declared target. +// ------------------------------------------------------------------------------------------ + +#[derive(Clone)] +struct FixtureRow { + schema_id: &'static str, + record_key: &'static str, + ordinal: i64, + kind: Option<&'static str>, + mass: Option<&'static str>, + references: Vec<(String, RecordRef, Option>)>, +} + +impl FixtureRow { + fn leaf(schema_id: &'static str, record_key: &'static str, ordinal: i64, kind: &'static str, mass: &'static str) -> Self { + Self { + schema_id, + record_key, + ordinal, + kind: Some(kind), + mass: Some(mass), + references: Vec::new(), + } + } + + fn citer(record_key: &'static str, ordinal: i64, references: Vec<(String, RecordRef, Option>)>) -> Self { + Self { + schema_id: "citer", + record_key, + ordinal, + kind: None, + mass: None, + references, + } + } +} + +impl Row for FixtureRow { + fn schema_id(&self) -> &str { + self.schema_id + } + fn record_key(&self) -> &str { + self.record_key + } + fn ordinal(&self) -> i64 { + self.ordinal + } + fn values(&self, index: &str) -> Vec { + match index { + "kind" => self.kind.map(|v| vec![v.to_string()]).unwrap_or_default(), + _ => Vec::new(), + } + } + fn number(&self, index: &str) -> Option { + if index == "mass" { + self.mass.map(str::to_string) + } else { + None + } + } + fn references(&self) -> Vec<(String, RecordRef, Option>)> { + self.references.clone() + } +} + +struct FixtureRowSet; + +impl RowSet for FixtureRowSet { + fn all_schema_ids(&self) -> Vec { + vec![ + "leaf_a".into(), + "leaf_b".into(), + "citer".into(), + "narrow_citer".into(), + ] + } + fn declared_indexes(&self, schema_id: &str) -> Vec { + match schema_id { + "leaf_a" | "leaf_b" => vec!["kind".into(), "mass".into()], + _ => Vec::new(), + } + } + fn declared_roles(&self, schema_id: &str) -> Vec { + match schema_id { + "citer" => vec![ + "parent".into(), + "related".into(), + "components".into(), + "Design".into(), + ], + "narrow_citer" => vec!["narrow_ref".into()], + _ => Vec::new(), + } + } + fn is_numeric(&self, schema_id: &str, index: &str) -> bool { + matches!(schema_id, "leaf_a" | "leaf_b") && index == "mass" + } + fn target_leaves(&self, role: &str) -> Vec { + match role { + "parent" | "Design" | "related" | "components" => { + vec!["leaf_a".into(), "leaf_b".into()] + } + "narrow_ref" => vec!["leaf_a".into()], + _ => Vec::new(), + } + } +} + +fn rr(schema_id: &str, record_key: &str) -> RecordRef { + RecordRef::new(schema_id, record_key) +} + +fn base_rows() -> Vec { + vec![ + FixtureRow::leaf("leaf_a", "a-lo", 1, "weapon", "4"), + FixtureRow::leaf("leaf_a", "a-eq", 2, "weapon", "5"), + FixtureRow::leaf("leaf_b", "b-hi", 3, "armor", "6"), + FixtureRow::leaf("leaf_a", "shared-1", 4, "shared", "1"), + FixtureRow::leaf("leaf_b", "shared-2", 5, "shared", "1"), + FixtureRow::leaf("leaf_a", "shared-3", 6, "shared", "1"), + FixtureRow::citer( + "citer-1", + 7, + vec![( + "Design".to_string(), + rr("leaf_a", "a-eq"), + None, + )], + ), + ] +} + +// S2: fields conjoin over declared indexes. +#[test] +fn conjoins_any_of_and_comparison_predicates() { + let rows = base_rows(); + let selection = Selection { + fields: Some(vec![ + FieldPredicate { + index: "kind".into(), + op: "any_of".into(), + values: Some(vec!["weapon".into()]), + number: None, + }, + FieldPredicate { + index: "mass".into(), + op: "ge".into(), + values: None, + number: Some("5".into()), + }, + ]), + ..Selection::default() + }; + let evaluation = select(&FixtureRowSet, &rows, &selection, 1).expect("selection is valid"); + let ids: Vec<&str> = evaluation.rows.iter().map(Row::record_key).collect(); + assert_eq!(ids, vec!["a-eq"]); +} + +// S3: order is (ordinal, schemaId, recordKey) ascending, reversed under descending. +#[test] +fn orders_by_ordinal_then_identity_and_reverses() { + let rows = base_rows(); + let ascending = select(&FixtureRowSet, &rows, &Selection::default(), 1).unwrap(); + assert_eq!( + ascending.rows.iter().map(Row::record_key).collect::>(), + vec!["a-lo", "a-eq", "b-hi", "shared-1", "shared-2", "shared-3", "citer-1"] + ); + + let descending = select( + &FixtureRowSet, + &rows, + &Selection { + descending: true, + ..Selection::default() + }, + 1, + ) + .unwrap(); + assert_eq!( + descending.rows.iter().map(Row::record_key).collect::>(), + vec!["citer-1", "shared-3", "shared-2", "shared-1", "b-hi", "a-eq", "a-lo"] + ); +} + +// S4/S5: a page walk visits every row exactly once, the last page carries no cursor, and a +// cursor is refused when it does not decode, its digest does not match, or asOf has moved. +#[test] +fn pages_exactly_once_and_refuses_a_stale_or_mismatched_cursor() { + let rows = base_rows(); + let mut selection = Selection { + limit: Some(2), + ..Selection::default() + }; + let mut seen = Vec::new(); + for _ in 0..10 { + let page = select(&FixtureRowSet, &rows, &selection, 1).unwrap(); + seen.extend(page.rows.iter().map(|r| r.record_key.to_string())); + match page.next_cursor { + Some(cursor) => selection.cursor = Some(cursor), + None => break, + } + } + assert_eq!( + seen, + vec!["a-lo", "a-eq", "b-hi", "shared-1", "shared-2", "shared-3", "citer-1"] + ); + + let first = select(&FixtureRowSet, &rows, &Selection { limit: Some(1), ..Selection::default() }, 1).unwrap(); + let cursor = first.next_cursor.expect("more than one row"); + + let stale = select( + &FixtureRowSet, + &rows, + &Selection { limit: Some(1), cursor: Some(cursor.clone()), ..Selection::default() }, + 2, + ); + assert!(matches!(stale, Err(SelectionRefusal::CursorStale { .. }))); + + let mismatched = select( + &FixtureRowSet, + &rows, + &Selection { limit: Some(1), cursor: Some(cursor), descending: true, ..Selection::default() }, + 1, + ); + assert!(matches!(mismatched, Err(SelectionRefusal::CursorInvalid { .. }))); + + let garbage = select( + &FixtureRowSet, + &rows, + &Selection { cursor: Some("not-base64!!".into()), ..Selection::default() }, + 1, + ); + assert!(matches!(garbage, Err(SelectionRefusal::CursorInvalid { .. }))); +} + +// S6: the hop follows one declared reference by role. +#[test] +fn hops_one_edge_by_declared_role() { + let rows = base_rows(); + let selection = Selection { + cites: Some(Citation { + target: rr("leaf_a", "a-eq"), + role: Some("Design".into()), + }), + ..Selection::default() + }; + let evaluation = select(&FixtureRowSet, &rows, &selection, 1).unwrap(); + assert_eq!(evaluation.rows.iter().map(Row::record_key).collect::>(), vec!["citer-1"]); + assert_eq!(evaluation.edges.len(), 1); + assert_eq!(evaluation.edges[0].role, "Design"); +} + +// S7: cited { exists } is the one negation, and the two directions answer opposite sets. +#[test] +fn cited_exists_is_the_one_negation() { + let rows = base_rows(); + let cited = select( + &FixtureRowSet, + &rows, + &Selection { + schemas: Some(vec!["leaf_a".into()]), + cited: Some(Incoming { role: "Design".into(), exists: true }), + ..Selection::default() + }, + 1, + ) + .unwrap(); + assert_eq!(cited.rows.iter().map(Row::record_key).collect::>(), vec!["a-eq"]); + + let uncited = select( + &FixtureRowSet, + &rows, + &Selection { + schemas: Some(vec!["leaf_a".into()]), + cited: Some(Incoming { role: "Design".into(), exists: false }), + ..Selection::default() + }, + 1, + ) + .unwrap(); + let mut ids: Vec<&str> = uncited.rows.iter().map(Row::record_key).collect(); + ids.sort_unstable(); + assert_eq!(ids, vec!["a-lo", "shared-1", "shared-3"]); +} + +// S16: the four comparisons at the boundary, including the row whose value equals the compared +// number exactly. +#[test] +fn compares_numbers_at_the_boundary_for_all_four_operators() { + let rows = vec![ + FixtureRow::leaf("leaf_a", "lo", 1, "k", "4"), + FixtureRow::leaf("leaf_a", "eq", 2, "k", "5"), + FixtureRow::leaf("leaf_a", "hi", 3, "k", "6"), + ]; + let matches_for = |op: &str| { + let selection = Selection { + fields: Some(vec![FieldPredicate { + index: "mass".into(), + op: op.into(), + values: None, + number: Some("5".into()), + }]), + ..Selection::default() + }; + let mut ids: Vec = select(&FixtureRowSet, &rows, &selection, 1) + .unwrap() + .rows + .iter() + .map(|r| r.record_key.to_string()) + .collect(); + ids.sort(); + ids + }; + assert_eq!(matches_for("lt"), vec!["lo"]); + assert_eq!(matches_for("le"), vec!["eq", "lo"]); + assert_eq!(matches_for("ge"), vec!["eq", "hi"]); + assert_eq!(matches_for("gt"), vec!["hi"]); +} + +// S18: an edge naming a row outside the reference's declared target refuses the selection. +#[test] +fn refuses_an_edge_outside_its_declared_target() { + let rows = vec![ + FixtureRow::leaf("leaf_b", "b", 1, "k", "1"), + FixtureRow { + schema_id: "narrow_citer", + record_key: "bad", + ordinal: 2, + kind: None, + mass: None, + references: vec![("narrow_ref".to_string(), rr("leaf_b", "b"), None)], + }, + ]; + let selection = Selection { + cited: Some(Incoming { role: "narrow_ref".into(), exists: true }), + ..Selection::default() + }; + let result = select(&FixtureRowSet, &rows, &selection, 1); + assert!(matches!(result, Err(SelectionRefusal::ReferenceOutsideTarget { .. }))); +} + +// S19-equivalent (D11): a dictionary reference's two entries keep distinct payload bytes. +// Row::references() is where Rust exposes this - there is no separate cache layer to test it +// against, unlike C#'s ReferencesOf/Cache_EnumeratesADictionaryReferenceAsEdgesCarryingItsValues. +#[test] +fn dictionary_reference_entries_keep_distinct_payload_bytes() { + let citer = FixtureRow::citer( + "assembly", + 3, + vec![ + ("components".to_string(), rr("leaf_a", "part-1"), Some(vec![1, 5])), + ("components".to_string(), rr("leaf_a", "part-2"), Some(vec![2, 5])), + ], + ); + let payloads: std::collections::HashSet> = + citer.references().into_iter().filter_map(|(_, _, payload)| payload).collect(); + assert_eq!(payloads.len(), 2, "the two dictionary entries must keep distinct payloads"); +} + +// The hop's own edges: a `cites` selection's page holds the citer, so its edges (From = citer) +// survive the page filter in `select` - the direction where edges are visible end to end, mirroring +// `EvaluatorHopsOneEdgeByDeclaredReferenceAndRole`. A `cited` selection's page holds the *citee* +// instead (section 2/8: candidates are filtered to rows the incoming index contains), so its edges +// (also From = citer) do not survive that same filter unless the citer independently matches the +// selection too - this is the reference's actual behaviour (CultNetSelectionEvaluator.Select's +// `pageKeys.Contains(edge.From.Key.Value)`), not a Rust-side gap, and is pinned here rather than +// silently relied upon. +#[test] +fn cited_selections_page_the_citee_so_their_edges_do_not_survive_the_page_filter() { + let rows = vec![ + FixtureRow::leaf("leaf_a", "part-1", 1, "k", "1"), + FixtureRow::citer( + "assembly", + 2, + vec![("components".to_string(), rr("leaf_a", "part-1"), Some(vec![9]))], + ), + ]; + let selection = Selection { + cited: Some(Incoming { role: "components".into(), exists: true }), + ..Selection::default() + }; + let evaluation = select(&FixtureRowSet, &rows, &selection, 1).unwrap(); + assert_eq!(evaluation.rows.iter().map(Row::record_key).collect::>(), vec!["part-1"]); + assert!(evaluation.edges.is_empty(), "the citer (assembly) is not itself on the page"); +} + +// S23: the evaluator matches every row sharing an index value - not a cache's last-writer-wins +// unique-index lookup (Rust's toy Row set never had that bug, but the rule is pinned here too: +// nothing in select() short-circuits after the first match). +#[test] +fn matches_every_row_sharing_an_index_value() { + let rows = base_rows(); + let selection = Selection { + fields: Some(vec![FieldPredicate { + index: "kind".into(), + op: "any_of".into(), + values: Some(vec!["shared".into()]), + number: None, + }]), + ..Selection::default() + }; + let evaluation = select(&FixtureRowSet, &rows, &selection, 1).unwrap(); + let mut ids: Vec<&str> = evaluation.rows.iter().map(Row::record_key).collect(); + ids.sort_unstable(); + assert_eq!(ids, vec!["shared-1", "shared-2", "shared-3"]); +} + +// S1: the door refuses typed rather than answering an empty page. +#[test] +fn validation_refuses_undeclared_index_role_and_empty_lists() { + let empty_schemas = Selection { + schemas: Some(Vec::new()), + ..Selection::default() + }; + assert_eq!(validate(&empty_schemas, &FixtureRowSet).unwrap_err().field, "schemas"); + + let empty_keys = Selection { + keys: Some(Vec::new()), + ..Selection::default() + }; + assert_eq!(validate(&empty_keys, &FixtureRowSet).unwrap_err().field, "keys"); + + let undeclared_index = Selection { + fields: Some(vec![FieldPredicate { + index: "not_declared".into(), + op: "any_of".into(), + values: Some(vec!["x".into()]), + number: None, + }]), + ..Selection::default() + }; + assert_eq!( + validate(&undeclared_index, &FixtureRowSet).unwrap_err().field, + "fields[0].index" + ); + + let undeclared_role = Selection { + cited: Some(Incoming { role: "no_such_role".into(), exists: true }), + ..Selection::default() + }; + assert_eq!(validate(&undeclared_role, &FixtureRowSet).unwrap_err().field, "cited.role"); + + let non_numeric_compare = Selection { + fields: Some(vec![FieldPredicate { + index: "kind".into(), + op: "gt".into(), + values: None, + number: Some("1".into()), + }]), + ..Selection::default() + }; + assert_eq!( + validate(&non_numeric_compare, &FixtureRowSet).unwrap_err().field, + "fields[0].index" + ); +} + +// Self's ruling, 2026-09-22 (docs/cultnet-selection-cut.md, commit 2 fix batch): the door refuses +// a blank entry inside a present schemas/keys list, not only an empty list - the evaluator must +// never have to decide what "" or " " means. +#[test] +fn validation_refuses_a_blank_entry_in_schemas_or_keys() { + let blank_schema = Selection { + schemas: Some(vec!["leaf_a".into(), " ".into()]), + ..Selection::default() + }; + assert_eq!(validate(&blank_schema, &FixtureRowSet).unwrap_err().field, "schemas[1]"); + + let blank_key = Selection { + keys: Some(vec!["".into()]), + ..Selection::default() + }; + assert_eq!(validate(&blank_key, &FixtureRowSet).unwrap_err().field, "keys[0]"); +} + +// Q-J: the door refuses every named non-canonical spelling of a comparison number. +#[test] +fn validation_refuses_non_canonical_number_spellings() { + for spelling in ["+1", "1.0", "01", "1e3", "-0"] { + let selection = Selection { + fields: Some(vec![FieldPredicate { + index: "mass".into(), + op: "gt".into(), + values: None, + number: Some(spelling.into()), + }]), + ..Selection::default() + }; + let error = validate(&selection, &FixtureRowSet) + .expect_err(&format!("{spelling:?} must be refused")); + assert_eq!(error.field, "fields[0].number", "spelling {spelling:?}"); + } +} + +// S13a: nothing on the wire is serde-untagged (docs/cultnet-selection-cut.md section 2). +#[test] +fn selection_is_never_untagged() { + let src_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + for entry in fs::read_dir(&src_dir).expect("src directory exists") { + let entry = entry.expect("readable directory entry"); + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("rs") { + continue; + } + let text = fs::read_to_string(&path).unwrap_or_default(); + assert!( + !text.contains("untagged"), + "{} must not contain #[serde(untagged)] (section 2's rule)", + path.display() + ); + } +} + +// S13b: the selection types round-trip through the named-map MessagePack encoding every runtime +// on this wire uses (rmp_serde::to_vec_named / from_slice), across every shape this cut adds. +#[test] +fn selection_round_trips_through_named_messagepack() { + let selection = Selection { + schemas: Some(vec!["leaf_a".into(), "leaf_b".into()]), + keys: Some(vec!["k1".into()]), + fields: Some(vec![ + FieldPredicate { + index: "kind".into(), + op: "any_of".into(), + values: Some(vec!["weapon".into()]), + number: None, + }, + FieldPredicate { + index: "mass".into(), + op: "ge".into(), + values: None, + number: Some("5".into()), + }, + ]), + cites: Some(Citation { target: rr("leaf_a", "a-eq"), role: Some("Design".into()) }), + cited: Some(Incoming { role: "Design".into(), exists: true }), + projection: "document".into(), + descending: true, + limit: Some(50), + cursor: Some("opaque".into()), + }; + let bytes = rmp_serde::to_vec_named(&selection).expect("encodes"); + let decoded: Selection = rmp_serde::from_slice(&bytes).expect("decodes"); + assert_eq!(decoded, selection); +} + +// ------------------------------------------------------------------------------------------ +// S12: parity vectors. contracts/cultnet/interop/selection-vectors.cs-written.json is written by +// the C# reference (SelectionParityVectorTests.WriteVectors, gated on CULTNET_WRITE_VECTORS=1) +// and judged here; selection-vectors.rs-written.json is the mirror this binary writes (gated the +// same way) for the C# side to judge. A within-runtime round trip pins nothing - both directions +// run against the fixture's *own* rows, decoding only the Selection bytes across the wire. +// ------------------------------------------------------------------------------------------ + +#[derive(serde::Serialize, serde::Deserialize)] +struct VectorFile { + vectors: Vec, +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct Vector { + name: String, + #[serde(rename = "selectionMessagePackBase64")] + selection_message_pack_base64: String, + #[serde(rename = "asOf")] + as_of: u64, + // Present iff this vector is a door refusal (Self's ruling, 2026-09-22): the selection must + // be refused by `validate`/`CultNetSelection.Validate` with exactly this field name, and no + // evaluation runs. Absent for an ordinary evaluated vector. + #[serde(rename = "expectedRefusalField", default, skip_serializing_if = "Option::is_none")] + expected_refusal_field: Option, + #[serde(rename = "expectedIds", default)] + expected_ids: Vec, + #[serde(default)] + matched: u32, + #[serde(rename = "hasNext", default)] + has_next: bool, + #[serde(rename = "expectedEdges", default)] + expected_edges: Vec, +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct VectorEdge { + #[serde(rename = "fromId")] + from_id: String, + role: String, + #[serde(rename = "toId")] + to_id: String, + #[serde(rename = "payloadBase64")] + payload_base64: Option, +} + +fn row_id(schema_id: &str, record_key: &str) -> String { + format!("{schema_id}/{record_key}") +} + +fn contracts_dir() -> std::path::PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("contracts") + .join("cultnet") + .join("interop") +} + +fn evaluate_vector(vector: &Vector) -> (Vec, u32, bool, Vec) { + use base64::Engine; + let bytes = base64::engine::general_purpose::STANDARD + .decode(&vector.selection_message_pack_base64) + .expect("vector selection bytes are valid base64"); + let selection: Selection = + rmp_serde::from_slice(&bytes).expect("vector selection bytes decode as MessagePack"); + let rows = base_rows(); + let evaluation = select(&FixtureRowSet, &rows, &selection, vector.as_of) + .expect("this campaign's vectors are all valid selections"); + let ids: Vec = evaluation + .rows + .iter() + .map(|row| row_id(row.schema_id(), row.record_key())) + .collect(); + let matched = ids.len() as u32; + let has_next = evaluation.next_cursor.is_some(); + let edges = evaluation + .edges + .iter() + .map(|edge| VectorEdge { + from_id: row_id(edge.from.schema_id(), edge.from.record_key()), + role: edge.role.clone(), + to_id: row_id(edge.to.schema_id(), edge.to.record_key()), + payload_base64: edge + .payload + .as_ref() + .map(|bytes| base64::engine::general_purpose::STANDARD.encode(bytes)), + }) + .collect(); + (ids, matched, has_next, edges) +} + +// Self's ruling, 2026-09-22: a door-refusal vector decodes its (necessarily malformed, by the +// v1 rule) selection bytes and asserts `validate` refuses it at exactly the named field, rather +// than evaluating it - "the evaluator never sees []" means these selections are never run +// through `select` at all, on either side of the vector. +fn assert_refusal_vector(vector: &Vector, expected_field: &str) { + use base64::Engine; + let bytes = base64::engine::general_purpose::STANDARD + .decode(&vector.selection_message_pack_base64) + .expect("vector selection bytes are valid base64"); + let selection: Selection = + rmp_serde::from_slice(&bytes).expect("vector selection bytes decode as MessagePack"); + let error = validate(&selection, &FixtureRowSet) + .expect_err(&format!("vector {:?} must be refused", vector.name)); + assert_eq!(error.field, expected_field, "vector {:?}: refusal field", vector.name); +} + +/// Vectors written by the reference (C#) decode and evaluate identically in Rust. +#[test] +fn selection_vectors_written_by_the_reference_decode_and_evaluate_identically_in_rust() { + let path = contracts_dir().join("selection-vectors.cs-written.json"); + let Ok(text) = fs::read_to_string(&path) else { + panic!( + "{} is missing - run the C# writer (SelectionParityVectorTests, CULTNET_WRITE_VECTORS=1) first", + path.display() + ); + }; + let file: VectorFile = serde_json::from_str(&text).expect("vector file is valid JSON"); + assert!(!file.vectors.is_empty(), "the vector file must carry at least one vector"); + for vector in &file.vectors { + if let Some(expected_field) = &vector.expected_refusal_field { + assert_refusal_vector(vector, expected_field); + continue; + } + let (ids, matched, has_next, edges) = evaluate_vector(vector); + assert_eq!(ids, vector.expected_ids, "vector {:?}: row ids", vector.name); + assert_eq!(matched, vector.matched, "vector {:?}: matched", vector.name); + assert_eq!(has_next, vector.has_next, "vector {:?}: hasNext", vector.name); + assert_eq!( + edges.len(), + vector.expected_edges.len(), + "vector {:?}: edge count", + vector.name + ); + for (actual, expected) in edges.iter().zip(vector.expected_edges.iter()) { + assert_eq!(actual.from_id, expected.from_id, "vector {:?}: edge.from", vector.name); + assert_eq!(actual.role, expected.role, "vector {:?}: edge.role", vector.name); + assert_eq!(actual.to_id, expected.to_id, "vector {:?}: edge.to", vector.name); + assert_eq!( + actual.payload_base64, expected.payload_base64, + "vector {:?}: edge.payload", + vector.name + ); + } + } +} + +/// The Rust->C# mirror: builds `selection-vectors.rs-written.json` from this binary's own +/// selections and its own evaluation of the same fixture, for the C# test +/// `SelectionVectorsWrittenByRustDecodeAndEvaluateIdenticallyInTheReference` to judge. Committed, +/// not regenerated on every run - rerun with `CULTNET_WRITE_VECTORS=1` after a real change to the +/// evaluator or the vocabulary. +#[test] +fn write_selection_vectors_for_the_reference() { + if std::env::var("CULTNET_WRITE_VECTORS").as_deref() != Ok("1") { + return; + } + use base64::Engine; + + let cases: Vec<(&str, Selection)> = vec![ + ( + "any_of_plus_ge_conjunction", + Selection { + fields: Some(vec![ + FieldPredicate { + index: "kind".into(), + op: "any_of".into(), + values: Some(vec!["weapon".into()]), + number: None, + }, + FieldPredicate { + index: "mass".into(), + op: "ge".into(), + values: None, + number: Some("5".into()), + }, + ]), + ..Selection::default() + }, + ), + ( + "descending_with_limit", + Selection { + descending: true, + limit: Some(3), + ..Selection::default() + }, + ), + ( + "gt_numeric_boundary", + Selection { + fields: Some(vec![FieldPredicate { + index: "mass".into(), + op: "gt".into(), + values: None, + number: Some("1".into()), + }]), + ..Selection::default() + }, + ), + ( + // Not "hop_by_role"/cites: MatchesCitation on the C# side compares + // Citation.Target.SchemaId against the real (SHA-256) schema id exactly, with no + // SchemaName fallback, so a cites-based vector cannot cross this fixture pair (see + // SelectionParityVectorTests's header comment in the C# test file). `cited` matches + // by role and record key only - no schema id comparison - so it is the hop direction + // both runtimes can agree on here. + "incoming_exists_true", + Selection { + schemas: Some(vec!["leaf_a".into()]), + cited: Some(Incoming { role: "Design".into(), exists: true }), + ..Selection::default() + }, + ), + ( + "incoming_negation", + Selection { + schemas: Some(vec!["leaf_a".into()]), + cited: Some(Incoming { role: "Design".into(), exists: false }), + ..Selection::default() + }, + ), + ( + "shared_index_value_three_rows", + Selection { + fields: Some(vec![FieldPredicate { + index: "kind".into(), + op: "any_of".into(), + values: Some(vec!["shared".into()]), + number: None, + }]), + ..Selection::default() + }, + ), + ]; + + let mut vectors = Vec::new(); + for (name, selection) in cases { + let bytes = rmp_serde::to_vec_named(&selection).expect("encodes"); + let rows = base_rows(); + let evaluation = select(&FixtureRowSet, &rows, &selection, 1).expect("valid selection"); + let ids: Vec = evaluation + .rows + .iter() + .map(|row| row_id(row.schema_id(), row.record_key())) + .collect(); + let edges = evaluation + .edges + .iter() + .map(|edge| VectorEdge { + from_id: row_id(edge.from.schema_id(), edge.from.record_key()), + role: edge.role.clone(), + to_id: row_id(edge.to.schema_id(), edge.to.record_key()), + payload_base64: edge + .payload + .as_ref() + .map(|bytes| base64::engine::general_purpose::STANDARD.encode(bytes)), + }) + .collect(); + vectors.push(Vector { + name: name.to_string(), + selection_message_pack_base64: base64::engine::general_purpose::STANDARD.encode(&bytes), + as_of: 1, + expected_refusal_field: None, + matched: ids.len() as u32, + has_next: evaluation.next_cursor.is_some(), + expected_ids: ids, + expected_edges: edges, + }); + } + + // Self's ruling, 2026-09-22 (commit 2 fix batch): door-refusal vectors. These selections are + // never evaluated on either side - only decoded and handed to validate/Validate, which must + // refuse at exactly the named field. + let refusal_cases: Vec<(&str, Selection, &str)> = vec![ + ( + "refuses_empty_schemas_list", + Selection { + schemas: Some(Vec::new()), + ..Selection::default() + }, + "schemas", + ), + ( + "refuses_blank_key_entry", + Selection { + keys: Some(vec!["a-eq".into(), " ".into()]), + ..Selection::default() + }, + "keys[1]", + ), + ]; + for (name, selection, expected_field) in refusal_cases { + let bytes = rmp_serde::to_vec_named(&selection).expect("encodes"); + vectors.push(Vector { + name: name.to_string(), + selection_message_pack_base64: base64::engine::general_purpose::STANDARD.encode(&bytes), + as_of: 1, + expected_refusal_field: Some(expected_field.to_string()), + matched: 0, + has_next: false, + expected_ids: Vec::new(), + expected_edges: Vec::new(), + }); + } + + let file = VectorFile { vectors }; + let json = serde_json::to_string_pretty(&file).expect("serializes"); + let dir = contracts_dir(); + fs::create_dir_all(&dir).expect("contracts/cultnet/interop exists or is created"); + fs::write(dir.join("selection-vectors.rs-written.json"), json).expect("writes the vector file"); +} + +// A canary that the canonical_number module used above stays reachable from an integration test +// (it is exercised indirectly through Selection.fields[].number already, but this names the rule +// directly): a lexicographic compare would put "10" below "9". +#[test] +fn canonical_number_compares_by_magnitude() { + assert_eq!( + canonical_number::compare("10", "9"), + std::cmp::Ordering::Greater + ); + assert!(canonical_number::is_canonical("10")); + assert!(!canonical_number::is_canonical("+10")); +} + +// Q-J: a negative value orders below a positive one of the same magnitude - a comparator that +// ignores sign would treat "-1" and "1" as equal (same integer/fraction digits). +#[test] +fn canonical_number_compares_by_sign() { + assert_eq!(canonical_number::compare("-1", "1"), std::cmp::Ordering::Less); + assert_eq!(canonical_number::compare("1", "-1"), std::cmp::Ordering::Greater); + assert_eq!(canonical_number::compare("-10", "-2"), std::cmp::Ordering::Less); +} + +#[allow(dead_code)] +fn unused_operator_reference(op: SelectionOperator) -> &'static str { + op.as_wire_str() +} diff --git a/scripts/mutate-cultnet-selection-rust.entries.ps1 b/scripts/mutate-cultnet-selection-rust.entries.ps1 new file mode 100644 index 00000000..e2770e21 --- /dev/null +++ b/scripts/mutate-cultnet-selection-rust.entries.ps1 @@ -0,0 +1,233 @@ +# Entries for tools/eureka-mutations.ps1 (C:\Users\Meta\.claude\skills\eureka\tools): +# CultNet typed selection, Cut 1 commit 3 - the Rust evaluator +# (src/selection.rs), mirroring the reference's rules at wire parity +# (docs/cultnet-selection-cut.md section 6/7/10). Target: src/selection.rs. +# +# Two commands are in play (the harness runs M0 once per distinct Command in the suite): +# `cargo test --test selection` for the evaluator-level rules (the integration test +# binary in packages/cultnet-rs/tests/selection.rs), and `cargo test --lib` for the +# canonical_number module's own unit tests (its render_*/canonicalize_decimal_digits functions are +# not otherwise exercised by the integration suite - Row::number already returns pre-rendered +# strings there). Run with CARGO_TARGET_DIR set to this worktree's own target directory per the +# campaign's build rules. +# +# Not exhaustive of every rule selection.rs carries (S1-S23's Rust-side coverage is +# "select_over_a_toy_row_set_matches_orders_hops_pages_and_refuses" plus these named mutants, per +# the map's S14): order, cursor freshness and digest, the one negation, the numeric boundary, the +# out-of-target refusal, and Q-J's door/comparator/renderer - the rules a Rust caller (Huginn) +# depends on that the reference's own suite already pins on the C# side. + +@( + @{ + Id = 'RS-S3-Revert' + Rule = 'S3: order reverses under descending.' + Test = 'orders_by_ordinal_then_identity_and_reverses' + Command = 'cargo test --test selection' + File = 'src/selection.rs' + Old = 'if descending { ordering.reverse() } else { ordering }' + New = 'let _ = descending; ordering' + }, + @{ + Id = 'RS-CursorStale-Revert' + Rule = 'A cursor from a moved asOf is refused cursor_stale.' + Test = 'pages_exactly_once_and_refuses_a_stale_or_mismatched_cursor' + Command = 'cargo test --test selection' + File = 'src/selection.rs' + Old = 'if cursor.as_of != as_of {' + New = 'if false {' + }, + @{ + Id = 'RS-CursorStale-Loosening' + Rule = 'Staleness compares equality, not only "cursor is newer than the current asOf".' + Test = 'pages_exactly_once_and_refuses_a_stale_or_mismatched_cursor' + Command = 'cargo test --test selection' + File = 'src/selection.rs' + Old = 'if cursor.as_of != as_of {' + New = 'if cursor.as_of > as_of {' + }, + @{ + Id = 'RS-CursorDigest-Revert' + Rule = 'A cursor whose digest does not match this selection is refused cursor_invalid.' + Test = 'pages_exactly_once_and_refuses_a_stale_or_mismatched_cursor' + Command = 'cargo test --test selection' + File = 'src/selection.rs' + Old = 'if cursor.digest != Cursor::compute_digest(selection) {' + New = 'if false {' + }, + @{ + Id = 'RS-S7-Revert' + Rule = 'S7: cited { exists } is honoured, not ignored.' + Test = 'cited_exists_is_the_one_negation' + Command = 'cargo test --test selection' + File = 'src/selection.rs' + Old = 'candidates.retain(|row| incoming.contains(row.record_key()) == exists);' + New = 'candidates.retain(|row| incoming.contains(row.record_key()) || true);' + }, + @{ + Id = 'RS-S7-Loosening' + Rule = 'exists:true and exists:false must answer opposite sets, not the same test inverted twice.' + Test = 'cited_exists_is_the_one_negation' + Command = 'cargo test --test selection' + File = 'src/selection.rs' + Old = 'candidates.retain(|row| incoming.contains(row.record_key()) == exists);' + New = 'candidates.retain(|row| incoming.contains(row.record_key()) != exists);' + }, + @{ + Id = 'RS-S16-Le-Revert' + Rule = 'S16: le includes the equal boundary.' + Test = 'compares_numbers_at_the_boundary_for_all_four_operators' + Command = 'cargo test --test selection' + File = 'src/selection.rs' + Old = 'SelectionOperator::Le => ordering != Ordering::Greater,' + New = 'SelectionOperator::Le => ordering == Ordering::Less,' + }, + @{ + Id = 'RS-S16-Ge-Revert' + Rule = 'S16: ge includes the equal boundary.' + Test = 'compares_numbers_at_the_boundary_for_all_four_operators' + Command = 'cargo test --test selection' + File = 'src/selection.rs' + Old = 'SelectionOperator::Ge => ordering != Ordering::Less,' + New = 'SelectionOperator::Ge => ordering == Ordering::Greater,' + }, + @{ + Id = 'RS-S18-Revert' + Rule = "D9/S18: an edge naming a row outside its reference's declared target refuses the selection." + Test = 'refuses_an_edge_outside_its_declared_target' + Command = 'cargo test --test selection' + File = 'src/selection.rs' + Old = 'if leaves.is_empty() || leaves.iter().any(|leaf| leaf == to.schema_id()) { + return Ok(()); + }' + New = 'if true { + return Ok(()); + }' + }, + @{ + Id = 'RS-QJ-Door-Regex-Revert' + Rule = 'Q-J: the door refuses a non-canonical number spelling rather than accepting it.' + Test = 'validation_refuses_non_canonical_number_spellings' + Command = 'cargo test --test selection' + File = 'src/selection.rs' + Old = 'Some(number) if !canonical_number::is_canonical(number) => {' + New = 'Some(number) if false && !canonical_number::is_canonical(number) => {' + }, + @{ + Id = 'RS-QJ-Door-NegativeZero-Revert' + Rule = 'Q-J: "-0" is excluded even though the digit grammar alone matches it.' + Test = 'validation_refuses_non_canonical_number_spellings' + Command = 'cargo test --test selection' + File = 'src/selection.rs' + Old = 'if value == "-0" { + return false; + }' + New = '' + }, + @{ + Id = 'RS-QJ-Compare-IntegerLength-Revert' + Rule = "Q-J: the comparator checks the integer part's length before its digits." + Test = 'canonical_number_compares_by_magnitude' + Command = 'cargo test --test selection' + File = 'src/selection.rs' + Old = 'let magnitude = if integer_left.len() != integer_right.len() { + integer_left.len().cmp(&integer_right.len()) + } else {' + New = 'let magnitude = if false { + integer_left.len().cmp(&integer_right.len()) + } else {' + }, + @{ + Id = 'RS-QJ-Compare-Sign-Revert' + Rule = 'Q-J: the comparator orders a negative value below a positive one.' + Test = 'canonical_number_compares_by_sign' + Command = 'cargo test --test selection' + File = 'src/selection.rs' + Old = 'if negative_left != negative_right {' + New = 'if false {' + }, + @{ + Id = 'RS-QJ-RenderInteger-Float64-Revert' + Rule = 'Q-J: an i64 renders its exact decimal, never through f64 (2^53 boundary).' + Test = 'selection::canonical_number::tests::render_i64_past_2_pow_53_is_exact' + Command = 'cargo test --lib' + File = 'src/selection.rs' + Old = 'pub fn render_i64(value: i64) -> String { + canonicalize_decimal_digits(&value.to_string()) + }' + New = 'pub fn render_i64(value: i64) -> String { + canonicalize_decimal_digits(&(value as f64).to_string()) + }' + }, + @{ + Id = 'RS-QJ-DecimalTrailingZeros-Revert' + Rule = 'Q-J: canonicalize_decimal_digits strips trailing fractional zeros.' + Test = 'selection::canonical_number::tests::canonicalize_decimal_digits_strips_leading_and_trailing_zeros' + Command = 'cargo test --lib' + File = 'src/selection.rs' + Old = "let fraction_part = fraction_part.trim_end_matches('0');" + New = 'let fraction_part = fraction_part;' + }, + @{ + # Not killed through render_f64/render_f32: probe_f64_display_never_uses_exponent_notation + # (this module's own tests) established that Rust's f64/f32 Display never produces exponent + # notation, so no real render_f64 input reaches the branch this reverts - unlike C#'s + # ToString(), which does. Killed directly against expand_scientific_notation instead, which + # pins the algorithm on hand-built exponent strings regardless of whether Display ever + # supplies one today. + Id = 'RS-QJ-ExponentExpansion-Revert' + Rule = 'Q-J: a string in exponent notation is rewritten to positional digits.' + Test = 'selection::canonical_number::tests::expand_scientific_notation_rewrites_hand_built_exponent_forms' + Command = 'cargo test --lib' + File = 'src/selection.rs' + Old = "if value.find(['e', 'E']).is_none() { + return value.to_string(); + }" + New = 'return value.to_string();' + }, + @{ + Id = 'RS-EmptySchemas-Revert' + Rule = 'S1: an empty (but present) schemas list is refused, never answered as an empty page.' + Test = 'validation_refuses_undeclared_index_role_and_empty_lists' + Command = 'cargo test --test selection' + File = 'src/selection.rs' + Old = 'if matches!(&selection.schemas, Some(schemas) if schemas.is_empty()) {' + New = 'if false {' + }, + @{ + Id = 'RS-EmptyKeys-Revert' + Rule = 'S1: an empty (but present) keys list is refused, never answered as an empty page.' + Test = 'validation_refuses_undeclared_index_role_and_empty_lists' + Command = 'cargo test --test selection' + File = 'src/selection.rs' + Old = 'if matches!(&selection.keys, Some(keys) if keys.is_empty()) {' + New = 'if false {' + }, + # Self's ruling, 2026-09-22 (docs/cultnet-selection-cut.md, commit 2 fix batch). + @{ + Id = 'RS-BlankSchema-Revert' + Rule = 'The door refuses a blank entry inside a present schemas list, not only an empty list.' + Test = 'validation_refuses_a_blank_entry_in_schemas_or_keys' + Command = 'cargo test --test selection' + File = 'src/selection.rs' + Old = 'if schema.trim().is_empty() {' + New = 'if false {' + }, + @{ + Id = 'RS-BlankKey-Revert' + Rule = 'The door refuses a blank entry inside a present keys list.' + Test = 'validation_refuses_a_blank_entry_in_schemas_or_keys' + Command = 'cargo test --test selection' + File = 'src/selection.rs' + Old = 'if key.trim().is_empty() {' + New = 'if false {' + }, + @{ + Id = 'RS-V0Lowering-Revert' + Rule = 'v0''s own cleaning: an empty or all-blank v0 list lowers to null (no filter), not to a list the door or the evaluator would have to refuse or misread.' + Test = 'serve_read_only_raw_snapshot_lowers_an_empty_or_blank_v0_schema_list_to_no_filter' + Command = 'cargo test --test cultnet' + File = 'src/snapshot_query.rs' + Old = 'Some(values) if !values.is_empty() && values.iter().any(|value| !value.trim().is_empty()) => {' + New = 'Some(values) => {' + } +) diff --git a/tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs b/tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs new file mode 100644 index 00000000..261c0d12 --- /dev/null +++ b/tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs @@ -0,0 +1,273 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using GameCult.Caching; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Networking.Tests +{ + // CultNet typed selection, Cut 1 commit 3/4 (docs/cultnet-selection-cut.md section 6/10, S12): + // the C#<->Rust parity vectors. New file; no existing C# source is modified. + // + // "schemaId" inside the committed vector files is each fixture document's *SchemaName* + // ("leaf_a", "leaf_b", "citer"), never CultDocumentDescriptor.SchemaId - a SHA-256 content + // hash over the type's canonical shape that Rust has no way to reproduce independently, and + // that the reference itself does not run through alias matching everywhere (MatchesCitation + // compares Citation.Target.SchemaId against the real hash exactly, with no SchemaName + // fallback). The vectors below therefore only exercise `cited` (role/existence, matched by + // record key alone - no schema id comparison at all) for the hop, never `cites` (which would + // need a real cross-runtime-agreed schema id this fixture pair cannot produce). This is a + // fixture-identity convention local to these vectors, not a claim about the real wire. + public sealed class SelectionParityVectorTests + { + private static CultDocumentRegistry Registry() => CultDocumentRegistry.ForTypes(new[] + { + typeof(ParityLeafA), typeof(ParityLeafB), typeof(ParityCiter) + }); + + private static CultNetSelectionEvaluator.Row Row(CultDocumentRegistry registry, object document, string key, long ordinal) => + new(registry.GetRequired(document.GetType()), new CultRecordKey(key), document, ordinal); + + private static (CultDocumentRegistry registry, CultNetSelectionEvaluator.Row[] rows) BuildFixture() + { + var registry = Registry(); + var rows = new[] + { + Row(registry, new ParityLeafA { Name = "a-lo", Kind = "weapon", Mass = 4 }, "a-lo", 1), + Row(registry, new ParityLeafA { Name = "a-eq", Kind = "weapon", Mass = 5 }, "a-eq", 2), + Row(registry, new ParityLeafB { Name = "b-hi", Kind = "armor", Mass = 6 }, "b-hi", 3), + Row(registry, new ParityLeafA { Name = "shared-1", Kind = "shared", Mass = 1 }, "shared-1", 4), + Row(registry, new ParityLeafB { Name = "shared-2", Kind = "shared", Mass = 1 }, "shared-2", 5), + Row(registry, new ParityLeafA { Name = "shared-3", Kind = "shared", Mass = 1 }, "shared-3", 6), + Row(registry, new ParityCiter { Name = "citer-1", Design = new CultRecordRef(new CultRecordKey("a-eq")) }, "citer-1", 7) + }; + return (registry, rows); + } + + // The same six selections packages/cultnet-rs/tests/selection.rs's + // write_selection_vectors_for_the_reference builds against its own fixture rows (which + // carry the same schema names, record keys, ordinals and declared values as BuildFixture + // above, independently constructed - a within-runtime round trip pins nothing, section 10). + private static IEnumerable<(string Name, CultNetSelection Selection)> Cases() + { + yield return ("any_of_plus_ge_conjunction", new CultNetSelection + { + Fields = new[] + { + new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "weapon" } }, + new CultNetFieldPredicate { Index = "mass", Op = "ge", Number = "5" } + } + }); + yield return ("descending_with_limit", new CultNetSelection { Descending = true, Limit = 3 }); + yield return ("gt_numeric_boundary", new CultNetSelection + { + Fields = new[] { new CultNetFieldPredicate { Index = "mass", Op = "gt", Number = "1" } } + }); + yield return ("incoming_exists_true", new CultNetSelection + { + Schemas = new[] { "leaf_a" }, + Cited = new CultNetIncoming { Role = "Design", Exists = true } + }); + yield return ("incoming_negation", new CultNetSelection + { + Schemas = new[] { "leaf_a" }, + Cited = new CultNetIncoming { Role = "Design", Exists = false } + }); + yield return ("shared_index_value_three_rows", new CultNetSelection + { + Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "shared" } } } + }); + } + + // Self's ruling, 2026-09-22 (commit 2 fix batch): door-refusal vectors. Never evaluated on + // either side - only decoded and handed to Validate/validate, which must refuse at exactly + // the named field. Mirrors packages/cultnet-rs/tests/selection.rs's refusal_cases. + private static IEnumerable<(string Name, CultNetSelection Selection, string ExpectedField)> RefusalCases() + { + yield return ("refuses_empty_schemas_list", new CultNetSelection { Schemas = Array.Empty() }, "schemas"); + yield return ("refuses_blank_key_entry", new CultNetSelection { Keys = new[] { "a-eq", " " } }, "keys[1]"); + } + + private static string RowId(CultNetSelectionEvaluator.Row row) => $"{row.Descriptor.SchemaName}/{row.Key.Value}"; + + // Vectors written by Rust (packages/cultnet-rs/tests/selection.rs, + // write_selection_vectors_for_the_reference) decode and evaluate identically here. + [Test] + public void SelectionVectorsWrittenByRustDecodeAndEvaluateIdenticallyInTheReference() + { + var path = Path.Combine(RepoRoot(), "contracts", "cultnet", "interop", "selection-vectors.rs-written.json"); + if (!File.Exists(path)) + Assert.Inconclusive($"{path} is missing - run cargo test -p cultnet-rs --test selection with CULTNET_WRITE_VECTORS=1 first."); + + var (registry, rows) = BuildFixture(); + using var document = JsonDocument.Parse(File.ReadAllText(path)); + var vectors = document.RootElement.GetProperty("vectors"); + Assert.That(vectors.GetArrayLength(), Is.GreaterThan(0), "the vector file must carry at least one vector"); + + foreach (var vector in vectors.EnumerateArray()) + { + var name = vector.GetProperty("name").GetString(); + var selectionBytes = Convert.FromBase64String(vector.GetProperty("selectionMessagePackBase64").GetString()!); + var selection = MessagePackSerializer.Deserialize(selectionBytes, CultNetSchemaMessageSerialization.Options); + var asOf = vector.GetProperty("asOf").GetUInt64(); + + if (vector.TryGetProperty("expectedRefusalField", out var refusalField) && refusalField.ValueKind != JsonValueKind.Null) + { + var error = Assert.Throws(() => selection.Validate(registry.AllDescriptors.ToArray()), $"vector {name} must be refused"); + Assert.That(error!.Field, Is.EqualTo(refusalField.GetString()), $"vector {name}: refusal field"); + continue; + } + + var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf); + + var expectedIds = vector.GetProperty("expectedIds").EnumerateArray().Select(e => e.GetString()).ToArray(); + Assert.That(evaluation.Rows.Select(RowId).ToArray(), Is.EqualTo(expectedIds), $"vector {name}: row ids"); + Assert.That((uint)evaluation.Rows.Count, Is.EqualTo(vector.GetProperty("matched").GetUInt32()), $"vector {name}: matched"); + Assert.That(evaluation.NextCursor != null, Is.EqualTo(vector.GetProperty("hasNext").GetBoolean()), $"vector {name}: hasNext"); + + var expectedEdges = vector.GetProperty("expectedEdges").EnumerateArray().ToArray(); + Assert.That(evaluation.Edges.Count, Is.EqualTo(expectedEdges.Length), $"vector {name}: edge count"); + for (var i = 0; i < expectedEdges.Length; i++) + { + var edge = evaluation.Edges[i]; + var expectedEdge = expectedEdges[i]; + Assert.That(RowId(edge.From), Is.EqualTo(expectedEdge.GetProperty("fromId").GetString()), $"vector {name}: edge[{i}].from"); + Assert.That(edge.Role, Is.EqualTo(expectedEdge.GetProperty("role").GetString()), $"vector {name}: edge[{i}].role"); + Assert.That(RowId(edge.To), Is.EqualTo(expectedEdge.GetProperty("toId").GetString()), $"vector {name}: edge[{i}].to"); + var expectedPayload = expectedEdge.GetProperty("payloadBase64"); + if (expectedPayload.ValueKind == JsonValueKind.Null) + Assert.That(edge.Payload, Is.Null, $"vector {name}: edge[{i}].payload"); + else + Assert.That(Convert.ToBase64String((byte[])edge.Payload!), Is.EqualTo(expectedPayload.GetString()), $"vector {name}: edge[{i}].payload"); + } + } + } + + // Writes contracts/cultnet/interop/selection-vectors.cs-written.json for Rust's + // selection_vectors_written_by_the_reference_decode_and_evaluate_identically_in_rust to + // judge. Gated the same way the QUIC campaign's C#-written vectors are + // (docs/typescript-quic-realtime-cut.md:15-21): committed, not regenerated on every run. + [Test] + public void WriteSelectionVectors() + { + if (Environment.GetEnvironmentVariable("CULTNET_WRITE_VECTORS") != "1") + Assert.Ignore("Set CULTNET_WRITE_VECTORS=1 to (re)write the committed vector file."); + + var (registry, rows) = BuildFixture(); + var vectors = new List(); + foreach (var (name, selection) in Cases()) + { + var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1); + var selectionBytes = MessagePackSerializer.Serialize(selection, CultNetSchemaMessageSerialization.Options); + + vectors.Add(new + { + name, + selectionMessagePackBase64 = Convert.ToBase64String(selectionBytes), + asOf = 1UL, + expectedRefusalField = (string?)null, + expectedIds = evaluation.Rows.Select(RowId).ToArray(), + matched = (uint)evaluation.Rows.Count, + hasNext = evaluation.NextCursor != null, + expectedEdges = evaluation.Edges.Select(edge => new + { + fromId = RowId(edge.From), + role = edge.Role, + toId = RowId(edge.To), + // None of this file's six vectors hop through a many-dictionary reference + // (the only shape D11 gives a non-null payload), so this is always null + // here; packages/cultnet-rs/tests/selection.rs's + // dictionary_reference_entries_keep_distinct_payload_bytes pins the + // payload-carrying case directly against Row::references() instead. + payloadBase64 = edge.Payload == null ? null : Convert.ToBase64String((byte[])edge.Payload) + }).ToArray() + }); + } + + // Self's ruling, 2026-09-22 (commit 2 fix batch): door-refusal vectors. Never + // evaluated on either side - only decoded and handed to Validate, which must refuse + // at exactly the named field. + foreach (var (name, selection, expectedField) in RefusalCases()) + { + var error = Assert.Throws( + () => selection.Validate(registry.AllDescriptors.ToArray()), + $"{name} must be refused by the reference before it can be committed as a refusal vector"); + Assert.That(error!.Field, Is.EqualTo(expectedField), $"{name}: refusal field"); + + var selectionBytes = MessagePackSerializer.Serialize(selection, CultNetSchemaMessageSerialization.Options); + vectors.Add(new + { + name, + selectionMessagePackBase64 = Convert.ToBase64String(selectionBytes), + asOf = 1UL, + expectedRefusalField = (string?)expectedField, + expectedIds = Array.Empty(), + matched = 0U, + hasNext = false, + expectedEdges = Array.Empty() + }); + } + + var directory = Path.Combine(RepoRoot(), "contracts", "cultnet", "interop"); + Directory.CreateDirectory(directory); + var json = JsonSerializer.Serialize(new { vectors }, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(Path.Combine(directory, "selection-vectors.cs-written.json"), json); + } + + private static string RepoRoot() + { + var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (directory != null && !File.Exists(Path.Combine(directory.FullName, "CultLib.sln"))) + directory = directory.Parent; + return directory?.FullName ?? throw new InvalidOperationException("CultLib.sln was not found above the test directory."); + } + + // A conceptual abstract middle (no [CultDocument]), matching Design's declared target so + // D9's leaf-set resolution reaches both ParityLeafA and ParityLeafB. SchemaName is the + // literal fixture-identity string ("leaf_a", "leaf_b", "citer") the vectors compare by - + // see this file's header comment. + public abstract class ParityMiddle + { + [Key(0)] + [CultName] + public string Name = string.Empty; + + [Key(1)] + [CultIndex("kind")] + public string Kind = string.Empty; + + [Key(2)] + [CultIndex("mass")] + public float Mass; + } + + [CultDocument("leaf_a", "cultnet.selection-parity.leaf_a.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class ParityLeafA : ParityMiddle + { + } + + [CultDocument("leaf_b", "cultnet.selection-parity.leaf_b.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class ParityLeafB : ParityMiddle + { + } + + [CultDocument("citer", "cultnet.selection-parity.citer.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class ParityCiter + { + [Key(0)] + [CultName] + public string Name = string.Empty; + + [Key(1)] + [CultReference(typeof(ParityMiddle))] + public CultRecordRef Design; + } + } +}