From 537a2170fbb27a31ae64d2b74a0a322f83cc8b98 Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Wed, 2 Sep 2026 09:16:11 +0000 Subject: [PATCH] =?UTF-8?q?Fingerprint=20ReadPlan/OffsetMap:=20Hash=20+=20?= =?UTF-8?q?Eq=20+=20fingerprint()=20(ADR-012=20=C2=A71/=C2=A74,=20plan=20p?= =?UTF-8?q?hase=206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #[derive(Hash, Eq)] on ReadPlan, FieldPlan, CompositePlan, ReadKind, DiscriminatorPlan (schema: Arc hashes via serde_json Value Hash + Eq under preserve_order), and on OffsetMap (+ Clone; LeafMeta/OffsetEntry/ByteRange payload already Hash from phase 5 / this phase). - fingerprint() -> u64 on both via std DefaultHasher (deferred decision 3 resolved: no new dep, not hot, cross-version stability a non-goal per ADR-012). - Contract tests both sides: equal schemas -> equal PartialEq + fingerprint; field-kind / field-order / endianness changes each break equality and fingerprint; different root names over the same document fingerprint differently (ReadPlan). - ValidationPlan already carries its own Hash/Eq/fingerprint + contract test (phase 7 landed early). Verification: 474 tests pass (9 new fingerprint contract tests); clippy -D warnings clean; cargo doc zero warnings; wasm32 release build green. --- docs/plans/030-compiled-forms.md | 19 +++++- src/offset_map.rs | 79 +++++++++++++++++++++++- src/read_plan.rs | 103 +++++++++++++++++++++++++++++-- 3 files changed, 193 insertions(+), 8 deletions(-) diff --git a/docs/plans/030-compiled-forms.md b/docs/plans/030-compiled-forms.md index 9fdd1c9..9ee563f 100644 --- a/docs/plans/030-compiled-forms.md +++ b/docs/plans/030-compiled-forms.md @@ -641,7 +641,24 @@ wasm32-unknown-unknown --release`. --- -## Phase 6 — Fingerprinting `ReadPlan`/`OffsetMap` (ADR-012 §1, §4) +## Phase 6 — Fingerprinting `ReadPlan`/`OffsetMap` (ADR-012 §1, §4) — **DONE (2026-09-02)** + +> **Status: implemented.** `#[derive(Hash, Eq)]` added to `ReadPlan`, +> `FieldPlan`, `CompositePlan`, `ReadKind`, `DiscriminatorPlan` +> (`ReadPlan`'s `schema: Arc` hashes fine — `serde_json::Value: +> Hash + Eq` under the pinned `preserve_order` serde_json) and to +> `OffsetMap` (`Clone` added alongside; its `LeafMeta`/`OffsetEntry`/ +> `ByteRange` payload gained `Hash` in phase 5 / this phase). The +> POC's `VariantPlan`/`VariantKind` don't exist in the production +> shape (phase 1 dropped them). `fingerprint() -> u64` on both via +> `DefaultHasher` (deferred decision 3 resolved: std `DefaultHasher`, +> no new dep; the fingerprint isn't hot; cross-version stability is a +> non-goal per ADR-012). Fingerprint contract tests on both: same +> schema twice → equal `PartialEq` + equal fingerprint; field-kind +> change, field-order change, and endianness change each → different +> fingerprints; (ReadPlan) different root names over the same document +> → different fingerprints. `ValidationPlan` already carries its own +> `Hash + Eq` + `fingerprint` + contract test (phase 7). **Goal:** Add `Hash + Eq` derives + `fingerprint() -> u64` to `ReadPlan` and `OffsetMap`. Enables cross-run caching, `alkcall` schema handshake, diff --git a/src/offset_map.rs b/src/offset_map.rs index 81a67dc..5a79280 100644 --- a/src/offset_map.rs +++ b/src/offset_map.rs @@ -97,7 +97,11 @@ impl OffsetEntry { /// in the table as their fixed-position portion only (length prefix, /// `{offset, length}` pair, or `maxLength` reservation); the variable /// data lives outside the static layout. -#[derive(Debug)] +/// +/// `PartialEq`/`Eq`/`Hash`: two equal maps produce identical +/// read/write behavior over identical buffers (the compute is a pure +/// function of the document). +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct OffsetMap { fields: Vec<(String, OffsetEntry)>, total_size: usize, @@ -166,6 +170,19 @@ impl OffsetMap { pub fn iter(&self) -> impl Iterator { self.fields.iter().map(|(path, entry)| (path.as_str(), entry)) } + + /// A stable-within-version hash of the map (ADR-012 §1/§4). + /// + /// Two maps with equal fingerprints (equal hashes) produce identical + /// read/write behavior over identical buffers. Cross-version + /// stability is a non-goal (ADR-012). Uses `DefaultHasher` (std, no + /// new dep); the fingerprint is not hot. + pub fn fingerprint(&self) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::hash::DefaultHasher::new(); + self.hash(&mut h); + h.finish() + } } /// Mutable context threaded through the recursive offset computation. @@ -926,6 +943,66 @@ mod tests { assert_eq!(paths, vec!["a", "b"]); } + // ----- Fingerprint contract (ADR-012 §1/§4) --------------------------- + + #[test] + fn fingerprint_is_equal_for_equal_maps() { + let root = json!({ "$defs": { "S": { "kind": "struct", "fields": [ + { "name": "a", "kind": "uint8" }, + { "name": "b", "kind": "uint32" } + ]}}}); + let m1 = map(&root, "S"); + let m2 = map(&root, "S"); + assert_eq!(m1, m2); + assert_eq!(m1.fingerprint(), m2.fingerprint()); + } + + #[test] + fn fingerprint_changes_when_a_field_changes() { + let root_a = json!({ "$defs": { "S": { "kind": "struct", "fields": [ + { "name": "a", "kind": "uint8" }, + { "name": "b", "kind": "uint32" } + ]}}}); + let root_b = json!({ "$defs": { "S": { "kind": "struct", "fields": [ + { "name": "a", "kind": "uint16" }, + { "name": "b", "kind": "uint32" } + ]}}}); + let ma = map(&root_a, "S"); + let mb = map(&root_b, "S"); + assert_ne!(ma, mb); + assert_ne!(ma.fingerprint(), mb.fingerprint()); + } + + #[test] + fn fingerprint_changes_when_field_order_changes() { + let root_a = json!({ "$defs": { "S": { "kind": "struct", "fields": [ + { "name": "a", "kind": "uint8" }, + { "name": "b", "kind": "uint8" } + ]}}}); + let root_b = json!({ "$defs": { "S": { "kind": "struct", "fields": [ + { "name": "b", "kind": "uint8" }, + { "name": "a", "kind": "uint8" } + ]}}}); + let ma = map(&root_a, "S"); + let mb = map(&root_b, "S"); + assert_ne!(ma, mb); + assert_ne!(ma.fingerprint(), mb.fingerprint()); + } + + #[test] + fn fingerprint_changes_when_endianness_changes() { + let root_a = json!({ "$defs": { "S": { "kind": "struct", "endian": "little", "fields": [ + { "name": "id", "kind": "uint32" } + ]}}}); + let root_b = json!({ "$defs": { "S": { "kind": "struct", "endian": "big", "fields": [ + { "name": "id", "kind": "uint32" } + ]}}}); + let ma = map(&root_a, "S"); + let mb = map(&root_b, "S"); + assert_ne!(ma, mb); + assert_ne!(ma.fingerprint(), mb.fingerprint()); + } + #[test] fn compute_rejects_non_struct_top_level() { let root = json!({ diff --git a/src/read_plan.rs b/src/read_plan.rs index f3d32c2..0c45cf0 100644 --- a/src/read_plan.rs +++ b/src/read_plan.rs @@ -61,7 +61,7 @@ const MAX_COMPILE_DEPTH: usize = 128; /// integration"). `PartialEq` holds: two equal plans produce identical /// reads over identical buffers (the compile is a pure function of the /// document + root name). -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct ReadPlan { endian: Endian, fields: Vec, @@ -72,7 +72,7 @@ pub struct ReadPlan { /// One compiled struct field: the read dispatch kind, the effective /// endianness, the field-level annotations, and (for composites) the /// compiled body. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct FieldPlan { name: String, kind: ReadKind, @@ -84,7 +84,7 @@ pub struct FieldPlan { /// The read dispatch kind for a field. Composites carry their compiled /// body in [`FieldPlan::body`]; primitives and enums read directly. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum ReadKind { Primitive(AlkTypeKind), Enum, @@ -96,7 +96,7 @@ pub enum ReadKind { /// The compiled body of a composite node — the packed-read analog of /// `BastType`'s composite arms. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum CompositePlan { /// A nested struct — its own field list and container default. Struct(ReadPlan), @@ -129,7 +129,7 @@ pub enum CompositePlan { } /// The compiled union discriminator (ADR-003, D-BAST-005). -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum DiscriminatorPlan { /// A fixed-size integer at a known byte offset within the union's /// buffer. Mapping keys are stringified integers. @@ -196,10 +196,26 @@ impl ReadPlan { /// The BAST document this plan was compiled from (retained as /// `Arc` — the reader's `schema()` accessor returns it, and - /// phase 6's `Hash` derive covers it via `serde_json::Value: Hash`). + /// the `Hash` derive covers it via `serde_json::Value: Hash`). pub fn schema(&self) -> &Value { &self.schema } + + /// A stable-within-version hash of the plan (ADR-012 §1/§4). + /// + /// Two plans with equal [`fingerprint`](Self::fingerprint)s (equal + /// hashes) produce identical reads over identical bytes — the plan + /// is a pure function of the document + root name, and the hash is + /// derived from the full plan contents. Cross-version stability is + /// a non-goal (ADR-012): a new `AlkTypeKind` may change the hash. + /// Uses `DefaultHasher` (std, no new dep); the fingerprint is not + /// hot. + pub fn fingerprint(&self) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::hash::DefaultHasher::new(); + self.hash(&mut h); + h.finish() + } } impl FieldPlan { @@ -1255,6 +1271,81 @@ mod tests { assert!(debug.contains("ReadPlan")); } + // ----- Fingerprint contract (ADR-012 §1/§4) --------------------------- + + #[test] + fn fingerprint_is_equal_for_equal_plans() { + let root = json!({ "$defs": { + "S": { "kind": "struct", "endian": "big", "fields": [ + { "name": "id", "kind": "uint32" }, + { "name": "pt", "kind": { "$ref": "#/$defs/Point" } } + ]}, + "Point": { "kind": "struct", "fields": [ + { "name": "x", "kind": "uint16" }, + { "name": "y", "kind": "uint16" } + ]} + }}); + let p1 = plan(&root, "S"); + let p2 = plan(&root, "S"); + assert_eq!(p1, p2); + assert_eq!(p1.fingerprint(), p2.fingerprint()); + } + + #[test] + fn fingerprint_changes_when_a_field_changes() { + let root_a = json!({ "$defs": { "S": { "kind": "struct", "fields": [ + { "name": "a", "kind": "uint8" } + ]}}}); + let root_b = json!({ "$defs": { "S": { "kind": "struct", "fields": [ + { "name": "a", "kind": "uint16" } + ]}}}); + let pa = plan(&root_a, "S"); + let pb = plan(&root_b, "S"); + assert_ne!(pa, pb); + assert_ne!(pa.fingerprint(), pb.fingerprint()); + } + + #[test] + fn fingerprint_changes_when_field_order_changes() { + let root_a = json!({ "$defs": { "S": { "kind": "struct", "fields": [ + { "name": "a", "kind": "uint8" }, + { "name": "b", "kind": "uint8" } + ]}}}); + let root_b = json!({ "$defs": { "S": { "kind": "struct", "fields": [ + { "name": "b", "kind": "uint8" }, + { "name": "a", "kind": "uint8" } + ]}}}); + let pa = plan(&root_a, "S"); + let pb = plan(&root_b, "S"); + assert_ne!(pa, pb); + assert_ne!(pa.fingerprint(), pb.fingerprint()); + } + + #[test] + fn fingerprint_changes_when_endianness_changes() { + let root_a = json!({ "$defs": { "S": { "kind": "struct", "endian": "little", "fields": [ + { "name": "id", "kind": "uint32" } + ]}}}); + let root_b = json!({ "$defs": { "S": { "kind": "struct", "endian": "big", "fields": [ + { "name": "id", "kind": "uint32" } + ]}}}); + let pa = plan(&root_a, "S"); + let pb = plan(&root_b, "S"); + assert_ne!(pa, pb); + assert_ne!(pa.fingerprint(), pb.fingerprint()); + } + + #[test] + fn fingerprint_differs_for_different_root_names() { + let root = json!({ "$defs": { + "A": { "kind": "struct", "fields": [ { "name": "a", "kind": "uint8" } ] }, + "B": { "kind": "struct", "fields": [ { "name": "b", "kind": "uint16" } ] } + }}); + let pa = plan(&root, "A"); + let pb = plan(&root, "B"); + assert_ne!(pa.fingerprint(), pb.fingerprint()); + } + // ----- Send + Sync (ADR-011 §"Engine integration") -------------------- #[test]