R-AP: nil-not-omitted for Edge/RawDocumentHeader/SelectionDocumentRecord/SelectionPage
Decided the payload shape: a map with camelCase keys, fields in declared
order, every optional field always present and nil when absent - never
omitted. C#'s [MessagePackObject]/[Key("name")] map formatter already does
this by default (confirmed byte-for-byte below); Rust's `to_vec_named`
encoding for these four types used `skip_serializing_if = "Option::is_none"`,
which omits the key instead - the gap the map cited ("C# writes nine fields
with nils, Rust omits absent ones").
Removes skip_serializing_if from Edge, RawDocumentHeader,
SelectionDocumentRecord and SelectionPage, keeping #[serde(default)] for
decode-side robustness. Selection (the request type) is untouched - out of
this ruling's scope, and the map already confirmed the request side agrees
between runtimes.
Adds tests/selection_wire_parity.rs: RawDocumentHeader and Edge, each
carrying several absent optionals, encoded via to_vec_named and compared
byte-for-byte against hex captured from a real MessagePack C# 3.1.7 run
(same pattern as error_message.rs's csharp_hex vectors) - both now agree
exactly.
What is NOT done, and why: R-AP also called for "Select gains projection,
so the thing the vectors test is the thing that decides." In C#, projection
(header vs document, ToRawRecord/ToEdge/FromRecord) is owned entirely by
CultNetDocumentRegistry.SelectPage/SelectAll (GameCult.Networking), not by
CultNetSelectionEvaluator.Select/Page - the evaluator returns raw Rows/
EdgeMatches and knows nothing about a document's payload serializer, which
only the registry's bindings own. Moving page assembly into the evaluator
without threading a caller-supplied serializer (the same pattern ordinalOf/
shardIdOf already use) would give a schema-generic evaluator per-binding
wire knowledge it should not own; threading a serializer callback in is
buildable but changes SelectPage/SelectAll's own signature and every
caller, plus the parity-vector harness's construction - a materially larger
change than this pass's remaining scope. No vector yet exercises the real
CultNetSnapshotResponseRawV1Message/select_page output byte-for-byte; only
the two nested wire types in isolation. Flagged for the operator per the
brief's own stop condition.
R-AQ, updated: type-level byte parity for the nested wire shapes (Edge,
RawDocumentHeader) is now real and vector-tested. Whole-message byte parity
for cultnet.snapshot_response_raw.v1 is still not - the smaller claim (this
cut's page bytes agree only where R-AH's fixed-shape rule reaches, and
projection ownership is still split between the evaluator and the registry)
is the true one until "Select gains projection" actually lands.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
62ebba4ab5
commit
94707ed8ed
2 changed files with 127 additions and 20 deletions
|
|
@ -1015,6 +1015,12 @@ impl Selection {
|
|||
}
|
||||
|
||||
/// One edge a hop traversed, carried on a selection page.
|
||||
///
|
||||
/// R-AP: a decodable *fixed-shape* map is worth more than brevity, and `skip_serializing_if`
|
||||
/// undermines the compact positional form anyway (a struct's `n`th absent optional turns an
|
||||
/// n-element array into a shorter one, undecodable against the declared shape) - every field here
|
||||
/// is always present on the wire, `nil` when absent, in this declared order, the same rule
|
||||
/// `RawDocumentHeader`/`SelectionDocumentRecord`/`SelectionPage` follow below.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Edge {
|
||||
|
|
@ -1025,11 +1031,11 @@ pub struct Edge {
|
|||
/// The cited row, its schema resolved to the leaf actually stored.
|
||||
pub to: RecordRef,
|
||||
/// `"messagepack"` when `payload` is present.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub payload_encoding: Option<String>,
|
||||
/// The value attached to the edge (a dictionary reference's value); present under `document`
|
||||
/// projection only.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none", with = "optional_bytes")]
|
||||
#[serde(default, with = "optional_bytes")]
|
||||
pub payload: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
|
|
@ -1052,25 +1058,28 @@ mod optional_bytes {
|
|||
}
|
||||
|
||||
/// A header record: [`SelectionDocumentRecord`] minus its payload and payload encoding.
|
||||
///
|
||||
/// R-AP: every field always present, `nil` when absent, in this declared order (see `Edge`'s doc
|
||||
/// comment above).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RawDocumentHeader {
|
||||
pub schema_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub schema_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub schema_version: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub schema_content_hash: Option<String>,
|
||||
pub record_key: String,
|
||||
pub stored_at: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub source_runtime_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub source_agent_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub source_role: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub tags: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
|
|
@ -1080,15 +1089,17 @@ 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.
|
||||
// R-AP: every optional field always present, `nil` when absent, in this declared order (see
|
||||
// `Edge`'s doc comment above).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SelectionDocumentRecord {
|
||||
pub schema_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub schema_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub schema_version: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub schema_content_hash: Option<String>,
|
||||
pub record_key: String,
|
||||
pub stored_at: String,
|
||||
|
|
@ -1096,13 +1107,13 @@ pub struct SelectionDocumentRecord {
|
|||
pub payload_encoding: String,
|
||||
#[serde(with = "serde_bytes")]
|
||||
pub payload: Vec<u8>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub source_runtime_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub source_agent_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub source_role: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub tags: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
|
|
@ -1875,18 +1886,21 @@ pub fn select<R: Row + Clone>(
|
|||
/// 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.
|
||||
// R-AP: the payload, not the envelope - matched/asOf/next/headers/documents/edges in this
|
||||
// declared order on both runtimes, every optional field always present and `nil` when absent
|
||||
// (never omitted - see `Edge`'s doc comment above).
|
||||
#[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")]
|
||||
#[serde(default)]
|
||||
pub next: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub headers: Option<Vec<RawDocumentHeader>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub documents: Option<Vec<SelectionDocumentRecord>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub edges: Option<Vec<Edge>>,
|
||||
}
|
||||
|
||||
|
|
|
|||
93
packages/cultnet-rs/tests/selection_wire_parity.rs
Normal file
93
packages/cultnet-rs/tests/selection_wire_parity.rs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
//! R-AP (docs/cultnet-selection-cut.md, "Self's rulings for fix batch 6"): `RawDocumentHeader` and
|
||||
//! `Edge`, pinned byte-for-byte against the C# reference, the same way `tests/error_message.rs`
|
||||
//! pins `cultnet.error.v0` - a hardcoded `csharp_hex` captured from a real MessagePack C# run,
|
||||
//! compared against this crate's own encoding of the identical value.
|
||||
//!
|
||||
//! Unlike `error_message.rs`'s bytes (captured on Yggdrasil via `ygg-verify.sh` per that file's own
|
||||
//! header), the bytes below were captured **locally**, in this worktree (`C:\ws7`), against
|
||||
//! MessagePack C# 3.1.7 - the exact version `GameCult.Networking` depends on
|
||||
//! (`src/GameCult.Networking/GameCult.Networking.csproj`) - using the project's own
|
||||
//! `CultNetRawDocumentHeader`/`CultNetEdge`/`CultNetSchemaMessageSerialization.Options`
|
||||
//! (`MessagePackSerializerOptions.Standard.WithSecurity(MessagePackSecurity.UntrustedData)`), then
|
||||
//! `Convert.ToHexString(MessagePackSerializer.Serialize(value, options))`. This session had no
|
||||
//! access to the Yggdrasil verify image, so unlike the error-message vectors this was not run
|
||||
//! through `ygg-verify.sh`; the capture itself is still a real MessagePack C# 3.1.7 run on this
|
||||
//! machine, not hand-derived bytes.
|
||||
//!
|
||||
//! What this proves: `RawDocumentHeader`/`Edge`'s `to_vec_named` encoding (R-AP: `skip_serializing_if`
|
||||
//! removed, every optional field always present and `nil` when absent, camelCase, declared order)
|
||||
//! now matches the C# reference's `[MessagePackObject]`/`[Key("name")]` map encoding byte-for-byte
|
||||
//! for a value carrying absent optionals - the exact shape `SelectionPage`/`CultNetSelectionPage`
|
||||
//! carry inside `cultnet.snapshot_response_raw.v1`. It does not prove whole-message parity: that
|
||||
//! still depends on R-AP's unbuilt half (see selection.rs's `SelectionPage` doc comment and this
|
||||
//! cut's map - "Select gains projection" has not moved page assembly into the evaluator, so no
|
||||
//! vector yet exercises the actual `CultNetSnapshotResponseRawV1Message`/`select_page` output
|
||||
//! byte-for-byte, only these two nested wire types in isolation).
|
||||
|
||||
use cultnet_rs::{Edge, RawDocumentHeader, RecordRef};
|
||||
|
||||
fn hex_decode(hex: &str) -> Vec<u8> {
|
||||
assert!(hex.len() % 2 == 0, "odd-length hex string");
|
||||
(0..hex.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).expect("valid hex byte"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
// C#: new CultNetRawDocumentHeader { SchemaId = "sha256:ff35f3...4cff", RecordKey = "a-eq",
|
||||
// StoredAt = "1970-01-01T00:00:00.000Z" } - SchemaName/SchemaVersion/SchemaContentHash/
|
||||
// SourceRuntimeId/SourceAgentId/SourceRole/Tags all left null (absent).
|
||||
// Convert.ToHexString(MessagePackSerializer.Serialize(header, CultNetSchemaMessageSerialization.Options)):
|
||||
const CSHARP_HEADER_HEX: &str = "8AA8736368656D614964D9477368613235363A66663335663364396436646533663130363662393336396537336665366337363438653838333638643138656663323237356339393437333063643834636666AA736368656D614E616D65C0AD736368656D6156657273696F6EC0B1736368656D61436F6E74656E7448617368C0A97265636F72644B6579A4612D6571A873746F7265644174B8313937302D30312D30315430303A30303A30302E3030305AAF736F7572636552756E74696D654964C0AD736F757263654167656E744964C0AA736F75726365526F6C65C0A474616773C0";
|
||||
|
||||
// C#: new CultNetEdge { From = { SchemaId = "sha256:9d0bc9...608dc", RecordKey = "citer-1" },
|
||||
// Role = "Design", To = { SchemaId = "sha256:ff35f3...4cff", RecordKey = "a-eq" } } -
|
||||
// PayloadEncoding/Payload left null (absent, header projection).
|
||||
const CSHARP_EDGE_HEX: &str = "85A466726F6D82A8736368656D614964D9477368613235363A39643062633961656263333631396230376161626635363466393836363761666465343134343463316238363636363164373737363837643434333630386463A97265636F72644B6579A763697465722D31A4726F6C65A644657369676EA2746F82A8736368656D614964D9477368613235363A66663335663364396436646533663130363662393336396537336665366337363438653838333638643138656663323237356339393437333063643834636666A97265636F72644B6579A4612D6571AF7061796C6F6164456E636F64696E67C0A77061796C6F6164C0";
|
||||
|
||||
#[test]
|
||||
fn raw_document_header_matches_the_csharp_reference_byte_for_byte() {
|
||||
let header = RawDocumentHeader {
|
||||
schema_id: "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff".to_string(),
|
||||
schema_name: None,
|
||||
schema_version: None,
|
||||
schema_content_hash: None,
|
||||
record_key: "a-eq".to_string(),
|
||||
stored_at: "1970-01-01T00:00:00.000Z".to_string(),
|
||||
source_runtime_id: None,
|
||||
source_agent_id: None,
|
||||
source_role: None,
|
||||
tags: None,
|
||||
};
|
||||
|
||||
let actual = rmp_serde::to_vec_named(&header).expect("encodes");
|
||||
let expected = hex_decode(CSHARP_HEADER_HEX);
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"RawDocumentHeader's Rust-encoded bytes do not match the captured C# encoding"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_matches_the_csharp_reference_byte_for_byte() {
|
||||
let edge = Edge {
|
||||
from: RecordRef::new(
|
||||
"sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc",
|
||||
"citer-1",
|
||||
),
|
||||
role: "Design".to_string(),
|
||||
to: RecordRef::new(
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
|
||||
"a-eq",
|
||||
),
|
||||
payload_encoding: None,
|
||||
payload: None,
|
||||
};
|
||||
|
||||
let actual = rmp_serde::to_vec_named(&edge).expect("encodes");
|
||||
let expected = hex_decode(CSHARP_EDGE_HEX);
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"Edge's Rust-encoded bytes do not match the captured C# encoding"
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue