CultNet selection cut1, commit 3: the Rust runtime
docs/cultnet-selection-cut.md section 6/7: packages/cultnet-rs/src/selection.rs (new) - the Rust spelling of the vocabulary at wire parity with the corrected C# reference (e5348d3), matching D7: no cultcache-rs knowledge, the row owner supplies declared values and edges through two traits. - Wire types: Selection, FieldPredicate, Citation, Incoming, RecordRef, Edge, RawDocumentHeader, SelectionDocumentRecord (the v1 full-shape record, distinct from v0's CultNetRawDocumentRecord, which lacks schemaName/schemaVersion/ schemaContentHash - a pre-existing v0 gap this cut does not touch). SelectionOperator mirrors CultNetSelectionOperator for evaluator ergonomics; FieldPredicate.op stays a raw wire string, never a serde-tagged union. - canonical_number module (Q-J, section 2 "Numbers"): is_canonical (a hand-rolled digit-grammar parser, no regex dependency added), canonicalize_decimal_digits, expand_scientific_notation, compare (sign, then integer length, then digits, then padded fraction - no f64 anywhere), and render_i64/render_u64/render_f64/render_f32 for a Row implementer's own rendering. probe_f64_display_never_uses_exponent_notation empirically verifies the map's claim about Rust's f64 Display (confirmed true) rather than trusting it. - Row/RowSet traits (D7): schema_id, record_key, ordinal, values, number (canonical string, never f64), references for Row; all_schema_ids, declared_indexes, declared_roles, is_numeric, target_leaves for RowSet. A blanket `impl<T: Row> Row for &T` lets sorted/filtered slices of references satisfy generic bounds without an extra copy. - validate (S1 door refusals), matches (D6 single-row fast path, hop-bearing selections assert via debug_assert since Rust has no exception type to mirror C#'s InvalidOperationException), select (order/hop/cursor/paging, D9's reference_outside_target refusal, S23's per-row match - never through a unique-index map, because there is none here to begin with). - Cursor: mint/parse/digest is this runtime's own internal algorithm (a cursor is opaque and never crosses runtimes - each server answers its own pages), not a byte-for-byte port of the reference's; what must match is the *rule* (asOf + last position + a digest bound to the selection), which it does, pinned by RS-CursorStale-Revert/Loosening and RS-CursorDigest-Revert. - contracts.rs: three v1 CultNetMessage variants (SnapshotRequestV1, DatabaseSubscribeV1, SnapshotResponseRawV1) matching the C# messages' field-for-field shape. SnapshotResponseRawV1 carries binary payloads (documents[].payload, edges[].payload), so it is routed through the existing raw JSON-bypass path (parse_raw_cultnet_schema_message/ encode_raw_cultnet_schema_message) alongside v0's DocumentPutRaw/ SnapshotResponseRaw, with its own require_*/encode_* helpers for SelectionDocumentRecord, RawDocumentHeader, Edge and RecordRef. SnapshotRequestV1/DatabaseSubscribeV1 carry no binary and use the ordinary serde_json-intermediate path. No message-layer selection semantics validation here, matching the reference (CultNetSchemaMessageSerialization.cs only deserializes; a server calls selection::validate against its own RowSet before evaluating). - snapshot_query.rs (D3/D4): serve_read_only_raw_snapshot's two hand-rolled allowlist filters collapse into one call to selection::matches over a lowered v0 Selection, through a minimal RawSnapshotRow wrapper whose ordinal/values/number/references are unreachable (the lowered selection never sets fields/cites/cited, so matches_schema_keys_fields never calls them) - documented, not hidden. Ruling (Self, 2026-09-22, docs/cultnet-selection-cut.md commit 2 fix batch, mirrored here per the coordinator's mid-task instruction rather than C#'s pre-fix spelling as it stood ate5348d3): - validate refuses an empty-but-present schemas/keys list (already had this) and now also refuses any individual entry that is empty or whitespace (fields[i]-style names: schemas[i]/keys[i]). - The evaluator never sees []; matches_schema_keys_fields does not special-case an empty list as "no filter" (it never did here - that C# fallback, CultNetSelectionEvaluator.cs:84 pre-fix, was never mirrored into Rust). - snapshot_query.rs's v0 lowering gets its own cleaning, owned by the lowering and not the evaluator: lower_v0_list turns an empty or all-blank v0 schemaIds/recordKeys list into None before it ever reaches validate or select. Tests: cultnet-rs 173 passed, 0 failed (was 111 baseline #[test]s pre-cut; +62 across lib unit tests, tests/selection.rs and tests/cultnet.rs). cultcache-rs unmodified, 64 passed, 0 failed (untouched by this commit - confirmed unaffected). GameCult.Networking.Tests (the new SelectionParityVectorTests.cs file, compiled but not yet fully exercised - see below) builds clean. Mutation harness (tools/eureka-mutations.ps1, scripts/mutate-cultnet- selection-rust.entries.ps1): 22 entries against src/selection.rs and src/snapshot_query.rs, all KILLED, M0 green. Covers: S1's empty-list and new blank-entry refusals (schemas and keys, revert x4), order/descending (revert), cursor staleness (revert + loosening) and digest (revert), S7's incoming negation (revert + loosening), S16's le/ge boundaries (revert x2), S18's reference_outside_target (revert), Q-J's door regex and -0 exclusion (revert x2), Q-J's comparator integer-length and sign (revert x2), Q-J's integer-through-float64 renderer, decimal trailing-zero stripping and exponent expansion (revert x3, the last two pinned via direct canonical_number unit tests since render_f64 itself cannot reach exponent notation given Rust's Display contract - verified, not assumed), and the new v0-lowering rule (revert). Negative grep: `rg -n "untagged" packages/cultnet-rs/src packages/cultcache-rs/src` empty. Not included here, landing as commit 4 once ready: the committed parity vector files (contracts/cultnet/interop/selection-vectors.{cs,rs}-written.json). Both directions already work for six evaluated selections and the [] door-refusal case; the blank-key-entry refusal vector cannot be generated from the C# side yet because the parallel C# fix batch implementing that door check (docs/cultnet-selection-cut.md, "Self's rulings...for the commit 2 fix batch") has not landed on this branch as of this commit. Rust's own tests (validation_refuses_a_blank_entry_in_schemas_or_keys, serve_read_only_raw_snapshot_lowers_an_empty_or_blank_v0_schema_list_to_no_filter) already pin both new rules independently of C#. Structural delta: packages/cultnet-rs/src/selection.rs +1407 (new), contracts.rs +616/-21, snapshot_query.rs +76/-21 (net +55, collapsing the fifth selector engine into one call), lib.rs +2, tests/cultnet.rs +67 (new v0-lowering test), tests/selection.rs +900 (new, evaluator tests + vector machinery), scripts/mutate-cultnet-selection-rust.entries.ps1 +233 (new), tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs +296 (new). Against section 14's ~430-line estimate for selection.rs+contracts.rs: the two together land at ~2000 lines added, well over estimate - the map's own §14 correction already flagged commits 0/1 running roughly 2x estimate for the same reason (the hop's incoming index, the cursor, and per-field door messages cost more than the prose assumed), and the v1 raw-message handling (SelectionDocumentRecord/RawDocumentHeader/Edge require_*/encode_* helpers, ~400 lines) was not separately budgeted at all. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
e5348d3d57
commit
b090ca97f9
8 changed files with 3559 additions and 21 deletions
|
|
@ -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<CultNetRawDocumentRecord>,
|
||||
},
|
||||
/// 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<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
shard_epoch: Option<i64>,
|
||||
},
|
||||
/// 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<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
body_ids: Option<Vec<String>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
supported_body_transports: Option<Vec<String>>,
|
||||
},
|
||||
/// `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<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
headers: Option<Vec<RawDocumentHeader>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
documents: Option<Vec<SelectionDocumentRecord>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
edges: Option<Vec<Edge>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
shard_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
shard_epoch: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
shard_log_sequence: Option<i64>,
|
||||
},
|
||||
#[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<CultNetMessag
|
|||
documents,
|
||||
})
|
||||
}
|
||||
"cultnet.snapshot_response_raw.v1" => {
|
||||
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::<Result<Vec<_>>>()
|
||||
})
|
||||
.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::<Result<Vec<_>>>()
|
||||
})
|
||||
.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::<Result<Vec<_>>>()
|
||||
})
|
||||
.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<rmpv::V
|
|||
rmpv::Value::Array(documents.iter().map(encode_raw_document_record).collect()),
|
||||
),
|
||||
],
|
||||
CultNetMessage::SnapshotResponseRawV1 {
|
||||
message_id,
|
||||
matched,
|
||||
as_of,
|
||||
next,
|
||||
headers,
|
||||
documents,
|
||||
edges,
|
||||
shard_id,
|
||||
shard_epoch,
|
||||
shard_log_sequence,
|
||||
} => 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<SelectionDocumentRecord> {
|
||||
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<RawDocumentHeader> {
|
||||
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<Edge> {
|
||||
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<RecordRef> {
|
||||
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<u32> {
|
||||
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<u64> {
|
||||
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<Option<i64>> {
|
||||
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<CultNetMessage> {
|
||||
let items = input.as_array().ok_or_else(|| {
|
||||
anyhow!("gamecult.networking.v0 messages must be a 2-element union array")
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
1407
packages/cultnet-rs/src/selection.rs
Normal file
1407
packages/cultnet-rs/src/selection.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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<S: CultNetRawSnapshotSource>(
|
|||
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::<BTreeSet<_>>());
|
||||
let requested_keys = record_keys
|
||||
.as_ref()
|
||||
.map(|values| values.iter().map(String::as_str).collect::<BTreeSet<_>>());
|
||||
// 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<S: CultNetRawSnapshotSource>(
|
|||
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<S: CultNetRawSnapshotSource>(
|
|||
})
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
unreachable!("v0's lowered selection carries no fields; values is not read")
|
||||
}
|
||||
fn number(&self, _index: &str) -> Option<String> {
|
||||
unreachable!("v0's lowered selection carries no fields; number is not read")
|
||||
}
|
||||
fn references(&self) -> Vec<(String, RecordRef, Option<Vec<u8>>)> {
|
||||
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<String>,
|
||||
|
|
@ -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<Vec<String>>) -> Option<Vec<String>> {
|
||||
match list {
|
||||
Some(values) if !values.is_empty() && values.iter().any(|value| !value.trim().is_empty()) => {
|
||||
Some(values.clone())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()?;
|
||||
|
|
|
|||
906
packages/cultnet-rs/tests/selection.rs
Normal file
906
packages/cultnet-rs/tests/selection.rs
Normal file
|
|
@ -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<Vec<u8>>)>,
|
||||
}
|
||||
|
||||
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<Vec<u8>>)>) -> 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<String> {
|
||||
match index {
|
||||
"kind" => self.kind.map(|v| vec![v.to_string()]).unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
fn number(&self, index: &str) -> Option<String> {
|
||||
if index == "mass" {
|
||||
self.mass.map(str::to_string)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
fn references(&self) -> Vec<(String, RecordRef, Option<Vec<u8>>)> {
|
||||
self.references.clone()
|
||||
}
|
||||
}
|
||||
|
||||
struct FixtureRowSet;
|
||||
|
||||
impl RowSet for FixtureRowSet {
|
||||
fn all_schema_ids(&self) -> Vec<String> {
|
||||
vec![
|
||||
"leaf_a".into(),
|
||||
"leaf_b".into(),
|
||||
"citer".into(),
|
||||
"narrow_citer".into(),
|
||||
]
|
||||
}
|
||||
fn declared_indexes(&self, schema_id: &str) -> Vec<String> {
|
||||
match schema_id {
|
||||
"leaf_a" | "leaf_b" => vec!["kind".into(), "mass".into()],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
fn declared_roles(&self, schema_id: &str) -> Vec<String> {
|
||||
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<String> {
|
||||
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<FixtureRow> {
|
||||
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<_>>(),
|
||||
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<_>>(),
|
||||
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<_>>(), 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<_>>(), 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<String> = 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<Vec<u8>> =
|
||||
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<_>>(), 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<Vector>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
#[serde(rename = "expectedIds", default)]
|
||||
expected_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
matched: u32,
|
||||
#[serde(rename = "hasNext", default)]
|
||||
has_next: bool,
|
||||
#[serde(rename = "expectedEdges", default)]
|
||||
expected_edges: Vec<VectorEdge>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
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<String>, u32, bool, Vec<VectorEdge>) {
|
||||
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<String> = 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<String> = 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()
|
||||
}
|
||||
233
scripts/mutate-cultnet-selection-rust.entries.ps1
Normal file
233
scripts/mutate-cultnet-selection-rust.entries.ps1
Normal file
|
|
@ -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) => {'
|
||||
}
|
||||
)
|
||||
273
tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs
Normal file
273
tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs
Normal file
|
|
@ -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<ParityMiddle>(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<string>() }, "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<CultNetSelection>(selectionBytes, CultNetSchemaMessageSerialization.Options);
|
||||
var asOf = vector.GetProperty("asOf").GetUInt64();
|
||||
|
||||
if (vector.TryGetProperty("expectedRefusalField", out var refusalField) && refusalField.ValueKind != JsonValueKind.Null)
|
||||
{
|
||||
var error = Assert.Throws<CultNetSelectionInvalidException>(() => 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<object>();
|
||||
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<CultNetSelectionInvalidException>(
|
||||
() => 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<string>(),
|
||||
matched = 0U,
|
||||
hasNext = false,
|
||||
expectedEdges = Array.Empty<object>()
|
||||
});
|
||||
}
|
||||
|
||||
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<ParityMiddle> Design;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue