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:
parent
3d32c67821
commit
8b114ed353
3 changed files with 1054 additions and 230 deletions
|
|
@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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(']');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue