From 8b114ed353f4ca5c6fc25ddaa5a0c3956ccc6b6d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 22 Sep 2026 10:08:06 +0200 Subject: [PATCH] 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:.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 --- .../interop/selection-vectors.rs-written.json | 103 ++- packages/cultnet-rs/src/selection.rs | 666 ++++++++++++++---- packages/cultnet-rs/tests/selection.rs | 511 ++++++++++++-- 3 files changed, 1052 insertions(+), 228 deletions(-) diff --git a/contracts/cultnet/interop/selection-vectors.rs-written.json b/contracts/cultnet/interop/selection-vectors.rs-written.json index f1c19b63..705dc66e 100644 --- a/contracts/cultnet/interop/selection-vectors.rs-written.json +++ b/contracts/cultnet/interop/selection-vectors.rs-written.json @@ -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": [] } ] } \ No newline at end of file diff --git a/packages/cultnet-rs/src/selection.rs b/packages/cultnet-rs/src/selection.rs index 58912e56..596f6b2b 100644 --- a/packages/cultnet-rs/src/selection.rs +++ b/packages/cultnet-rs/src/selection.rs @@ -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; + + 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 { 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::().unwrap().to_bits(), 0.1_f32.to_bits()); + assert_eq!(as_f64.parse::().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::().unwrap().to_bits(), smallest_f32.to_bits()); + assert_eq!(rendered_f64.parse::().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::().unwrap().to_bits(), f32::MAX.to_bits()); + assert_eq!(rendered_f64.parse::().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`). +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)`. + 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` marker, or `None` when `schema_id` + /// carries no such marker (real hashed ids, e.g. `"sha256:"`, 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 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(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(row: &R, selection: &Selection) -> bool { } fn matches_schema_keys_fields(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 { #[derive(Clone, Debug)] pub struct Evaluation { pub rows: Vec, + /// 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>, pub next_cursor: Option, } @@ -1086,12 +1294,17 @@ pub struct Evaluation { /// 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( row_set: &impl RowSet, all_rows: &[R], selection: &Selection, as_of: u64, ) -> Result, 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( let mut edges: Vec> = Vec::new(); if let Some(cited) = &selection.cited { - let incoming = build_incoming_index(row_set, &by_key, &cited.role, &mut edges)?; + let 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( 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( 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> = 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| -> 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub headers: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub documents: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub edges: Option>, +} + +/// 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( + row_set: &impl RowSet, + all_rows: &[R], + selection: &Selection, + as_of: u64, + document_record: impl Fn(&R) -> SelectionDocumentRecord, +) -> Result { + 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(a: &R, b: &R, descending: bool) -> Ordering { let ordering = a .ordinal() @@ -1246,7 +1592,8 @@ fn matches_citation( 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( 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( row_set: &impl RowSet, + all_rows: &[R], by_key: &HashMap<&str, &R>, role: &str, edge_sink: &mut Vec>, ) -> Result, SelectionRefusal> { let mut incoming = HashSet::new(); - for citer in by_key.values() { + for 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( 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(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::>() - .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 `:` - 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 `[...]`. +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(']'); +} diff --git a/packages/cultnet-rs/tests/selection.rs b/packages/cultnet-rs/tests/selection.rs index ffbad331..78f52c5a 100644 --- a/packages/cultnet-rs/tests/selection.rs +++ b/packages/cultnet-rs/tests/selection.rs @@ -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:`), the same shape +// `CultCache.cs`'s `Sha256(semanticFingerprint)` produces, each carrying a `.v1` version marker so +// `schema_alias::matches` has a real `.v` 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>)>) -> 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 { 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 { 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 { 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 { 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 { 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> = @@ -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!["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!["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, u32, bool, Vec) .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!["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); +}