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 at e5348d3):
- 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:
Meta Vens 2026-09-22 05:31:19 +02:00
commit b090ca97f9
8 changed files with 3559 additions and 21 deletions

View file

@ -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")

View file

@ -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::*;

File diff suppressed because it is too large Load diff

View file

@ -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,
}
}

View file

@ -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(&registry, &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()?;

View 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()
}

View 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) => {'
}
)

View 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;
}
}
}