feat(core): RecordHash/AggregateHash and the record index in DataStore

ADR 002's surrogate, and the reverse index a server resolves it through.

The hash is specified rather than inherited: SipHash-2-4 under a constant
key over an explicit length-prefixed byte serialization of the sorted
(category, item) pairs. Not DefaultHasher/RandomState (randomly seeded per
process), not #[derive(Hash)] (no stability guarantee). 128 bits, argued in
the doc comment: a collision is a silent write to the wrong record and no
later check catches it, while the cost of the wider hash is a bounded 8
bytes per visible cell. The wire form is hex-as-a-string, because a JSON
number above 2^53 comes back changed in a JavaScript host.

RecordHash::of normalizes, so a cell key and its record hash alike -- the
_Measure-drop is a property of the constructor, not a rule callers follow.

The index unifies ADR 002's resolution with ADR 001's record_versions:
RecordHash -> RecordEntry { record, cells, version }, one lookup answering
both "which record do you mean?" and "is your basis stale?". A zero cell
count is a tombstone rather than a removal, so a delete-and-recreate cannot
hand a stale basis its own version number back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
This commit is contained in:
Edward Langley
2026-09-19 17:09:46 -07:00
parent bf7f0e0969
commit 2e7e757538
5 changed files with 620 additions and 1 deletions
Generated
+2
View File
@@ -880,6 +880,8 @@ dependencies = [
"indexmap",
"proptest",
"serde",
"serde_json",
"siphasher",
]
[[package]]
+6
View File
@@ -11,6 +11,12 @@ improvise-formula = { path = "../improvise-formula" }
anyhow = "1"
indexmap = { version = "2", features = ["serde"] }
serde = { version = "1", features = ["derive"] }
# SipHash-2-4 under a constant key: the record surrogate must not move
# between processes, which rules out anything built on `RandomState`
# (ADR 002). Already in the tree as a transitive dependency; no_std and
# wasm-clean.
siphasher = "1"
[dev-dependencies]
proptest = "1"
serde_json = "1"
+285 -1
View File
@@ -2,6 +2,7 @@ use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use super::record::{RecordHash, record_of};
use super::symbol::{Symbol, SymbolTable};
/// A cell key is a sorted vector of (category_name, item_name) pairs.
@@ -101,6 +102,45 @@ impl std::fmt::Display for CellValue {
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct InternedKey(pub Vec<(Symbol, Symbol)>);
/// What the record index knows about one record: the coordinates a
/// surrogate resolves back to, and the write counter a basis is checked
/// against (ADR 001 + ADR 002, unified — see [`DataStore::records`]).
///
/// The two questions a client's surrogate raises — "which record do you
/// mean?" and "is your basis stale?" — are answered from this one entry, in
/// one lookup, because both are keyed by the record and both are
/// invalidated at the same moment.
#[derive(Debug, Clone)]
pub struct RecordEntry {
record: CellKey,
/// How many stored cells belong to this record. Zero is a **tombstone**,
/// not a reason to forget the entry: a record that is deleted and then
/// re-created must not hand a client holding the old basis its own
/// version number back. Dropping the entry would reset the counter to
/// zero and make a stale basis look current, which is the silent-accept
/// this index exists to prevent.
cells: usize,
version: u64,
}
impl RecordEntry {
/// The coordinate tuple, `_Measure` already excluded.
pub fn record(&self) -> &CellKey {
&self.record
}
/// ADR 001's per-record write counter. Zero for a record nothing has
/// written.
pub fn version(&self) -> u64 {
self.version
}
/// Whether any stored cell still belongs to this record.
pub fn is_live(&self) -> bool {
self.cells > 0
}
}
/// Serialized as a list of (key, value) pairs so CellKey doesn't need
/// to implement the `Serialize`-as-string requirement for JSON object keys.
#[derive(Debug, Clone, Default)]
@@ -112,6 +152,28 @@ pub struct DataStore {
pub symbols: SymbolTable,
/// Secondary index: interned (category, item) → set of interned keys.
index: HashMap<(Symbol, Symbol), HashSet<InternedKey>>,
/// Reverse index for the client-facing surrogate (ADR 002): the one-way
/// [`RecordHash`] back to the record it names, plus that record's write
/// counter.
///
/// It lives here because this is where the same job is already done. The
/// store already interns every name and already maintains a secondary
/// index updated at exactly these two points, and it is that existing
/// `(Symbol, Symbol)` index which makes **invalidation** tractable: when
/// a category or an item disappears, the cells naming it are found
/// without a scan, so the records built from them are too.
///
/// It is deliberately not regenerated from the layout on demand. That
/// would be O(visible cells) per resolve *and* would only ever cover the
/// visible window, so a record scrolled out of view would look identical
/// to a record that no longer exists — conflating "reject, nothing to
/// write to" with "apply".
///
/// Not serialized: [`Deserialize`] rebuilds it through `set`, and every
/// version starts at zero, which is correct because a freshly loaded
/// document is a new `document_generation` and no client holds a basis
/// against it.
records: HashMap<RecordHash, RecordEntry>,
}
impl Serialize for DataStore {
@@ -183,12 +245,23 @@ impl DataStore {
}
pub fn set(&mut self, key: CellKey, value: CellValue) {
let hash = RecordHash::of(&key);
let record = record_of(&key);
let ikey = self.intern_key(&key);
// Update index for each coordinate pair
for pair in &ikey.0 {
self.index.entry(*pair).or_default().insert(ikey.clone());
}
self.cells.insert(ikey, value);
let is_new_cell = self.cells.insert(ikey, value).is_none();
let entry = self.records.entry(hash).or_insert(RecordEntry {
record,
cells: 0,
version: 0,
});
// Overwriting a cell does not add one; the count tracks liveness,
// not writes, and the version is bumped by the observed diff in
// `apply_effects` rather than from here.
entry.cells += usize::from(is_new_cell);
}
pub fn get(&self, key: &CellKey) -> Option<&CellValue> {
@@ -221,9 +294,55 @@ impl DataStore {
set.remove(&ikey);
}
}
if let Some(entry) = self.records.get_mut(&RecordHash::of(key)) {
entry.cells -= 1;
}
}
}
/// Resolve a client's surrogate back to the record it names (ADR 002).
///
/// `None` means **the record is gone** — either it was never stored or
/// every cell of it has been removed, which is what happens when a
/// category or item it named is deleted. That is one of the two arms of
/// staleness and it needs no version comparison: there is nothing to
/// write to, so the edit is rejected.
pub fn resolve_record(&self, hash: RecordHash) -> Option<&CellKey> {
self.records
.get(&hash)
.filter(|entry| entry.is_live())
.map(RecordEntry::record)
}
/// The write counter for the record a surrogate names — the other arm:
/// it resolves, but against a version the client did not compose
/// against (ADR 001).
///
/// Zero for a record nothing has written, which is deliberately also
/// what an unknown surrogate reports: a basis composed against an
/// absent record and one composed against an untouched record are the
/// same claim.
pub fn record_version(&self, hash: RecordHash) -> u64 {
self.records.get(&hash).map_or(0, RecordEntry::version)
}
/// Record that the record `hash` names has been written. No-op for a
/// record the store has never held a cell for — there is no basis
/// anybody could hold against it.
pub fn bump_record(&mut self, hash: RecordHash) {
if let Some(entry) = self.records.get_mut(&hash) {
entry.version += 1;
}
}
/// Every record that still holds at least one cell, with its surrogate.
pub fn live_records(&self) -> impl Iterator<Item = (RecordHash, &CellKey)> {
self.records
.iter()
.filter(|(_, entry)| entry.is_live())
.map(|(hash, entry)| (*hash, entry.record()))
}
/// Values of all cells where every coordinate in `partial` matches.
/// Hot path: avoids allocating CellKey for each result.
pub fn matching_values(&self, partial: &[(String, String)]) -> Vec<&CellValue> {
@@ -648,3 +767,168 @@ mod prop_tests {
}
}
}
/// The reverse index a client's surrogate is resolved through (ADR 002),
/// and the write counter ADR 001 checks a basis against — one structure,
/// because both are keyed by the record and invalidated together.
#[cfg(test)]
mod record_index {
use super::{CellKey, CellValue, DataStore};
use crate::model::record::RecordHash;
fn key(pairs: &[(&str, &str)]) -> CellKey {
CellKey::new(
pairs
.iter()
.map(|(c, i)| (c.to_string(), i.to_string()))
.collect(),
)
}
/// Two records over two categories, so "it resolved" cannot pass by
/// naming the only record there is.
fn store() -> DataStore {
let mut store = DataStore::new();
store.set(
key(&[
("Region", "East"),
("Product", "Shirts"),
("_Measure", "Revenue"),
]),
CellValue::Number(1200.0),
);
store.set(
key(&[
("Region", "West"),
("Product", "Shirts"),
("_Measure", "Revenue"),
]),
CellValue::Number(900.0),
);
store
}
#[test]
fn a_surrogate_resolves_to_the_record_it_names() {
let store = store();
let east = key(&[("Product", "Shirts"), ("Region", "East")]);
assert_eq!(store.resolve_record(RecordHash::of(&east)), Some(&east));
}
/// Gotcha 1's unit seen from the index side: two measures on one
/// coordinate tuple are one record, so they share one entry.
#[test]
fn a_second_measure_on_a_record_resolves_to_the_same_record() {
let mut store = store();
store.set(
key(&[
("Region", "East"),
("Product", "Shirts"),
("_Measure", "Cost"),
]),
CellValue::Number(400.0),
);
let record = key(&[("Product", "Shirts"), ("Region", "East")]);
assert_eq!(store.resolve_record(RecordHash::of(&record)), Some(&record));
assert_eq!(store.live_records().count(), 2, "still two records");
}
/// The first arm of staleness: a surrogate for a record whose cells are
/// all gone must fail to resolve, rather than resolve to some other
/// record. Two records exist, so a bug returning "any record" is visible.
#[test]
fn a_surrogate_for_a_removed_record_fails_to_resolve() {
let mut store = store();
let east_cell = key(&[
("Region", "East"),
("Product", "Shirts"),
("_Measure", "Revenue"),
]);
let east = key(&[("Product", "Shirts"), ("Region", "East")]);
let west = key(&[("Product", "Shirts"), ("Region", "West")]);
store.remove(&east_cell);
assert_eq!(store.resolve_record(RecordHash::of(&east)), None);
assert_eq!(
store.resolve_record(RecordHash::of(&west)),
Some(&west),
"the surviving record must be untouched, not stand in for the gone one"
);
}
/// A record loses its last cell only when *every* measure has gone, so
/// a surrogate still resolves while another measure holds the row up.
#[test]
fn removing_one_measure_leaves_the_record_resolvable() {
let mut store = store();
let record = key(&[("Product", "Shirts"), ("Region", "East")]);
store.set(
key(&[
("Region", "East"),
("Product", "Shirts"),
("_Measure", "Cost"),
]),
CellValue::Number(400.0),
);
store.remove(&key(&[
("Region", "East"),
("Product", "Shirts"),
("_Measure", "Revenue"),
]));
assert_eq!(store.resolve_record(RecordHash::of(&record)), Some(&record));
}
/// The tombstone earns its place. If the entry were dropped when the
/// last cell went, a delete-and-recreate would reset the counter to
/// zero — and a client holding version 0 against the *old* record would
/// be told its basis is current and its write applied, which is exactly
/// the silent accept the basis check exists to prevent.
#[test]
fn deleting_and_recreating_a_record_does_not_reset_its_version() {
let mut store = store();
let cell = key(&[
("Region", "East"),
("Product", "Shirts"),
("_Measure", "Revenue"),
]);
let hash = RecordHash::of(&cell);
store.bump_record(hash);
store.bump_record(hash);
assert_eq!(store.record_version(hash), 2);
store.remove(&cell);
assert_eq!(
store.record_version(hash),
2,
"the version outlives the record"
);
store.set(cell, CellValue::Number(5.0));
assert_eq!(
store.record_version(hash),
2,
"and a re-created record does not start over"
);
}
/// Nothing can hold a basis against a record the store has never seen,
/// so there is nothing to invalidate and no entry to invent.
#[test]
fn bumping_an_unknown_surrogate_is_a_no_op() {
let mut store = store();
let stranger = RecordHash::of(&key(&[("Region", "North")]));
store.bump_record(stranger);
assert_eq!(store.record_version(stranger), 0);
assert_eq!(store.resolve_record(stranger), None);
}
/// The index is derived state, so it must come back with the data
/// rather than being written alongside it.
#[test]
fn a_deserialized_store_can_resolve_its_records() {
let json = serde_json::to_string(&store()).expect("serializes");
let back: DataStore = serde_json::from_str(&json).expect("deserializes");
let east = key(&[("Product", "Shirts"), ("Region", "East")]);
assert_eq!(back.resolve_record(RecordHash::of(&east)), Some(&east));
assert_eq!(back.live_records().count(), 2);
}
}
+1
View File
@@ -1,5 +1,6 @@
pub mod category;
pub mod cell;
pub mod record;
pub mod symbol;
pub mod types;
+326
View File
@@ -0,0 +1,326 @@
//! Surrogate identities for the things a client may name (ADR 002).
//!
//! A client sees a grid of label strings indexed by viewport position. It
//! holds no `Model`, no category names and no page/filter constraints, so it
//! cannot build a `CellKey` — which is why `improvise-q08` found that a thin
//! client cannot compose `SetCell`. The answer is an opaque surrogate: the
//! server projects one per visible cell and the client echoes it back.
//!
//! Two surrogates, because two kinds of thing are nameable and only one of
//! them is settable:
//!
//! - [`RecordHash`] names a **record** — the coordinate tuple minus
//! `_Measure` ([`record_of`]). That unit is not a choice made here: it is
//! already ADR 001's unit of atomicity and already what the write counters
//! are keyed by, because renaming a category coordinate in wide records
//! mode moves every measure cell in the row (gotcha 1). One identity
//! therefore serves addressing *and* staleness.
//! - [`AggregateHash`] names an **aggregate's coordinate constraint** — the
//! row and column coordinates plus the page and filter selections that
//! decide which stored cells it sums (gotcha 27). An aggregate is not the
//! absence of an identity; it is a different kind of identity, and that is
//! what makes `Drill` addressable at all.
//!
//! # The hash is specified here, not inherited
//!
//! Three ways this breaks silently, each producing a *wrong write* rather
//! than an error, and each closed deliberately:
//!
//! 1. **Not `DefaultHasher`, not `RandomState`.** `std`'s default `HashMap`
//! hasher is randomly seeded per process, so a surrogate would not
//! survive a server restart or agree between two processes. The hash here
//! is SipHash-2-4 under a **constant key**.
//! 2. **Not `#[derive(Hash)]`.** Rust gives no stability guarantee for a
//! derived `Hash` across compiler versions or platforms. What is hashed
//! is the explicit byte serialization written out in [`hash_coords`], so
//! the surrogate is defined by that format rather than by whatever the
//! compiler did this week.
//! 3. **Width by consequence, not by convenience.** See [`RecordHash`].
use std::hash::Hasher;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use siphasher::sip128::{Hasher128, SipHasher24};
use super::category::MEASURE_CATEGORY;
use super::cell::CellKey;
/// The record one cell belongs to: its coordinate tuple with `_Measure`
/// dropped.
///
/// `record_row_identity(wide, coord_group_excluding_measure)` made into a
/// function. `CellKey` is canonically sorted by construction (gotcha 5) and
/// dropping a coordinate preserves that order, so "the normalized record" is
/// a property the type guarantees rather than a convention to maintain.
pub fn record_of(key: &CellKey) -> CellKey {
key.without(MEASURE_CATEGORY)
}
/// Domain separation. A record and an aggregate may be built from the same
/// coordinates — an aggregate over a single record is the degenerate case —
/// and they must not hash alike, so the kind is the first byte hashed.
const TAG_RECORD: u8 = 0x01;
const TAG_AGGREGATE: u8 = 0x02;
/// The SipHash key, fixed forever. `b"improvis"` and `b"e-record"` read as
/// little-endian `u64`s would depend on the reader's endianness; these are
/// the big-endian readings, written as literals so the constant is the
/// constant on every platform.
const KEY_0: u64 = 0x696d_7072_6f76_6973; // "improvis"
const KEY_1: u64 = 0x652d_7265_636f_7264; // "e-record"
/// The one byte serialization both surrogates are defined by.
///
/// `tag ‖ count ‖ (len(category) ‖ category ‖ len(item) ‖ item)*`, with every
/// length a little-endian `u64` and every string its UTF-8 bytes. The
/// lengths are what make it injective: without them `("ab", "c")` and
/// `("a", "bc")` serialize alike, and a collision here is a silent write to
/// the wrong record.
///
/// Only `Hasher::write` is used, never `write_u64`/`write_u8`: those are
/// free to feed the hasher in native byte order, which would make the
/// surrogate endianness-dependent.
fn hash_coords(tag: u8, coords: &[(String, String)]) -> u128 {
let mut hasher = SipHasher24::new_with_keys(KEY_0, KEY_1);
hasher.write(&[tag]);
hasher.write(&(coords.len() as u64).to_le_bytes());
for (category, item) in coords {
hasher.write(&(category.len() as u64).to_le_bytes());
hasher.write(category.as_bytes());
hasher.write(&(item.len() as u64).to_le_bytes());
hasher.write(item.as_bytes());
}
hasher.finish128().as_u128()
}
/// Hex, not a JSON number.
///
/// `serde_json` writes a `u128` as a bare number, and a JavaScript host
/// parses every JSON number as an `f64` — so anything above 2^53 comes back
/// changed, which for a surrogate means resolving to a different record or
/// to none. The wire form is a fixed 32-character lowercase hex string.
fn serialize_hex<S: Serializer>(value: u128, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&format!("{value:032x}"))
}
fn deserialize_hex<'de, D: Deserializer<'de>>(d: D) -> Result<u128, D::Error> {
let text = String::deserialize(d)?;
u128::from_str_radix(&text, 16).map_err(serde::de::Error::custom)
}
/// An opaque name for one record — the coordinate tuple minus `_Measure`.
///
/// **128 bits, argued rather than defaulted.** A collision is a silent write
/// to the wrong record, which is precisely the failure that got `_Index`
/// rejected as a surrogate, and no later check catches it: a colliding
/// surrogate resolves *successfully* to a record the client never saw. 64
/// bits reaches birthday risk around 2^32 records, which a pivot tool will
/// not see by record count — but the risk is not over the records alive at
/// one instant; it is over every record a long-lived document ever names,
/// and the consequence is unbounded and undetectable. The cost of the other
/// choice is bounded and small: 8 more bytes per *visible* cell, so a 50×20
/// viewport pays 8 KB on a full projection and far less on the diffs that
/// carry steady-state traffic. Bounded cost against silent corruption is not
/// a close call.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct RecordHash(u128);
impl RecordHash {
/// The surrogate for the record `key` belongs to.
///
/// Normalizes: a full cell key and the record it belongs to hash the
/// same, because `_Measure` is dropped first. That is why two cells
/// differing only in their measure share a surrogate, and it is a
/// property of the constructor rather than a rule callers must follow.
pub fn of(key: &CellKey) -> Self {
Self(hash_coords(TAG_RECORD, &record_of(key).0))
}
/// The 32-character hex form — also the wire form.
pub fn to_hex(self) -> String {
format!("{:032x}", self.0)
}
}
impl std::fmt::Display for RecordHash {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:032x}", self.0)
}
}
impl Serialize for RecordHash {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
serialize_hex(self.0, s)
}
}
impl<'de> Deserialize<'de> for RecordHash {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
deserialize_hex(d).map(Self)
}
}
/// An opaque name for one aggregate's coordinate constraint.
///
/// Same width and same hash as [`RecordHash`], under a different domain tag.
/// What it hashes is the whole constraint — row and column coordinates plus
/// the page *and filter* selections, which is `View::filtering_cats()`'s
/// question and not `categories_on(Axis::Page)`'s (gotcha 27) — because that
/// is exactly the set of cells the aggregate sums, and two aggregates that
/// sum different cells must not share a name.
///
/// Unlike a record, an aggregate has no existence outside the layout that
/// derives it, so nothing maintains an index of these; resolution is against
/// the current layout, and a layout change correctly makes an old one
/// unresolvable.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct AggregateHash(u128);
impl AggregateHash {
/// The surrogate for the constraint `key` expresses.
///
/// Deliberately *not* normalized: a measure on Row or Column is part of
/// what the aggregate sums, so dropping `_Measure` here would give two
/// visibly different totals one name.
pub fn of(constraint: &CellKey) -> Self {
Self(hash_coords(TAG_AGGREGATE, &constraint.0))
}
pub fn to_hex(self) -> String {
format!("{:032x}", self.0)
}
}
impl std::fmt::Display for AggregateHash {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:032x}", self.0)
}
}
impl Serialize for AggregateHash {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
serialize_hex(self.0, s)
}
}
impl<'de> Deserialize<'de> for AggregateHash {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
deserialize_hex(d).map(Self)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(pairs: &[(&str, &str)]) -> CellKey {
CellKey::new(
pairs
.iter()
.map(|(c, i)| (c.to_string(), i.to_string()))
.collect(),
)
}
/// The surrogate must mean the same thing in the process that minted it
/// and in the one that restarts under it, so it is pinned to a literal
/// rather than to "whatever this build computes".
///
/// A failure here is not a bug in the test: it means the hash, the key
/// or the byte serialization moved, and every surrogate any client holds
/// is now a name for something else. That is a wire-format break.
#[test]
fn a_records_surrogate_is_pinned_to_a_literal() {
let record = key(&[("Region", "East"), ("Product", "Shirts")]);
assert_eq!(
RecordHash::of(&record).to_hex(),
"fe4c8e5e9cbd578e0d388f4b2b5a7879"
);
}
#[test]
fn an_aggregates_surrogate_is_pinned_to_a_literal() {
let constraint = key(&[("Region", "East"), ("Product", "Shirts")]);
assert_eq!(
AggregateHash::of(&constraint).to_hex(),
"5b05bbcd2364099f68a49dbf9099e075"
);
}
/// The same coordinates under the two kinds must not collide: a client
/// holding an aggregate id must not be able to spend it as a record id
/// by accident.
#[test]
fn the_two_kinds_do_not_share_a_hash() {
let coords = key(&[("Region", "East")]);
assert_ne!(
RecordHash::of(&coords).to_hex(),
AggregateHash::of(&coords).to_hex()
);
}
/// Gotcha 1: two measures on one coordinate tuple are one record, and
/// one legal edit moves both. The unit of identity must be that record.
#[test]
fn two_cells_differing_only_in_measure_share_a_record_hash() {
let revenue = key(&[("Region", "East"), ("_Measure", "Revenue")]);
let cost = key(&[("Region", "East"), ("_Measure", "Cost")]);
assert_eq!(RecordHash::of(&revenue), RecordHash::of(&cost));
// …and it is the record's own hash, so the server can go from a
// projected cell straight to the write counter without rebuilding
// a key.
assert_eq!(
RecordHash::of(&revenue),
RecordHash::of(&key(&[("Region", "East")]))
);
}
/// Two categories, not one: a one-coordinate fixture cannot tell "the
/// item moved" from "the category moved", which is the same blind spot
/// as the one-category fixture that could not distinguish
/// `TransposeAxes` from `CycleAxis`.
#[test]
fn renaming_either_half_of_a_coordinate_changes_the_hash() {
let base = key(&[("Region", "East"), ("Product", "Shirts")]);
let item_renamed = key(&[("Region", "Eastern"), ("Product", "Shirts")]);
let category_renamed = key(&[("Zone", "East"), ("Product", "Shirts")]);
assert_ne!(RecordHash::of(&base), RecordHash::of(&item_renamed));
assert_ne!(RecordHash::of(&base), RecordHash::of(&category_renamed));
assert_ne!(
RecordHash::of(&item_renamed),
RecordHash::of(&category_renamed)
);
}
/// The length prefixes earn their place: without them these two
/// serialize to the same bytes.
#[test]
fn coordinates_that_concatenate_alike_do_not_hash_alike() {
let a = key(&[("Ab", "c")]);
let b = key(&[("A", "bc")]);
assert_ne!(RecordHash::of(&a), RecordHash::of(&b));
}
/// A surrogate that changes shape on the wire resolves to nothing or,
/// worse, to something else. Hex-as-a-string is the guard against a JSON
/// number losing its low bits in a JavaScript host.
#[test]
fn a_surrogate_round_trips_through_json_as_a_string() {
let hash = RecordHash::of(&key(&[("Region", "East")]));
let json = serde_json::to_string(&hash).expect("serializes");
assert!(json.starts_with('"'), "must be a string, got {json}");
assert_eq!(json.len(), 34, "32 hex digits plus quotes: {json}");
let back: RecordHash = serde_json::from_str(&json).expect("deserializes");
assert_eq!(back, hash);
}
/// Dropping a coordinate preserves the canonical sort, so a record built
/// by stripping `_Measure` from an out-of-order construction is the same
/// record as one built directly.
#[test]
fn construction_order_does_not_reach_the_hash() {
let one = key(&[("Region", "East"), ("Product", "Shirts")]);
let other = key(&[("Product", "Shirts"), ("Region", "East")]);
assert_eq!(RecordHash::of(&one), RecordHash::of(&other));
}
}