CultNet selection cut1 Rust fix batch: R-B/C/D/E/F/G/H/I

The Rust half of the Cut 1 fix batch (docs/cultnet-selection-cut.md, "Self's
rulings for the Cut 1 fix batch, 2026-09-22"), against packages/cultnet-rs.

- R-B: hop edges now follow the hop's direction - cites keys an edge off its
  From (the citer), cited off its To (the citee), each in page-row order
  then (from, role, to) code-point order. Before this, both hop kinds keyed
  off From, so a cited selection's edges were silently empty unless the
  citer also happened to be on the page (Soul's F2). build_incoming_index
  iterates the caller's own row slice, never a HashMap, for its own order.
- R-C: code-point order was already Rust's default str::cmp (UTF-8 byte
  order); no change needed there.
- R-D: canonical_number::render_f32/render_f64 render the exact IEEE-754
  decimal expansion from mantissa and exponent, via a small fixed-point
  bignum (~60 lines), replacing the previous shortest-round-trip-form path
  that rounded ties differently from .NET. expand_scientific_notation and
  its test are gone (R-M); the Display probe test is kept per the map.
- R-E: schema_alias (ported from CultNetSchemaAliasMatching's string
  overload) is the one schema-identity rule for `schemas` and
  `cites.target.schemaId`; an unmatched cites target is refused at the
  door. Found and fixed along the way: matches_schema_keys_fields must not
  use schema_alias::matches_any's own "empty candidates matches everything"
  shortcut (correct for the door/reachability call sites it mirrors from
  C#) - v0's snapshot server calls matches() directly, without validate(),
  so an empty-but-present schemas list must still match nothing there too.
- R-F: select() validates first and returns the typed refusal; matches()'s
  hop guard is a real assert!, not a debug_assert! a release build would
  compile out.
- R-G/P-1: Evaluation and SelectionPage both carry matched, the total match
  count, not the page count.
- R-H: Cursor::compute_digest is length-prefixed for every string and list,
  closing the collision Soul found (["a|b"] and ["a","b"] used to digest
  the same).
- R-I: select_page projects a selection into the wire's SelectionPage -
  headers or documents under Selection::projection, matched, and edges; a
  header projection carries no payload, in edges too (S20).

Test fixture: real SHA-256-shaped schema ids (sha256:<hex>.v1) replace the
old fixture's schema names doubling as ids, which had silently masked the
R-E gap (vectors passed only because "leaf_a" happened to already be both
the alias and the id).

Two documented discrepancies against the map's own worked numbers, both
verified independently outside this crate (Rust bit decomposition plus a
Node/BigInt cross-check): an f32 of 3e20 is 300000006012263202816, not the
map's 300000002010536247296; 1e-7 as f64 is its own 79-digit exact
expansion, not "0.0000001" (a stale shortest-form assertion this fix batch
did not originally touch).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-22 10:08:06 +02:00
commit 8b114ed353
3 changed files with 1054 additions and 230 deletions

View file

@ -5,7 +5,7 @@
"selectionMessagePackBase64": "g6ZmaWVsZHOSg6VpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORpndlYXBvboOlaW5kZXikbWFzc6JvcKJnZaZudW1iZXKhNapwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==",
"asOf": 1,
"expectedIds": [
"leaf_a/a-eq"
"sha256:b532cf011d665a43446d4083a95144877686692823a55a4c82b62038a7bea0e2.v1/a-eq"
],
"matched": 1,
"hasNext": false,
@ -16,11 +16,11 @@
"selectionMessagePackBase64": "g6pwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nw6VsaW1pdAM=",
"asOf": 1,
"expectedIds": [
"citer/citer-1",
"leaf_a/shared-3",
"leaf_b/shared-2"
"sha256:2cefd212c0eaffa1a9fbd68233146958cde9bf6e906fc9a549f968cd3d1f7646.v1/citer-1",
"sha256:b532cf011d665a43446d4083a95144877686692823a55a4c82b62038a7bea0e2.v1/shared-3",
"sha256:0ad8269a37926e20e76a6c70ea86f052ee4ae90bcf2665546b8b008ec5c8b144.v1/shared-2"
],
"matched": 3,
"matched": 7,
"hasNext": true,
"expectedEdges": []
},
@ -29,9 +29,9 @@
"selectionMessagePackBase64": "g6ZmaWVsZHORg6VpbmRleKRtYXNzom9womd0pm51bWJlcqExqnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfC",
"asOf": 1,
"expectedIds": [
"leaf_a/a-lo",
"leaf_a/a-eq",
"leaf_b/b-hi"
"sha256:b532cf011d665a43446d4083a95144877686692823a55a4c82b62038a7bea0e2.v1/a-lo",
"sha256:b532cf011d665a43446d4083a95144877686692823a55a4c82b62038a7bea0e2.v1/a-eq",
"sha256:0ad8269a37926e20e76a6c70ea86f052ee4ae90bcf2665546b8b008ec5c8b144.v1/b-hi"
],
"matched": 3,
"hasNext": false,
@ -39,23 +39,30 @@
},
{
"name": "incoming_exists_true",
"selectionMessagePackBase64": "hKdzY2hlbWFzkaZsZWFmX2GlY2l0ZWSCpHJvbGWmRGVzaWdupmV4aXN0c8OqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=",
"selectionMessagePackBase64": "hKdzY2hlbWFzkdlKc2hhMjU2OmI1MzJjZjAxMWQ2NjVhNDM0NDZkNDA4M2E5NTE0NDg3NzY4NjY5MjgyM2E1NWE0YzgyYjYyMDM4YTdiZWEwZTIudjGlY2l0ZWSCpHJvbGWmRGVzaWdupmV4aXN0c8OqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=",
"asOf": 1,
"expectedIds": [
"leaf_a/a-eq"
"sha256:b532cf011d665a43446d4083a95144877686692823a55a4c82b62038a7bea0e2.v1/a-eq"
],
"matched": 1,
"hasNext": false,
"expectedEdges": []
"expectedEdges": [
{
"fromId": "sha256:2cefd212c0eaffa1a9fbd68233146958cde9bf6e906fc9a549f968cd3d1f7646.v1/citer-1",
"role": "Design",
"toId": "sha256:b532cf011d665a43446d4083a95144877686692823a55a4c82b62038a7bea0e2.v1/a-eq",
"payloadBase64": null
}
]
},
{
"name": "incoming_negation",
"selectionMessagePackBase64": "hKdzY2hlbWFzkaZsZWFmX2GlY2l0ZWSCpHJvbGWmRGVzaWdupmV4aXN0c8KqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=",
"selectionMessagePackBase64": "hKdzY2hlbWFzkdlKc2hhMjU2OmI1MzJjZjAxMWQ2NjVhNDM0NDZkNDA4M2E5NTE0NDg3NzY4NjY5MjgyM2E1NWE0YzgyYjYyMDM4YTdiZWEwZTIudjGlY2l0ZWSCpHJvbGWmRGVzaWdupmV4aXN0c8KqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=",
"asOf": 1,
"expectedIds": [
"leaf_a/a-lo",
"leaf_a/shared-1",
"leaf_a/shared-3"
"sha256:b532cf011d665a43446d4083a95144877686692823a55a4c82b62038a7bea0e2.v1/a-lo",
"sha256:b532cf011d665a43446d4083a95144877686692823a55a4c82b62038a7bea0e2.v1/shared-1",
"sha256:b532cf011d665a43446d4083a95144877686692823a55a4c82b62038a7bea0e2.v1/shared-3"
],
"matched": 3,
"hasNext": false,
@ -66,14 +73,64 @@
"selectionMessagePackBase64": "g6ZmaWVsZHORg6VpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORpnNoYXJlZKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==",
"asOf": 1,
"expectedIds": [
"leaf_a/shared-1",
"leaf_b/shared-2",
"leaf_a/shared-3"
"sha256:b532cf011d665a43446d4083a95144877686692823a55a4c82b62038a7bea0e2.v1/shared-1",
"sha256:0ad8269a37926e20e76a6c70ea86f052ee4ae90bcf2665546b8b008ec5c8b144.v1/shared-2",
"sha256:b532cf011d665a43446d4083a95144877686692823a55a4c82b62038a7bea0e2.v1/shared-3"
],
"matched": 3,
"hasNext": false,
"expectedEdges": []
},
{
"name": "hop_by_role_cites_exact_id",
"selectionMessagePackBase64": "g6VjaXRlc4KmdGFyZ2V0gqhzY2hlbWFJZNlKc2hhMjU2OmI1MzJjZjAxMWQ2NjVhNDM0NDZkNDA4M2E5NTE0NDg3NzY4NjY5MjgyM2E1NWE0YzgyYjYyMDM4YTdiZWEwZTIudjGpcmVjb3JkS2V5pGEtZXGkcm9sZaZEZXNpZ26qcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=",
"asOf": 1,
"expectedIds": [
"sha256:2cefd212c0eaffa1a9fbd68233146958cde9bf6e906fc9a549f968cd3d1f7646.v1/citer-1"
],
"matched": 1,
"hasNext": false,
"expectedEdges": [
{
"fromId": "sha256:2cefd212c0eaffa1a9fbd68233146958cde9bf6e906fc9a549f968cd3d1f7646.v1/citer-1",
"role": "Design",
"toId": "sha256:b532cf011d665a43446d4083a95144877686692823a55a4c82b62038a7bea0e2.v1/a-eq",
"payloadBase64": null
}
]
},
{
"name": "hop_by_role_cites_alias_target",
"selectionMessagePackBase64": "g6VjaXRlc4KmdGFyZ2V0gqhzY2hlbWFJZNlHc2hhMjU2OmI1MzJjZjAxMWQ2NjVhNDM0NDZkNDA4M2E5NTE0NDg3NzY4NjY5MjgyM2E1NWE0YzgyYjYyMDM4YTdiZWEwZTKpcmVjb3JkS2V5pGEtZXGkcm9sZaZEZXNpZ26qcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=",
"asOf": 1,
"expectedIds": [
"sha256:2cefd212c0eaffa1a9fbd68233146958cde9bf6e906fc9a549f968cd3d1f7646.v1/citer-1"
],
"matched": 1,
"hasNext": false,
"expectedEdges": [
{
"fromId": "sha256:2cefd212c0eaffa1a9fbd68233146958cde9bf6e906fc9a549f968cd3d1f7646.v1/citer-1",
"role": "Design",
"toId": "sha256:b532cf011d665a43446d4083a95144877686692823a55a4c82b62038a7bea0e2.v1/a-eq",
"payloadBase64": null
}
]
},
{
"name": "schemas_by_alias",
"selectionMessagePackBase64": "g6dzY2hlbWFzkdlHc2hhMjU2OmI1MzJjZjAxMWQ2NjVhNDM0NDZkNDA4M2E5NTE0NDg3NzY4NjY5MjgyM2E1NWE0YzgyYjYyMDM4YTdiZWEwZTKqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=",
"asOf": 1,
"expectedIds": [
"sha256:b532cf011d665a43446d4083a95144877686692823a55a4c82b62038a7bea0e2.v1/a-lo",
"sha256:b532cf011d665a43446d4083a95144877686692823a55a4c82b62038a7bea0e2.v1/a-eq",
"sha256:b532cf011d665a43446d4083a95144877686692823a55a4c82b62038a7bea0e2.v1/shared-1",
"sha256:b532cf011d665a43446d4083a95144877686692823a55a4c82b62038a7bea0e2.v1/shared-3"
],
"matched": 4,
"hasNext": false,
"expectedEdges": []
},
{
"name": "refuses_empty_schemas_list",
"selectionMessagePackBase64": "g6dzY2hlbWFzkKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==",
@ -93,6 +150,16 @@
"matched": 0,
"hasNext": false,
"expectedEdges": []
},
{
"name": "refuses_unmatched_cites_target_schema",
"selectionMessagePackBase64": "g6VjaXRlc4KmdGFyZ2V0gqhzY2hlbWFJZLlzaGEyNTY6bm90LWEta25vd24tc2NoZW1hqXJlY29yZEtleaRhLWVxpHJvbGWmRGVzaWduqnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfC",
"asOf": 1,
"expectedRefusalField": "cites.target.schemaId",
"expectedIds": [],
"matched": 0,
"hasNext": false,
"expectedEdges": []
}
]
}

View file

@ -170,46 +170,6 @@ pub mod canonical_number {
}
}
/// A `double`/`float` that .NET or Rust's shortest round-trip `Display` would render in
/// exponent notation (`"1E+20"`, `"1e-7"`) rewritten to positional digits, before
/// [`canonicalize_decimal_digits`] runs. Mirrors `CultCache.cs`'s `ExpandScientificNotation`.
pub fn expand_scientific_notation(value: &str) -> String {
if value.find(['e', 'E']).is_none() {
return value.to_string();
}
let negative = value.starts_with('-');
let unsigned = if negative { &value[1..] } else { value };
let marker = unsigned.find(['e', 'E']).expect("marker found above");
let mantissa = &unsigned[..marker];
let exponent: i64 = unsigned[marker + 1..]
.parse()
.expect("Display never emits a non-numeric exponent");
let (digits, dot_index) = match mantissa.find('.') {
Some(dot) => (format!("{}{}", &mantissa[..dot], &mantissa[dot + 1..]), dot),
None => (mantissa.to_string(), mantissa.len()),
};
let point_position = dot_index as i64 + exponent;
let expanded = if point_position <= 0 {
format!("0.{}{}", "0".repeat((-point_position) as usize), digits)
} else if point_position as usize >= digits.len() {
format!(
"{digits}{}",
"0".repeat(point_position as usize - digits.len())
)
} else {
let split = point_position as usize;
format!("{}.{}", &digits[..split], &digits[split..])
};
if negative {
format!("-{expanded}")
} else {
expanded
}
}
/// Renders a signed 64-bit integer as its exact canonical decimal - no 2^53 limit, because this
/// never passes through a float.
pub fn render_i64(value: i64) -> String {
@ -221,20 +181,107 @@ pub mod canonical_number {
canonicalize_decimal_digits(&value.to_string())
}
// R-D (docs/cultnet-selection-cut.md, "Self's rulings for the Cut 1 fix batch"): a float
// renders as its exact decimal expansion, computed from mantissa and exponent, not as a
// shortest round-trip form (the previous `f64::to_string()` path rounded ties the wrong way
// relative to .NET - Soul's parity vectors). Every finite f32/f64 is an exact dyadic rational
// `mantissa * 2^exponent`; this is a small fixed-point bignum (decimal digits, little-endian)
// that computes that expansion exactly, with no precision loss and no new dependency.
/// One decimal digit per element, least-significant first.
type BigDigits = Vec<u8>;
fn big_from_u64(mut value: u64) -> BigDigits {
if value == 0 {
return vec![0];
}
let mut digits = Vec::new();
while value > 0 {
digits.push((value % 10) as u8);
value /= 10;
}
digits
}
/// Multiplies `digits` in place by a one-digit factor (2 or 5, here - the two primes 2^k and
/// 5^k need to turn a binary mantissa into an exact decimal one).
/// Multiplies `digits` in place by a single-digit `factor` (only ever called with 2 or 5, the
/// two primes that turn a binary mantissa's magnitude into a decimal one). A single-digit
/// factor against a single digit plus a carry that is itself always a single digit
/// (inductively: it starts at 0) never produces a carry of 10 or more, so the leftover carry
/// needs at most one new leading digit - never a loop (the mutation harness's own
/// RS-D-BigMulCarry-Loosening entry proved a `while` here is unreachable generality: nothing
/// distinguishes it from a plain `if`).
fn big_mul_small(digits: &mut BigDigits, factor: u8) {
debug_assert!(factor <= 9, "big_mul_small takes a single decimal digit as its factor");
let mut carry: u32 = 0;
for digit in digits.iter_mut() {
let product = (*digit as u32) * (factor as u32) + carry;
*digit = (product % 10) as u8;
carry = product / 10;
}
if carry > 0 {
digits.push(carry as u8);
}
}
fn big_to_most_significant_first(digits: &BigDigits) -> String {
digits.iter().rev().map(|d| (b'0' + d) as char).collect()
}
/// The exact decimal expansion of `(-1)^negative * mantissa * 2^exponent`. `mantissa` carries
/// no implicit bit; the caller has already folded it in (or not, for a subnormal).
fn exact_decimal_from_mantissa(mantissa: u64, exponent: i32, negative: bool) -> String {
// No mantissa == 0 short-circuit: the general path already collapses to "0" for a zero
// mantissa (multiplying zero stays zero at every step, and `canonicalize_decimal_digits`
// collapses the signed "-0" case too) - a dedicated early return here duplicated that
// without changing the answer, and the mutation harness's own RS-D-ZeroMantissa-Revert
// entry proved it: removing the guard entirely left the test green.
let mut digits = big_from_u64(mantissa);
let unsigned = if exponent >= 0 {
for _ in 0..exponent {
big_mul_small(&mut digits, 2);
}
big_to_most_significant_first(&digits)
} else {
// mantissa / 2^k == (mantissa * 5^k) / 10^k: multiplying by 5^k turns the binary
// fraction into a decimal one with exactly k fraction digits.
let k = (-exponent) as usize;
for _ in 0..k {
big_mul_small(&mut digits, 5);
}
let mut rendered = big_to_most_significant_first(&digits);
if rendered.len() <= k {
rendered = format!("{}{rendered}", "0".repeat(k + 1 - rendered.len()));
}
let split = rendered.len() - k;
format!("{}.{}", &rendered[..split], &rendered[split..])
};
canonicalize_decimal_digits(&if negative {
format!("-{unsigned}")
} else {
unsigned
})
}
/// Renders a `f64` as its canonical decimal, or `None` for NaN/infinity ("a NaN or infinite
/// member matches no comparison"). Rust's `Display` for `f64` is the shortest round-trip form
/// and - verified by `probe_f64_display_never_uses_exponent_notation` in this module's tests,
/// per the map's instruction not to trust the claim - never emits exponent notation for a finite
/// value, so `expand_scientific_notation` is a no-op on this path today; it stays, because that
/// is a fact about the current standard library, not a language guarantee, and the C# side needs
/// it for real.
/// member matches no comparison"). Built from IEEE-754's own mantissa and exponent (R-D) -
/// never from `Display`'s shortest round-trip form, which rounds ties differently than .NET's
/// `ToString()` (Soul's parity vectors, 394/200k f32 and 48/200k f64 values at ties).
pub fn render_f64(value: f64) -> Option<String> {
if value.is_nan() || value.is_infinite() {
return None;
}
Some(canonicalize_decimal_digits(&expand_scientific_notation(
&value.to_string(),
)))
let bits = value.to_bits();
let negative = (bits >> 63) & 1 == 1;
let biased_exponent = ((bits >> 52) & 0x7FF) as i32;
let fraction = bits & ((1u64 << 52) - 1);
let (mantissa, exponent) = if biased_exponent == 0 {
(fraction, -1074) // subnormal: 1 - 1023 (bias) - 52 (fraction bits)
} else {
(fraction | (1u64 << 52), biased_exponent - 1023 - 52)
};
Some(exact_decimal_from_mantissa(mantissa, exponent, negative))
}
/// Renders a `f32` as its canonical decimal, or `None` for NaN/infinity.
@ -242,17 +289,24 @@ pub mod canonical_number {
if value.is_nan() || value.is_infinite() {
return None;
}
Some(canonicalize_decimal_digits(&expand_scientific_notation(
&value.to_string(),
)))
let bits = value.to_bits();
let negative = (bits >> 31) & 1 == 1;
let biased_exponent = ((bits >> 23) & 0xFF) as i32;
let fraction = (bits & ((1u32 << 23) - 1)) as u64;
let (mantissa, exponent) = if biased_exponent == 0 {
(fraction, -149) // subnormal: 1 - 127 (bias) - 23 (fraction bits)
} else {
(fraction | (1u64 << 23), biased_exponent - 127 - 23)
};
Some(exact_decimal_from_mantissa(mantissa, exponent, negative))
}
#[cfg(test)]
mod tests {
// The map requires this claim probed, not trusted: Rust's Display for f64 is documented to
// never use scientific notation, unlike .NET's default ToString(). If a future toolchain
// changes this, expand_scientific_notation stops being a no-op and this probe starts failing
// loudly instead of a canonical number silently carrying an 'E'.
// R-D no longer renders through `Display` at all (render_f64/render_f32 build the exact
// decimal straight from mantissa and exponent), so this probe no longer guards a
// production path - it is kept anyway, on the map's instruction, as a standing record of
// what Rust's `Display` does for a value this module used to hand it.
#[test]
fn probe_f64_display_never_uses_exponent_notation() {
let huge = 1e21_f64;
@ -296,22 +350,18 @@ pub mod canonical_number {
#[test]
fn render_f64_rewrites_exponent_notation_to_positional_digits() {
// 1e21 and 3.5 are both exact dyadic rationals, so their exact expansion is also
// their shortest form.
assert_eq!(super::render_f64(1e21).unwrap(), "1000000000000000000000");
assert_eq!(super::render_f64(1e-7).unwrap(), "0.0000001");
assert_eq!(super::render_f64(3.5).unwrap(), "3.5");
}
// Exercised directly with hand-built exponent-bearing strings, independent of whether
// Display ever actually produces one for f64/f32 (probe_f64_display_never_uses_exponent_notation
// shows it currently does not - this function exists so the algorithm is correct and pinned
// regardless of that upstream fact, mirroring CultCache.cs's ExpandScientificNotation, which
// C#'s ToString() does exercise for real).
#[test]
fn expand_scientific_notation_rewrites_hand_built_exponent_forms() {
assert_eq!(super::expand_scientific_notation("1.5E3"), "1500");
assert_eq!(super::expand_scientific_notation("1e-7"), "0.0000001");
assert_eq!(super::expand_scientific_notation("-2.5e2"), "-250");
assert_eq!(super::expand_scientific_notation("42"), "42");
// 1e-7 is not exactly representable in binary - R-D renders the value actually
// stored, not the shortest decimal that would round-trip back to it (verified
// independently outside this crate: 1e-7_f64.to_bits() decomposes to mantissa
// 5764607523034235 * 2^-106, whose exact decimal is this 79-digit expansion).
assert_eq!(
super::render_f64(1e-7).unwrap(),
"0.0000000999999999999999954748111825886258685613938723690807819366455078125"
);
}
#[test]
@ -321,6 +371,149 @@ pub mod canonical_number {
assert_eq!(super::render_f32(f32::NAN), None);
assert_eq!(super::render_f32(f32::NEG_INFINITY), None);
}
// R-D: an f32 of 3e20 is not "3e20" or "300000000000000000000", it is the exact IEEE-754
// value nearest 3e20. The map's own worked example (and Soul's notes) give this as
// "300000002010536247296" - that literal is wrong: 3e20_f32.to_bits() is
// 0b01100001100000100001101010110001 (sign 0, exponent 195, fraction 137905), so mantissa
// 8526513 * 2^45 - verified independently in Node with BigInt bit-shifting outside this
// crate - is 300000006012263202816, which is what this algorithm (and a plain `mantissa
// << exponent`) computes. Flagged as a discrepancy in the fix-batch report; this test
// pins the value this algorithm can prove, not the map's transcription.
#[test]
fn render_f32_3e20_is_the_exact_ieee754_value_not_the_shortest_form() {
assert_eq!(super::render_f32(3e20_f32).unwrap(), "300000006012263202816");
}
// R-D: 0.1 has no exact binary representation, so its f32/f64 renderings are long and
// differ from each other - a shortest-form renderer would print "0.1" for both.
#[test]
fn render_f32_and_f64_of_0_1_render_the_distinct_exact_binary_values() {
let as_f32 = super::render_f32(0.1_f32).unwrap();
let as_f64 = super::render_f64(0.1_f64).unwrap();
assert_ne!(as_f32, as_f64, "f32 and f64 round 0.1 to different exact values");
// Round-trip through Rust's own correctly-rounded parser: the exact decimal expansion
// must parse back to the identical bit pattern it was rendered from.
assert_eq!(as_f32.parse::<f32>().unwrap().to_bits(), 0.1_f32.to_bits());
assert_eq!(as_f64.parse::<f64>().unwrap().to_bits(), 0.1_f64.to_bits());
assert_ne!(as_f32, "0.1", "the exact expansion is far longer than the shortest form");
}
// R-D: subnormals (the smallest representable magnitudes, below the normal range) still
// render exactly, and round-trip through a correctly-rounded parser.
#[test]
fn render_f32_and_f64_subnormals_round_trip() {
let smallest_f32 = f32::from_bits(1); // smallest positive subnormal f32
let smallest_f64 = f64::from_bits(1); // smallest positive subnormal f64
let rendered_f32 = super::render_f32(smallest_f32).unwrap();
let rendered_f64 = super::render_f64(smallest_f64).unwrap();
assert_eq!(rendered_f32.parse::<f32>().unwrap().to_bits(), smallest_f32.to_bits());
assert_eq!(rendered_f64.parse::<f64>().unwrap().to_bits(), smallest_f64.to_bits());
assert!(!rendered_f32.contains(['e', 'E']));
assert!(!rendered_f64.contains(['e', 'E']));
}
// R-D: the largest finite magnitudes round-trip too - these are the widest bignums this
// algorithm produces (f64::MAX has 309 integer digits).
#[test]
fn render_f32_and_f64_max_round_trip() {
let rendered_f32 = super::render_f32(f32::MAX).unwrap();
let rendered_f64 = super::render_f64(f64::MAX).unwrap();
assert_eq!(rendered_f32.parse::<f32>().unwrap().to_bits(), f32::MAX.to_bits());
assert_eq!(rendered_f64.parse::<f64>().unwrap().to_bits(), f64::MAX.to_bits());
assert!(!rendered_f32.starts_with('-'));
assert!(!rendered_f64.starts_with('-'));
}
// R-D: positive and negative zero both render as the canonical "0", never "-0".
#[test]
fn render_f32_and_f64_positive_and_negative_zero_render_as_canonical_zero() {
assert_eq!(super::render_f32(0.0_f32).unwrap(), "0");
assert_eq!(super::render_f32(-0.0_f32).unwrap(), "0");
assert_eq!(super::render_f64(0.0_f64).unwrap(), "0");
assert_eq!(super::render_f64(-0.0_f64).unwrap(), "0");
}
}
}
// ---------------------------------------------------------------------------------------------
// Schema-alias matching (R-E)
// ---------------------------------------------------------------------------------------------
/// The one schema-identity rule (R-E), ported from the C# reference's
/// `CultNetSchemaAliasMatching.Matches(string, string)` / `MatchesAny`. `selection.schemas` and
/// `cites.target.schemaId` both go through this - D7: Rust carries no `CultDocumentDescriptor`
/// knowledge, so (unlike the C# reference's richer descriptor-aware overload) this only ever
/// compares two schema-id strings, exactly or by their inferred name (the part before a trailing
/// `.v<digits>`).
pub mod schema_alias {
/// `true` when `candidate` names `schema_id` exactly, or the two share an inferred name.
/// Mirrors `CultNetSchemaAliasMatching.Matches(string candidate, string schemaId)`.
pub fn matches(candidate: &str, schema_id: &str) -> bool {
if candidate == schema_id {
return true;
}
let candidate_name = infer_schema_name(candidate).unwrap_or(candidate);
let schema_name = infer_schema_name(schema_id).unwrap_or(schema_id);
candidate_name == schema_name
}
/// `true` when `candidates` is empty (no filter) or any candidate matches `schema_id`.
/// Mirrors `CultNetSchemaAliasMatching.MatchesAny(IReadOnlyList<string>, string)`.
pub fn matches_any(candidates: &[String], schema_id: &str) -> bool {
candidates.is_empty() || candidates.iter().any(|candidate| matches(candidate, schema_id))
}
/// The part of `schema_id` before a trailing `.v<digits>` marker, or `None` when `schema_id`
/// carries no such marker (real hashed ids, e.g. `"sha256:<hex>"`, carry none on their own -
/// aliasing only bridges two ids that share the same unversioned name). Mirrors
/// `CultNetSchemaAliasMatching.InferSchemaName`.
fn infer_schema_name(schema_id: &str) -> Option<&str> {
let marker = schema_id.rfind(".v")?;
if marker == 0 {
return None;
}
let version = &schema_id[marker + 2..];
if version.is_empty() || !version.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
Some(&schema_id[..marker])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn matches_exact_ids() {
assert!(matches("sha256:abc", "sha256:abc"));
assert!(!matches("sha256:abc", "sha256:def"));
}
#[test]
fn matches_by_inferred_name_across_versions() {
assert!(matches("sha256:abc.v1", "sha256:abc.v2"));
assert!(matches("sha256:abc", "sha256:abc.v1"));
assert!(matches("sha256:abc.v1", "sha256:abc"));
}
#[test]
fn does_not_match_a_real_hashed_id_with_no_shared_name() {
assert!(!matches("sha256:abc", "sha256:xyz"));
}
#[test]
fn matches_any_empty_candidates_matches_everything() {
assert!(matches_any(&[], "sha256:anything"));
}
#[test]
fn infer_schema_name_refuses_a_leading_marker_and_a_non_numeric_or_empty_version() {
assert_eq!(infer_schema_name(".v1"), None);
assert_eq!(infer_schema_name("name.vX"), None);
assert_eq!(infer_schema_name("name.v"), None);
assert_eq!(infer_schema_name("name.v1"), Some("name"));
}
}
}
@ -397,7 +590,7 @@ impl RecordRef {
}
/// One predicate over a single declared index alias (section 2).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FieldPredicate {
/// A declared index alias, reachable on some schema the selection can reach.
@ -413,7 +606,7 @@ pub struct FieldPredicate {
}
/// Selects rows whose declared reference names a target row.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Citation {
pub target: RecordRef,
@ -423,7 +616,7 @@ pub struct Citation {
}
/// Selects rows some other row does or does not name in `role` - the one negation.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Incoming {
pub role: String,
@ -432,7 +625,7 @@ pub struct Incoming {
/// One typed selection, carried by `cultnet.snapshot_request.v1` and
/// `cultnet.database_subscribe.v1` (section 2).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Selection {
/// Kinds: schema ids. Absent = every schema. (No alias matching in this runtime - D7: Rust's
@ -489,7 +682,7 @@ impl Selection {
}
/// One edge a hop traversed, carried on a selection page.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Edge {
/// The citing row.
@ -526,7 +719,7 @@ mod optional_bytes {
}
/// A header record: [`SelectionDocumentRecord`] minus its payload and payload encoding.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RawDocumentHeader {
pub schema_id: String,
@ -554,7 +747,7 @@ pub struct RawDocumentHeader {
/// reference's `CultNetRawDocumentRecord` that this cut does not touch (out of scope: v0's wire is
/// unchanged). v1 reuses the reference's richer, already-existing C# type, so this Rust type matches
/// that one, not v0's.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SelectionDocumentRecord {
pub schema_id: String,
@ -584,24 +777,6 @@ fn messagepack_encoding() -> String {
"messagepack".to_string()
}
impl RawDocumentHeader {
/// Builds a header by dropping a document record's payload.
pub fn from_document(document: &SelectionDocumentRecord) -> Self {
Self {
schema_id: document.schema_id.clone(),
schema_name: document.schema_name.clone(),
schema_version: document.schema_version.clone(),
schema_content_hash: document.schema_content_hash.clone(),
record_key: document.record_key.clone(),
stored_at: document.stored_at.clone(),
source_runtime_id: document.source_runtime_id.clone(),
source_agent_id: document.source_agent_id.clone(),
source_role: document.source_role.clone(),
tags: document.tags.clone(),
}
}
}
// ---------------------------------------------------------------------------------------------
// Refusals (section 2/3, typed at the door)
// ---------------------------------------------------------------------------------------------
@ -826,6 +1001,22 @@ pub fn validate(selection: &Selection, row_set: &impl RowSet) -> Result<(), Sele
"selection.cites.target requires schemaId and recordKey.",
));
}
// R-E: cites.target.schemaId goes through the one schema-identity rule too. An unmatched
// target is refused at the door, never silently answered with an empty page.
if !row_set
.all_schema_ids()
.iter()
.any(|schema_id| schema_alias::matches(&cites.target.schema_id, schema_id))
{
return Err(SelectionInvalid::new(
"cites.target.schemaId",
Some(cites.target.schema_id.clone()),
format!(
"selection.cites.target.schemaId {:?} matches no known schema.",
cites.target.schema_id
),
));
}
if let Some(role) = &cites.role
&& !any_schema_declares_role(row_set, role)
{
@ -865,9 +1056,10 @@ pub fn reachable_schemas(selection: &Selection, row_set: &impl RowSet) -> Vec<St
let all = row_set.all_schema_ids();
match &selection.schemas {
None => all,
// R-E: reachability goes through the alias matcher, not exact equality.
Some(schemas) => all
.into_iter()
.filter(|id| schemas.iter().any(|s| s == id))
.filter(|id| schema_alias::matches_any(schemas, id))
.collect(),
}
}
@ -1004,8 +1196,13 @@ pub const LIMIT_MAX: u32 = 200;
/// The single-row fast path (D6): schemas, keys and fields only. A hop-bearing selection
/// ([`Selection::has_hop`]) is set-dependent and must not use this path - the caller reconciles the
/// full row set with [`select`] instead.
///
/// R-F: this is a real, always-on `assert!`, not a `debug_assert!` - a release build must not
/// silently ignore `cites`/`cited` by skipping straight to the schema/keys/fields comparison; a
/// caller that reaches this with a hop-bearing selection has a programming error to fix, not a
/// page to answer wrong.
pub fn matches<R: Row>(row: &R, selection: &Selection) -> bool {
debug_assert!(
assert!(
!selection.has_hop(),
"a hop-bearing selection (cites/cited) is set-dependent and cannot use the single-row \
matches fast path; reconcile the full row set with select() instead"
@ -1014,8 +1211,16 @@ pub fn matches<R: Row>(row: &R, selection: &Selection) -> bool {
}
fn matches_schema_keys_fields<R: Row>(row: &R, selection: &Selection) -> bool {
// R-E, but deliberately not `schema_alias::matches_any`: that helper's `candidates.is_empty()`
// shortcut mirrors the C# reference's own `MatchesAny(schemaId)` on purpose (R-E), but this is
// the evaluator's fast path, reached without going through `validate` on the v0 lowering path
// (`serve_read_only_raw_snapshot`, which never calls `select`/`validate` by design - v0 has its
// own null-collapsing). An empty-but-present `schemas` must never be silently read as "every
// schema" here - only `None` means no filter. A plain `.any()` over an empty slice is already
// `false` (matches nothing), which is what a present-but-empty list must do until something
// upstream (the v1 door, or v0's own lowering) turns it into `None` or refuses it outright.
if let Some(schemas) = &selection.schemas
&& !schemas.iter().any(|s| s == row.schema_id())
&& !schemas.iter().any(|candidate| schema_alias::matches(candidate, row.schema_id()))
{
return false;
}
@ -1079,6 +1284,9 @@ pub struct EdgeMatch<R: Row + Clone> {
#[derive(Clone, Debug)]
pub struct Evaluation<R: Row + Clone> {
pub rows: Vec<R>,
/// The total number of matches (C5, R-G/P-1) - not the page count. One evaluation answers
/// both v0 and shard paging as a single snapshot.
pub matched: u32,
pub edges: Vec<EdgeMatch<R>>,
pub next_cursor: Option<String>,
}
@ -1086,12 +1294,17 @@ pub struct Evaluation<R: Row + Clone> {
/// Evaluates a selection over the full row set: schemas/keys/fields, the hop (`cites`/`cited`, with
/// its edges and the `reference_outside_target` refusal), order, cursor and paging. Mirrors
/// `CultNetSelectionEvaluator.Select`.
///
/// R-F: the door is inside `select` - this validates first and returns the typed refusal; there is
/// no public entry point that evaluates an unvalidated selection.
pub fn select<R: Row + Clone>(
row_set: &impl RowSet,
all_rows: &[R],
selection: &Selection,
as_of: u64,
) -> Result<Evaluation<R>, SelectionRefusal> {
validate(selection, row_set)?;
let by_key: HashMap<&str, &R> = all_rows.iter().map(|row| (row.record_key(), row)).collect();
let mut candidates: Vec<&R> = all_rows
@ -1102,7 +1315,7 @@ pub fn select<R: Row + Clone>(
let mut edges: Vec<EdgeMatch<R>> = Vec::new();
if let Some(cited) = &selection.cited {
let incoming = build_incoming_index(row_set, &by_key, &cited.role, &mut edges)?;
let incoming = build_incoming_index(row_set, all_rows, &by_key, &cited.role, &mut edges)?;
let exists = cited.exists;
candidates.retain(|row| incoming.contains(row.record_key()) == exists);
}
@ -1119,6 +1332,7 @@ pub fn select<R: Row + Clone>(
let mut matched = candidates;
matched.sort_by(|a, b| order_rows(*a, *b, selection.descending));
let matched_total = matched.len() as u32;
let mut start_index = 0usize;
if let Some(cursor_text) = selection.cursor.as_deref()
@ -1156,23 +1370,155 @@ pub fn select<R: Row + Clone>(
None
};
let page_keys: HashSet<&str> = page.iter().map(|row| row.record_key()).collect();
// R-B: hop edges follow the hop's direction. Under `cites`, the edges are the ones *from*
// page rows (the citer is what is paged); under `cited`, they are the ones *into* page rows
// (the citee is what is paged). Order is deterministic: page-row order, then (from, role, to)
// in code-point order - never a `HashMap`'s iteration order.
let page_edges = if selection.has_hop() {
edges
let page_position: HashMap<&str, usize> = page
.iter()
.enumerate()
.map(|(index, row)| (row.record_key(), index))
.collect();
let cited_direction = selection.cited.is_some();
let mut kept: Vec<EdgeMatch<R>> = edges
.into_iter()
.filter(|edge| page_keys.contains(edge.from.record_key()))
.collect()
.filter(|edge| {
let owner = if cited_direction { edge.to.record_key() } else { edge.from.record_key() };
page_position.contains_key(owner)
})
.collect();
kept.sort_by(|a, b| {
let owner = |edge: &EdgeMatch<R>| -> usize {
let key = if cited_direction { edge.to.record_key() } else { edge.from.record_key() };
page_position[key]
};
owner(a)
.cmp(&owner(b))
.then_with(|| a.from.schema_id().cmp(b.from.schema_id()))
.then_with(|| a.from.record_key().cmp(b.from.record_key()))
.then_with(|| a.role.cmp(&b.role))
.then_with(|| a.to.schema_id().cmp(b.to.schema_id()))
.then_with(|| a.to.record_key().cmp(b.to.record_key()))
});
kept
} else {
Vec::new()
};
Ok(Evaluation {
rows: page,
matched: matched_total,
edges: page_edges,
next_cursor,
})
}
// ---------------------------------------------------------------------------------------------
// Page projection (R-I): `cultnet.snapshot_response_raw.v1`'s wire shape
// ---------------------------------------------------------------------------------------------
/// `cultnet.snapshot_response_raw.v1`'s page (section 2 `SelectionPage`): `matched`, the page
/// itself under [`Selection::projection`], and the hop's edges. Carried on the wire by the
/// consumer's own message envelope (`shardId`/`shardEpoch`/`shardLogSequence`, `messageId`, ... -
/// declared once already in `contracts.rs`'s `CultNetMessage::SnapshotResponseRawV1`); this type
/// is the page body those fields wrap.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SelectionPage {
pub matched: u32,
pub as_of: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub headers: Option<Vec<RawDocumentHeader>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub documents: Option<Vec<SelectionDocumentRecord>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub edges: Option<Vec<Edge>>,
}
/// Evaluates a selection and projects it into the wire's `SelectionPage` shape (R-I): `select`'s
/// generic rows become `headers` or `documents` under [`Selection::projection`], never both, and a
/// header projection carries no payload - in the page's rows and in its edges alike (S20). The
/// caller supplies `document_record`, the one place a full wire record is built from an `R` (D7:
/// the evaluator never reflects over a row; the row owner renders it).
pub fn select_page<R: Row + Clone>(
row_set: &impl RowSet,
all_rows: &[R],
selection: &Selection,
as_of: u64,
document_record: impl Fn(&R) -> SelectionDocumentRecord,
) -> Result<SelectionPage, SelectionRefusal> {
let evaluation = select(row_set, all_rows, selection, as_of)?;
let want_document = selection.projection == PROJECTION_DOCUMENT;
let (headers, documents) = if want_document {
(None, Some(evaluation.rows.iter().map(&document_record).collect()))
} else {
(
Some(
evaluation
.rows
.iter()
.map(|row| header_from_record(&document_record(row)))
.collect(),
),
None,
)
};
let edges = if selection.has_hop() {
Some(
evaluation
.edges
.iter()
.map(|edge| Edge {
from: RecordRef::new(edge.from.schema_id(), edge.from.record_key()),
role: edge.role.clone(),
to: RecordRef::new(edge.to.schema_id(), edge.to.record_key()),
// S20: a header projection carries no payload, in edges too.
payload_encoding: if want_document && edge.payload.is_some() {
Some(messagepack_encoding())
} else {
None
},
payload: if want_document { edge.payload.clone() } else { None },
})
.collect(),
)
} else {
None
};
Ok(SelectionPage {
matched: evaluation.matched,
as_of,
next: evaluation.next_cursor,
headers,
documents,
edges,
})
}
/// A header carries every field a document record does, minus its payload and payload encoding
/// (private: R-M deleted the public, zero-caller `RawDocumentHeader::from_document`; this is the
/// one real caller, folded in rather than resurrecting that associated function under a new name).
fn header_from_record(document: &SelectionDocumentRecord) -> RawDocumentHeader {
RawDocumentHeader {
schema_id: document.schema_id.clone(),
schema_name: document.schema_name.clone(),
schema_version: document.schema_version.clone(),
schema_content_hash: document.schema_content_hash.clone(),
record_key: document.record_key.clone(),
stored_at: document.stored_at.clone(),
source_runtime_id: document.source_runtime_id.clone(),
source_agent_id: document.source_agent_id.clone(),
source_role: document.source_role.clone(),
tags: document.tags.clone(),
}
}
fn order_rows<R: Row>(a: &R, b: &R, descending: bool) -> Ordering {
let ordering = a
.ordinal()
@ -1246,7 +1592,8 @@ fn matches_citation<R: Row + Clone>(
continue;
};
ensure_within_declared_target(row_set, &role, citer, *resolved)?;
if resolved.schema_id() != citation.target.schema_id {
// R-E: the target's schema id is matched through the one alias rule, not exact equality.
if !schema_alias::matches(&citation.target.schema_id, resolved.schema_id()) {
continue;
}
edge_sink.push(EdgeMatch {
@ -1260,14 +1607,18 @@ fn matches_citation<R: Row + Clone>(
Ok(found)
}
// R-B: iterates `all_rows` (the caller's own `Vec` order), never `by_key.values()` - a `HashMap`'s
// iteration order is not something this function's output may depend on, even transiently, given
// `select`'s own final sort relies on nothing here having silently picked up map order.
fn build_incoming_index<R: Row + Clone>(
row_set: &impl RowSet,
all_rows: &[R],
by_key: &HashMap<&str, &R>,
role: &str,
edge_sink: &mut Vec<EdgeMatch<R>>,
) -> Result<HashSet<String>, SelectionRefusal> {
let mut incoming = HashSet::new();
for citer in by_key.values() {
for citer in all_rows {
for (edge_role, target, payload) in citer.references() {
if edge_role != role {
continue;
@ -1275,10 +1626,10 @@ fn build_incoming_index<R: Row + Clone>(
let Some(resolved) = by_key.get(target.record_key.as_str()) else {
continue;
};
ensure_within_declared_target(row_set, &edge_role, *citer, *resolved)?;
ensure_within_declared_target(row_set, &edge_role, citer, *resolved)?;
incoming.insert(target.record_key.clone());
edge_sink.push(EdgeMatch {
from: (*citer).clone(),
from: citer.clone(),
role: edge_role,
to: (*resolved).clone(),
payload,
@ -1307,7 +1658,6 @@ pub struct Cursor {
}
const CURSOR_FIELD_SEPARATOR: char = '\u{1}';
const DIGEST_FIELD_SEPARATOR: char = '\u{2}';
impl Cursor {
pub fn mint<R: Row>(as_of: u64, last_row: &R, selection: &Selection) -> String {
@ -1346,58 +1696,54 @@ impl Cursor {
/// A digest of the selection with `cursor` and `limit` cleared, so a cursor is bound to the
/// selection that minted it.
///
/// R-H: every string and list is length-prefixed, so no separator character can ever make two
/// distinct selections collide (Soul found `["a|b"]` and `["a","b"]` digesting the same under
/// the previous separator-joined scheme). This need not (and does not) byte-match the C#
/// reference's own digest - each server answers its own pages (see this type's doc comment) -
/// only the collision-freedom is load-bearing.
pub fn compute_digest(selection: &Selection) -> String {
let mut sorted_schemas = selection.schemas.clone().unwrap_or_default();
sorted_schemas.sort();
let mut sorted_keys = selection.keys.clone().unwrap_or_default();
sorted_keys.sort();
let fields_text = selection
.fields
.as_deref()
.unwrap_or_default()
.iter()
.map(|field| {
format!(
"{}:{}:{}:{}",
field.index,
field.op,
field.values.as_deref().unwrap_or_default().join("|"),
field.number.as_deref().unwrap_or_default()
)
})
.collect::<Vec<_>>()
.join(";");
let mut canonical = String::new();
write_length_prefixed_list(&mut canonical, &sorted_schemas);
write_length_prefixed_list(&mut canonical, &sorted_keys);
let cites_text = selection
.cites
.as_ref()
.map(|cites| {
format!(
"{}/{}:{}",
cites.target.schema_id,
cites.target.record_key,
cites.role.as_deref().unwrap_or_default()
)
})
.unwrap_or_default();
let fields = selection.fields.as_deref().unwrap_or_default();
canonical.push_str(&fields.len().to_string());
canonical.push('{');
for field in fields {
write_length_prefixed(&mut canonical, &field.index);
write_length_prefixed(&mut canonical, &field.op);
write_length_prefixed_list(&mut canonical, field.values.as_deref().unwrap_or_default());
write_length_prefixed(&mut canonical, field.number.as_deref().unwrap_or_default());
}
canonical.push('}');
let cited_text = selection
.cited
.as_ref()
.map(|cited| format!("{}:{}", cited.role, cited.exists))
.unwrap_or_default();
match &selection.cites {
Some(cites) => {
canonical.push('1');
write_length_prefixed(&mut canonical, &cites.target.schema_id);
write_length_prefixed(&mut canonical, &cites.target.record_key);
write_length_prefixed(&mut canonical, cites.role.as_deref().unwrap_or_default());
}
None => canonical.push('0'),
}
let canonical = [
sorted_schemas.join(","),
sorted_keys.join(","),
fields_text,
cites_text,
cited_text,
selection.projection.clone(),
selection.descending.to_string(),
]
.join(&DIGEST_FIELD_SEPARATOR.to_string());
match &selection.cited {
Some(cited) => {
canonical.push('1');
write_length_prefixed(&mut canonical, &cited.role);
canonical.push(if cited.exists { 'T' } else { 'F' });
}
None => canonical.push('0'),
}
write_length_prefixed(&mut canonical, &selection.projection);
canonical.push(if selection.descending { 'D' } else { 'A' });
let mut hasher = Sha256::new();
hasher.update(canonical.as_bytes());
@ -1408,3 +1754,21 @@ impl Cursor {
.collect()
}
}
/// Writes `value` as `<byte length>:<value>` - unambiguous regardless of what characters `value`
/// itself carries, including the separator this module would otherwise use.
fn write_length_prefixed(buffer: &mut String, value: &str) {
buffer.push_str(&value.len().to_string());
buffer.push(':');
buffer.push_str(value);
}
/// Writes a list as `<count>[<len-prefixed item><len-prefixed item>...]`.
fn write_length_prefixed_list(buffer: &mut String, values: &[String]) {
buffer.push_str(&values.len().to_string());
buffer.push('[');
for value in values {
write_length_prefixed(buffer, value);
}
buffer.push(']');
}

View file

@ -8,8 +8,10 @@ use std::fs;
use std::path::Path;
use cultnet_rs::{
Citation, FieldPredicate, Incoming, RecordRef, Row, RowSet, Selection, SelectionOperator,
SelectionRefusal, canonical_number, select, validate,
Citation, Cursor, CultNetMessage, CultNetWireContract, Edge, FieldPredicate, Incoming,
RawDocumentHeader, RecordRef, Row, RowSet, Selection, SelectionDocumentRecord, SelectionOperator,
SelectionPage, SelectionRefusal, canonical_number, decode_cultnet_message_from_slice,
encode_cultnet_message_to_vec, schema_alias, select, select_page, validate,
};
// ------------------------------------------------------------------------------------------
@ -19,8 +21,24 @@ use cultnet_rs::{
// 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.
//
// Soul's finding (soul-ss4-notes.md, F3): the earlier fixture used the schema *names* ("leaf_a",
// "leaf_b", ...) as their own schema ids, so every `schemas`/`cites.target` selection matched by
// plain string equality and never exercised the alias matcher at all - the real gap (C# resolves
// `["leaf_a"]` against a schema's declared name; Rust's exact-id compare gave 0 rows) was
// invisible. These are real SHA-256-shaped ids (`sha256:<hex>`), the same shape
// `CultCache.cs`'s `Sha256(semanticFingerprint)` produces, each carrying a `.v1` version marker so
// `schema_alias::matches` has a real `.v<digits>` suffix to strip - `LEAF_A_ALIAS` is the same
// hash with no version marker, a realistic "cite this schema, any version" reference that only
// resolves to `LEAF_A` through the alias matcher (R-E), never through exact equality.
// ------------------------------------------------------------------------------------------
const LEAF_A: &str = "sha256:b532cf011d665a43446d4083a95144877686692823a55a4c82b62038a7bea0e2.v1";
const LEAF_A_ALIAS: &str = "sha256:b532cf011d665a43446d4083a95144877686692823a55a4c82b62038a7bea0e2";
const LEAF_B: &str = "sha256:0ad8269a37926e20e76a6c70ea86f052ee4ae90bcf2665546b8b008ec5c8b144.v1";
const CITER: &str = "sha256:2cefd212c0eaffa1a9fbd68233146958cde9bf6e906fc9a549f968cd3d1f7646.v1";
const NARROW_CITER: &str = "sha256:356840f80f14bda0186a33eb7aed41c1e644fd4ffc3b925ad0d7f10fe77b52cb.v1";
#[derive(Clone)]
struct FixtureRow {
schema_id: &'static str,
@ -45,7 +63,7 @@ impl FixtureRow {
fn citer(record_key: &'static str, ordinal: i64, references: Vec<(String, RecordRef, Option<Vec<u8>>)>) -> Self {
Self {
schema_id: "citer",
schema_id: CITER,
record_key,
ordinal,
kind: None,
@ -88,39 +106,39 @@ 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(),
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()],
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![
CITER => vec![
"parent".into(),
"related".into(),
"components".into(),
"Design".into(),
],
"narrow_citer" => vec!["narrow_ref".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"
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()]
vec![LEAF_A.into(), LEAF_B.into()]
}
"narrow_ref" => vec!["leaf_a".into()],
"narrow_ref" => vec![LEAF_A.into()],
_ => Vec::new(),
}
}
@ -132,18 +150,18 @@ fn rr(schema_id: &str, record_key: &str) -> RecordRef {
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::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"),
rr(LEAF_A, "a-eq"),
None,
)],
),
@ -259,7 +277,7 @@ fn hops_one_edge_by_declared_role() {
let rows = base_rows();
let selection = Selection {
cites: Some(Citation {
target: rr("leaf_a", "a-eq"),
target: rr(LEAF_A, "a-eq"),
role: Some("Design".into()),
}),
..Selection::default()
@ -277,19 +295,19 @@ fn hops_one_edge_by_declared_role() {
#[test]
fn cites_role_excludes_a_second_reference_at_the_same_target() {
let rows = vec![
FixtureRow::leaf("leaf_a", "a-eq", 1, "weapon", "5"),
FixtureRow::leaf(LEAF_A, "a-eq", 1, "weapon", "5"),
FixtureRow::citer(
"double-citer",
2,
vec![
("Design".to_string(), rr("leaf_a", "a-eq"), None),
("OtherRole".to_string(), rr("leaf_a", "a-eq"), None),
("Design".to_string(), rr(LEAF_A, "a-eq"), None),
("OtherRole".to_string(), rr(LEAF_A, "a-eq"), None),
],
),
];
let selection = Selection {
cites: Some(Citation {
target: rr("leaf_a", "a-eq"),
target: rr(LEAF_A, "a-eq"),
role: Some("Design".into()),
}),
..Selection::default()
@ -307,7 +325,7 @@ fn cited_exists_is_the_one_negation() {
&FixtureRowSet,
&rows,
&Selection {
schemas: Some(vec!["leaf_a".into()]),
schemas: Some(vec![LEAF_A.into()]),
cited: Some(Incoming { role: "Design".into(), exists: true }),
..Selection::default()
},
@ -320,7 +338,7 @@ fn cited_exists_is_the_one_negation() {
&FixtureRowSet,
&rows,
&Selection {
schemas: Some(vec!["leaf_a".into()]),
schemas: Some(vec![LEAF_A.into()]),
cited: Some(Incoming { role: "Design".into(), exists: false }),
..Selection::default()
},
@ -337,9 +355,9 @@ fn cited_exists_is_the_one_negation() {
#[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"),
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 {
@ -370,14 +388,14 @@ fn compares_numbers_at_the_boundary_for_all_four_operators() {
#[test]
fn refuses_an_edge_outside_its_declared_target() {
let rows = vec![
FixtureRow::leaf("leaf_b", "b", 1, "k", "1"),
FixtureRow::leaf(LEAF_B, "b", 1, "k", "1"),
FixtureRow {
schema_id: "narrow_citer",
schema_id: NARROW_CITER,
record_key: "bad",
ordinal: 2,
kind: None,
mass: None,
references: vec![("narrow_ref".to_string(), rr("leaf_b", "b"), None)],
references: vec![("narrow_ref".to_string(), rr(LEAF_B, "b"), None)],
},
];
let selection = Selection {
@ -397,8 +415,8 @@ fn dictionary_reference_entries_keep_distinct_payload_bytes() {
"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])),
("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>> =
@ -406,22 +424,21 @@ fn dictionary_reference_entries_keep_distinct_payload_bytes() {
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
// R-B: hop edges follow the hop's direction. A `cites` selection's page holds the citer, so its
// edges (From = citer) survive the page filter, 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.
// instead, so - as of R-B - its edges survive when their *To* is on the page, not their *From*:
// the citer ("assembly") need not itself be on the page at all. (Before R-B, Rust filtered both
// hop kinds by `edge.From` on the page, so a `cited` selection's edges were silently empty
// whenever the citer itself did not separately match - Soul's F2 finding.)
#[test]
fn cited_selections_page_the_citee_so_their_edges_do_not_survive_the_page_filter() {
fn cited_selections_page_the_citee_and_their_edges_survive_the_page_filter() {
let rows = vec![
FixtureRow::leaf("leaf_a", "part-1", 1, "k", "1"),
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]))],
vec![("components".to_string(), rr(LEAF_A, "part-1"), Some(vec![9]))],
),
];
let selection = Selection {
@ -430,7 +447,29 @@ fn cited_selections_page_the_citee_so_their_edges_do_not_survive_the_page_filter
};
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");
assert_eq!(evaluation.edges.len(), 1, "the citee (part-1) is on the page, so its incoming edge survives");
assert_eq!(evaluation.edges[0].from.record_key, "assembly");
assert_eq!(evaluation.edges[0].to.record_key, "part-1");
assert_eq!(evaluation.edges[0].payload, Some(vec![9]));
}
// R-B: the converse of the test above - a `cites` selection's edges still key off the *citer*
// being on the page (unchanged by R-B), so an edge whose citee is on the page but whose citer is
// not filtered out does not leak in under `cites`.
#[test]
fn cites_selections_still_page_the_citer_and_key_edges_off_it() {
let rows = vec![
FixtureRow::leaf(LEAF_A, "a-eq", 1, "weapon", "5"),
FixtureRow::citer("citer-1", 2, vec![("Design".to_string(), rr(LEAF_A, "a-eq"), None)]),
];
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].from.record_key, "citer-1");
}
// S23: the evaluator matches every row sharing an index value - not a cache's last-writer-wins
@ -510,7 +549,7 @@ fn validation_refuses_undeclared_index_role_and_empty_lists() {
#[test]
fn validation_refuses_a_blank_entry_in_schemas_or_keys() {
let blank_schema = Selection {
schemas: Some(vec!["leaf_a".into(), " ".into()]),
schemas: Some(vec![LEAF_A.into(), " ".into()]),
..Selection::default()
};
assert_eq!(validate(&blank_schema, &FixtureRowSet).unwrap_err().field, "schemas");
@ -565,7 +604,7 @@ fn selection_is_never_untagged() {
#[test]
fn selection_round_trips_through_named_messagepack() {
let selection = Selection {
schemas: Some(vec!["leaf_a".into(), "leaf_b".into()]),
schemas: Some(vec![LEAF_A.into(), LEAF_B.into()]),
keys: Some(vec!["k1".into()]),
fields: Some(vec![
FieldPredicate {
@ -581,7 +620,7 @@ fn selection_round_trips_through_named_messagepack() {
number: Some("5".into()),
},
]),
cites: Some(Citation { target: rr("leaf_a", "a-eq"), role: Some("Design".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,
@ -667,7 +706,9 @@ fn evaluate_vector(vector: &Vector) -> (Vec<String>, u32, bool, Vec<VectorEdge>)
.iter()
.map(|row| row_id(row.schema_id(), row.record_key()))
.collect();
let matched = ids.len() as u32;
// R-G/P-1: matched is the total match count, not the page count - the two differ whenever a
// vector's limit clips the page.
let matched = evaluation.matched;
let has_next = evaluation.next_cursor.is_some();
let edges = evaluation
.edges
@ -795,15 +836,9 @@ fn write_selection_vectors_for_the_reference() {
},
),
(
// 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()]),
schemas: Some(vec![LEAF_A.into()]),
cited: Some(Incoming { role: "Design".into(), exists: true }),
..Selection::default()
},
@ -811,7 +846,7 @@ fn write_selection_vectors_for_the_reference() {
(
"incoming_negation",
Selection {
schemas: Some(vec!["leaf_a".into()]),
schemas: Some(vec![LEAF_A.into()]),
cited: Some(Incoming { role: "Design".into(), exists: false }),
..Selection::default()
},
@ -828,6 +863,34 @@ fn write_selection_vectors_for_the_reference() {
..Selection::default()
},
),
// R-E/Soul "Settled": with real SHA-256 schema ids in the fixture, `cites.target.schemaId`
// agrees byte for byte across runtimes - the earlier name-as-id fixture masked this. This
// is the exact-id form.
(
"hop_by_role_cites_exact_id",
Selection {
cites: Some(Citation { target: rr(LEAF_A, "a-eq"), role: Some("Design".into()) }),
..Selection::default()
},
),
// R-E: the same citation, but the target is named by its unversioned alias
// (`LEAF_A_ALIAS`, the bare hash with no `.v1`) rather than the exact id - only the alias
// matcher bridges this to the real, versioned schema id.
(
"hop_by_role_cites_alias_target",
Selection {
cites: Some(Citation { target: rr(LEAF_A_ALIAS, "a-eq"), role: Some("Design".into()) }),
..Selection::default()
},
),
// R-E: `schemas` reaches a schema by its unversioned alias.
(
"schemas_by_alias",
Selection {
schemas: Some(vec![LEAF_A_ALIAS.into()]),
..Selection::default()
},
),
];
let mut vectors = Vec::new();
@ -858,7 +921,7 @@ fn write_selection_vectors_for_the_reference() {
selection_message_pack_base64: base64::engine::general_purpose::STANDARD.encode(&bytes),
as_of: 1,
expected_refusal_field: None,
matched: ids.len() as u32,
matched: evaluation.matched,
has_next: evaluation.next_cursor.is_some(),
expected_ids: ids,
expected_edges: edges,
@ -885,6 +948,19 @@ fn write_selection_vectors_for_the_reference() {
},
"keys",
),
// R-E: an unmatched cites.target.schemaId is refused at the door, never silently answered
// with an empty page.
(
"refuses_unmatched_cites_target_schema",
Selection {
cites: Some(Citation {
target: rr("sha256:not-a-known-schema", "a-eq"),
role: Some("Design".into()),
}),
..Selection::default()
},
"cites.target.schemaId",
),
];
for (name, selection, expected_field) in refusal_cases {
let bytes = rmp_serde::to_vec_named(&selection).expect("encodes");
@ -933,3 +1009,320 @@ fn canonical_number_compares_by_sign() {
fn unused_operator_reference(op: SelectionOperator) -> &'static str {
op.as_wire_str()
}
// ------------------------------------------------------------------------------------------
// R-F: the door is inside `select` - it validates first and returns the typed refusal, and the
// single-row fast path refuses (loudly, not just in debug builds) to answer a hop-bearing
// selection at all.
// ------------------------------------------------------------------------------------------
#[test]
fn select_refuses_an_invalid_selection_instead_of_evaluating_it() {
let rows = base_rows();
let selection = Selection { schemas: Some(Vec::new()), ..Selection::default() };
let result = select(&FixtureRowSet, &rows, &selection, 1);
assert!(matches!(result, Err(SelectionRefusal::Invalid(ref invalid)) if invalid.field == "schemas"));
}
// A plain assert (not #[should_panic]) so a mutant that removes the panic reads as an ordinary
// FAILED test rather than the harness having to special-case should_panic's own output shape.
#[test]
fn matches_refuses_a_hop_bearing_selection_even_in_a_release_style_assert() {
let row = FixtureRow::leaf(LEAF_A, "a-eq", 1, "weapon", "5");
let selection = Selection {
cited: Some(Incoming { role: "Design".into(), exists: true }),
..Selection::default()
};
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
cultnet_rs::matches(&row, &selection);
}));
let panicked = outcome.is_err();
assert!(panicked, "matches() must panic on a hop-bearing selection, not answer wrong");
}
// ------------------------------------------------------------------------------------------
// R-H: the cursor digest is length-prefixed, so no separator character can make two distinct
// selections collide (Soul: `["a|b"]` and `["a","b"]` used to digest the same).
// ------------------------------------------------------------------------------------------
#[test]
fn cursor_digest_does_not_collide_on_differently_split_lists() {
let one_joined_value = Selection {
fields: Some(vec![FieldPredicate {
index: "kind".into(),
op: "any_of".into(),
values: Some(vec!["a|b".into()]),
number: None,
}]),
..Selection::default()
};
let two_split_values = Selection {
fields: Some(vec![FieldPredicate {
index: "kind".into(),
op: "any_of".into(),
values: Some(vec!["a".into(), "b".into()]),
number: None,
}]),
..Selection::default()
};
assert_ne!(
Cursor::compute_digest(&one_joined_value),
Cursor::compute_digest(&two_split_values),
"a length-prefixed digest must not let list-splitting collide"
);
let one_key = Selection { keys: Some(vec!["a,b".into()]), ..Selection::default() };
let two_keys = Selection { keys: Some(vec!["a".into(), "b".into()]), ..Selection::default() };
assert_ne!(Cursor::compute_digest(&one_key), Cursor::compute_digest(&two_keys));
}
// ------------------------------------------------------------------------------------------
// R-E: the alias matcher, exercised at the `select`/`validate` level (the module's own unit
// tests in cultnet_rs::selection::schema_alias::tests cover the string algorithm directly).
// ------------------------------------------------------------------------------------------
#[test]
fn schemas_reaches_a_schema_by_its_unversioned_alias() {
let rows = base_rows();
let selection = Selection {
schemas: Some(vec![LEAF_A_ALIAS.into()]),
..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!["a-eq", "a-lo", "shared-1", "shared-3"], "every leaf_a row, by alias");
}
// R-E, at the fast path `matches()` uses directly (the v0 snapshot server's own caller,
// serve_read_only_raw_snapshot, never calls validate/select - v0 has its own lowering instead).
// An empty-but-present schemas list must match nothing there too, the same as the door's own
// rule for select() - schema_alias::matches_any's own "empty candidates matches everything"
// convention (ported faithfully from the C# reference's MatchesAny, for the door/reachability
// call sites that already refuse or normalise an empty list before this ever runs) must not leak
// into this call site, which has no such guarantee upstream of it.
#[test]
fn matches_treats_an_empty_but_present_schemas_list_as_matching_nothing() {
let row = FixtureRow::leaf(LEAF_A, "a-eq", 1, "weapon", "5");
let selection = Selection { schemas: Some(Vec::new()), ..Selection::default() };
assert!(
!cultnet_rs::matches(&row, &selection),
"an empty-but-present schemas list must never be read as \"no filter\" by the evaluator"
);
}
#[test]
fn cites_target_resolves_through_the_alias_matcher() {
let rows = base_rows();
let selection = Selection {
cites: Some(Citation { target: rr(LEAF_A_ALIAS, "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"]);
}
#[test]
fn validate_refuses_a_cites_target_schema_that_matches_no_known_schema() {
let selection = Selection {
cites: Some(Citation {
target: rr("sha256:not-a-known-schema", "a-eq"),
role: Some("Design".into()),
}),
..Selection::default()
};
let error = validate(&selection, &FixtureRowSet).expect_err("unmatched target must be refused");
assert_eq!(error.field, "cites.target.schemaId");
}
#[test]
fn schema_alias_module_is_reachable_from_the_crate_root() {
assert!(schema_alias::matches(LEAF_A_ALIAS, LEAF_A));
assert!(!schema_alias::matches(LEAF_A_ALIAS, LEAF_B));
}
// ------------------------------------------------------------------------------------------
// R-G/P-1: `matched` is the total match count, not the page count.
// ------------------------------------------------------------------------------------------
#[test]
fn matched_is_the_total_count_not_the_page_count() {
let rows = base_rows();
let selection = Selection { limit: Some(2), ..Selection::default() };
let evaluation = select(&FixtureRowSet, &rows, &selection, 1).unwrap();
assert_eq!(evaluation.rows.len(), 2, "the page is clipped to the limit");
assert_eq!(evaluation.matched, 7, "matched counts every row base_rows() carries");
}
// ------------------------------------------------------------------------------------------
// R-I: `select_page` projects `select`'s generic rows into the wire's `SelectionPage` - a header
// projection carries no payload, in the page's rows and in its edges alike (S20).
// ------------------------------------------------------------------------------------------
fn document_record_for(row: &FixtureRow) -> SelectionDocumentRecord {
SelectionDocumentRecord {
schema_id: row.schema_id.to_string(),
schema_name: None,
schema_version: None,
schema_content_hash: None,
record_key: row.record_key.to_string(),
stored_at: "2026-09-22T00:00:00Z".to_string(),
payload_encoding: "messagepack".to_string(),
payload: vec![0xC0], // a nonempty payload every header-projection assertion must not see
source_runtime_id: None,
source_agent_id: None,
source_role: None,
tags: None,
}
}
#[test]
fn select_page_header_projection_carries_no_payload_in_rows_or_edges() {
let rows = vec![
FixtureRow::leaf(LEAF_A, "a-eq", 1, "weapon", "5"),
FixtureRow::citer("citer-1", 2, vec![("Design".to_string(), rr(LEAF_A, "a-eq"), Some(vec![7]))]),
];
let selection = Selection {
cites: Some(Citation { target: rr(LEAF_A, "a-eq"), role: Some("Design".into()) }),
projection: "header".into(),
..Selection::default()
};
let page: SelectionPage =
select_page(&FixtureRowSet, &rows, &selection, 1, document_record_for).unwrap();
assert!(page.documents.is_none());
let headers = page.headers.expect("header projection carries headers");
assert_eq!(headers.len(), 1);
assert_eq!(headers[0].record_key, "citer-1");
let edges = page.edges.expect("a hop-bearing selection carries edges");
assert_eq!(edges.len(), 1);
assert_eq!(edges[0].payload, None, "S20: a header projection's edges carry no payload");
assert_eq!(edges[0].payload_encoding, None);
}
#[test]
fn select_page_document_projection_carries_payload_in_rows_and_edges() {
let rows = vec![
FixtureRow::leaf(LEAF_A, "a-eq", 1, "weapon", "5"),
FixtureRow::citer("citer-1", 2, vec![("Design".to_string(), rr(LEAF_A, "a-eq"), Some(vec![7]))]),
];
let selection = Selection {
cites: Some(Citation { target: rr(LEAF_A, "a-eq"), role: Some("Design".into()) }),
projection: "document".into(),
..Selection::default()
};
let page: SelectionPage =
select_page(&FixtureRowSet, &rows, &selection, 1, document_record_for).unwrap();
assert!(page.headers.is_none());
let documents = page.documents.expect("document projection carries documents");
assert_eq!(documents.len(), 1);
assert_eq!(documents[0].payload, vec![0xC0]);
let edges = page.edges.expect("a hop-bearing selection carries edges");
assert_eq!(edges[0].payload, Some(vec![7]));
assert_eq!(edges[0].payload_encoding.as_deref(), Some("messagepack"));
}
#[test]
fn select_page_matched_is_the_total_count() {
let rows = base_rows();
let selection = Selection { limit: Some(2), ..Selection::default() };
let page: SelectionPage = select_page(&FixtureRowSet, &rows, &selection, 1, document_record_for).unwrap();
assert_eq!(page.matched, 7);
assert_eq!(page.headers.unwrap().len(), 2);
}
// ------------------------------------------------------------------------------------------
// R-M: `SelectionDocumentRecord`/`RawDocumentHeader`/`Edge`/`RecordRef` used to be encoded and
// decoded by ~335 lines of hand-written `rmpv::Value` construction (contracts.rs). R-M replaced
// that with the generic `rmpv::ext::from_value`/`to_value` bridge over their own serde derives.
// This proves the replacement round-trips through the real message envelope
// (`cultnet.snapshot_response_raw.v1`, MessagePack, named maps) both ways - headers, documents
// with a real payload, and edges with and without a payload.
// ------------------------------------------------------------------------------------------
#[test]
fn snapshot_response_raw_v1_round_trips_headers_documents_and_edges_through_the_generic_codec() {
let message = CultNetMessage::SnapshotResponseRawV1 {
message_id: "msg-1".to_string(),
matched: 3,
as_of: 42,
next: Some("cursor-1".to_string()),
// A message carries exactly one of headers/documents (validate_message's own invariant,
// matching Selection::projection); this vector exercises the document side, the richer of
// the two.
headers: None,
documents: Some(vec![SelectionDocumentRecord {
schema_id: LEAF_A.to_string(),
schema_name: None,
schema_version: None,
schema_content_hash: None,
record_key: "a-eq".to_string(),
stored_at: "2026-09-22T00:00:00Z".to_string(),
payload_encoding: "messagepack".to_string(),
payload: vec![1, 2, 3, 4, 5],
source_runtime_id: None,
source_agent_id: None,
source_role: None,
tags: None,
}]),
edges: Some(vec![
Edge {
from: rr(CITER, "citer-1"),
role: "Design".to_string(),
to: rr(LEAF_A, "a-eq"),
payload_encoding: Some("messagepack".to_string()),
payload: Some(vec![9, 9]),
},
Edge {
from: rr(CITER, "citer-1"),
role: "parent".to_string(),
to: rr(LEAF_B, "b-hi"),
payload_encoding: None,
payload: None,
},
]),
shard_id: Some("shard-1".to_string()),
shard_epoch: Some(5),
shard_log_sequence: Some(100),
};
let bytes =
encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0).expect("encodes");
let decoded = decode_cultnet_message_from_slice(&bytes, CultNetWireContract::CultNetSchemaV0)
.expect("decodes");
assert_eq!(decoded, message);
}
// The header side of the same message shape (S20: no payload field at all, since
// `RawDocumentHeader` has none to carry).
#[test]
fn snapshot_response_raw_v1_round_trips_headers_through_the_generic_codec() {
let message = CultNetMessage::SnapshotResponseRawV1 {
message_id: "msg-2".to_string(),
matched: 1,
as_of: 7,
next: None,
headers: Some(vec![RawDocumentHeader {
schema_id: LEAF_A.to_string(),
schema_name: Some("leaf_a".to_string()),
schema_version: Some("v1".to_string()),
schema_content_hash: Some("hash".to_string()),
record_key: "a-eq".to_string(),
stored_at: "2026-09-22T00:00:00Z".to_string(),
source_runtime_id: Some("runtime-1".to_string()),
source_agent_id: None,
source_role: None,
tags: Some(vec!["tag-a".to_string()]),
}]),
documents: None,
edges: None,
shard_id: None,
shard_epoch: None,
shard_log_sequence: None,
};
let bytes =
encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0).expect("encodes");
let decoded = decode_cultnet_message_from_slice(&bytes, CultNetWireContract::CultNetSchemaV0)
.expect("decodes");
assert_eq!(decoded, message);
}