diff --git a/.github/workflows/rust-test.yml b/.github/workflows/rust-test.yml index 8ffcaf034..d5c7b0165 100644 --- a/.github/workflows/rust-test.yml +++ b/.github/workflows/rust-test.yml @@ -163,6 +163,11 @@ jobs: run: cargo test --manifest-path crates/lance-graph-dt64/Cargo.toml - name: Run xlog tests run: cargo test --manifest-path crates/lance-graph-xlog/Cargo.toml + # dr256-ternlog-lab: a workspace member whose tests no workflow ran. + # tests/lab.rs carries the fold-vs-oracle checks; tests/order_store.rs + # the persisted OrderIndex (S3) and its refusals. + - name: Run dr256-ternlog-lab tests + run: cargo test --manifest-path crates/dr256-ternlog-lab/Cargo.toml # lance-graph-report: no workflow ran its tests before D-LANESOCK-2. The # lane-socket suite is a `#[cfg(test)]` unit module (its constructor # takes an unwitnessed reader scope), so the plain run covers it. diff --git a/crates/dr256-ternlog-lab/src/lib.rs b/crates/dr256-ternlog-lab/src/lib.rs index 53bc5169c..6a9de5bb9 100644 --- a/crates/dr256-ternlog-lab/src/lib.rs +++ b/crates/dr256-ternlog-lab/src/lib.rs @@ -60,6 +60,8 @@ pub const FRIEND_OF: u32 = 0xD257_0001; const PERSON_VIEW: ClassId = 0xD256; const FRIEND_VIEW: ClassId = 0xD257; +pub mod order_store; + /// Field positions of [`PERSON`]. pub mod person { pub const ID: u8 = 0; diff --git a/crates/dr256-ternlog-lab/src/order_store.rs b/crates/dr256-ternlog-lab/src/order_store.rs new file mode 100644 index 000000000..84fea6317 --- /dev/null +++ b/crates/dr256-ternlog-lab/src/order_store.rs @@ -0,0 +1,448 @@ +//! A sealed, persistable [`OrderIndex`]: the order of one key, kept beside a +//! table, with the witness that says which table state it is valid for. +//! +//! An order index is a content-dependent preparation: its rows and keys are +//! read from the source's contents. Coordinates, class and population can all +//! stay the same while the contents move, so an order is admitted only by +//! +//! * an O(n) check minted once: at [`SealedOrder::seal`] (the cached keys +//! against the lane) and at [`SealedOrder::from_bytes`] (the bytes are a +//! permutation, in the stated order law); or +//! * an O(1) attest ([`SealedOrder::attest`]): the witness fields equal the +//! table's. +//! +//! Nothing in this module orders rows. The order is read from the index or +//! from the file and only checked. +//! +//! What the witness covers: the table's class, table population, index +//! population, row count, source revision, relation-map revision and +//! coordinates revision, the key lane (which key the order is over), the +//! presence version, and the tie-break rule ([`TIE_KEY_THEN_FRAME_POSITION`]). +//! +//! * Rows are frame-relative canonical positions: table row `0..n_rows` in +//! the table's own row order at the witnessed coordinates revision. They +//! are not global ids and not storage row addresses. The table's lanes are +//! never moved. +//! * The keys are a derived cache of source content, not a versioned copy. +//! They are checked against the lane once, at seal. A source revision that +//! differs refuses the order; no contents are kept to compare against. +//! * The presence version is stated by the caller, because the lab `Table` +//! carries none. It is an input, like the scope plane. +//! * There is no checksum. A file damaged in a way that keeps it a valid +//! permutation in order is not detected here. +//! +//! Reading is one decode into a `Vec` and a `Vec`; the crate +//! forbids `unsafe`, so the file is parsed with `from_le_bytes`. + +use crate::{OrderIndex, Origin, Table}; +use lance_graph_dt64::dr::FrameRevisions; +use lance_graph_mask_risc::LaneRef; +use std::path::Path; + +/// The one tie-break rule this layout knows: ascending key, equal keys by +/// ascending frame-relative position (the row's position in the bound frame). +pub const TIE_KEY_THEN_FRAME_POSITION: u32 = 1; + +/// Byte layout constants (public so a test can patch a field). +pub mod layout { + pub const MAGIC: [u8; 8] = *b"DRORDIX1"; + pub const VERSION: u32 = 1; + pub const OFF_MAGIC: usize = 0; + pub const OFF_VERSION: usize = 8; + pub const OFF_TIE_BREAK: usize = 12; + pub const OFF_CLASS: usize = 16; + pub const OFF_KEY_KIND: usize = 20; + pub const OFF_KEY_ID: usize = 24; + pub const OFF_TABLE_POPULATION: usize = 32; + pub const OFF_INDEX_POPULATION: usize = 40; + pub const OFF_N_ROWS: usize = 48; + pub const OFF_SOURCE: usize = 56; + pub const OFF_RELATION_MAP: usize = 64; + pub const OFF_COORDINATES: usize = 72; + pub const OFF_PRESENCE: usize = 80; + pub const HEADER_BYTES: usize = 88; +} + +/// Which key the order is over (ordered-lane identity). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyLane { + /// An `i32` field of the table's class, by basis position. + Field(u8), + /// A computed key (`OrderIndex::by_key`), named by a caller-chosen tag. + Computed(u64), +} + +/// Why sealing, reading or attesting was refused. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StoreRefused { + Io(std::io::ErrorKind), + BadMagic, + UnknownLayout { + version: u32, + }, + UnknownTieBreak { + rule: u32, + }, + UnknownKeyKind { + kind: u32, + }, + /// The byte length is not exactly `HEADER_BYTES + 8 * n_rows`. + Length { + expected: u64, + found: u64, + }, + /// The stored index claims the table's own row order. + OwnRowOrderClaimed, + /// `order` is not a permutation of `0..n_rows`; `at` is the first bad + /// index position. + NotAPermutation { + at: usize, + }, + /// `(key, row)` at `at` is not strictly greater than at `at - 1`. + OutOfOrder { + at: usize, + }, + /// seal: `KeyLane::Field(f)` names no `i32` lane of the table. + KeyLaneMissing { + field: u8, + }, + /// seal: the cached key at index position `at` is not the lane's value + /// there. + KeyMismatch { + at: usize, + }, + /// seal: the index was built over another table or revision. + IndexOfAnotherTable, + /// attest: class, table population or row count differ. + AnotherTable, + /// attest: relation map or coordinates revision differ. + MappingChanged, + /// attest: same mapping, another source revision (content may have + /// changed). + SourceChanged { + sealed: u64, + found: u64, + }, + AnotherKey, + PresenceChanged { + sealed: u64, + found: u64, + }, +} + +/// An order index with its full witness. Not `Clone`: one owner of the +/// permutation. +pub struct SealedOrder { + /// Index position -> frame-relative table row. + order: Vec, + /// The key at each index position (derived cache of source content). + keys: Vec, + index_population: u64, + origin: Origin, + key: KeyLane, + presence: u64, +} + +impl core::fmt::Debug for SealedOrder { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("SealedOrder") + .field("len", &self.order.len()) + .field("key", &self.key) + .field("revisions", &self.origin.revisions) + .field("presence", &self.presence) + .finish() + } +} + +/// The `i32` lane of field `f`, if the table holds one. +fn i32_lane<'t>(table: &Table<'t>, f: u8) -> Option<&'t [i32]> { + match table.lanes.get(usize::from(f)).copied().flatten() { + Some(LaneRef::I32(lane)) => Some(lane), + _ => None, + } +} + +fn u32_at(bytes: &[u8], off: usize) -> u32 { + u32::from_le_bytes([bytes[off], bytes[off + 1], bytes[off + 2], bytes[off + 3]]) +} + +fn u64_at(bytes: &[u8], off: usize) -> u64 { + let mut b = [0u8; 8]; + b.copy_from_slice(&bytes[off..off + 8]); + u64::from_le_bytes(b) +} + +impl SealedOrder { + /// Mint the seal: the O(n) check, once. + pub fn seal( + index: OrderIndex, + table: &Table<'_>, + key: KeyLane, + presence: u64, + ) -> Result { + if index.origin != Origin::of(table) { + return Err(StoreRefused::IndexOfAnotherTable); + } + if index.order.len() != index.origin.n_rows { + return Err(StoreRefused::IndexOfAnotherTable); + } + let field = match key { + KeyLane::Field(f) => Some(f), + KeyLane::Computed(_) => None, + }; + let lane = field.and_then(|f| i32_lane(table, f)); + if let Some(field) = field.filter(|_| lane.is_none()) { + return Err(StoreRefused::KeyLaneMissing { field }); + } + if let Some(lane) = lane { + for (at, (&row, &k)) in index.order.iter().zip(index.keys.iter()).enumerate() { + if lane.get(row as usize) != Some(&k) { + return Err(StoreRefused::KeyMismatch { at }); + } + } + } + Ok(SealedOrder { + order: index.order, + keys: index.keys, + index_population: index.population, + origin: index.origin, + key, + presence, + }) + } + + /// The file form: exactly `HEADER_BYTES + 8 * n_rows` bytes, little + /// endian, deterministic. + pub fn to_bytes(&self) -> Vec { + let (kind, id) = match self.key { + KeyLane::Field(f) => (0u32, u64::from(f)), + KeyLane::Computed(tag) => (1u32, tag), + }; + let n = self.order.len(); + let mut out = Vec::with_capacity(layout::HEADER_BYTES + 8 * n); + out.extend_from_slice(&layout::MAGIC); + out.extend_from_slice(&layout::VERSION.to_le_bytes()); + out.extend_from_slice(&TIE_KEY_THEN_FRAME_POSITION.to_le_bytes()); + out.extend_from_slice(&self.origin.class.to_le_bytes()); + out.extend_from_slice(&kind.to_le_bytes()); + out.extend_from_slice(&id.to_le_bytes()); + out.extend_from_slice(&self.origin.population.to_le_bytes()); + out.extend_from_slice(&self.index_population.to_le_bytes()); + out.extend_from_slice(&(n as u64).to_le_bytes()); + out.extend_from_slice(&self.origin.revisions.source.to_le_bytes()); + out.extend_from_slice(&self.origin.revisions.relation_map.to_le_bytes()); + out.extend_from_slice(&self.origin.revisions.coordinates.to_le_bytes()); + out.extend_from_slice(&self.presence.to_le_bytes()); + debug_assert_eq!(out.len(), layout::HEADER_BYTES); + for &row in &self.order { + out.extend_from_slice(&row.to_le_bytes()); + } + for &k in &self.keys { + out.extend_from_slice(&k.to_le_bytes()); + } + out + } + + /// Validate `bytes` in O(n) with no reordering: header, length, + /// permutation, order law. The header is read first, so a forged row + /// count never allocates. + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() < layout::HEADER_BYTES { + return Err(StoreRefused::Length { + expected: layout::HEADER_BYTES as u64, + found: bytes.len() as u64, + }); + } + if bytes[layout::OFF_MAGIC..layout::OFF_MAGIC + 8] != layout::MAGIC { + return Err(StoreRefused::BadMagic); + } + let version = u32_at(bytes, layout::OFF_VERSION); + if version != layout::VERSION { + return Err(StoreRefused::UnknownLayout { version }); + } + let rule = u32_at(bytes, layout::OFF_TIE_BREAK); + if rule != TIE_KEY_THEN_FRAME_POSITION { + return Err(StoreRefused::UnknownTieBreak { rule }); + } + let kind = u32_at(bytes, layout::OFF_KEY_KIND); + let id = u64_at(bytes, layout::OFF_KEY_ID); + if kind > 1 { + return Err(StoreRefused::UnknownKeyKind { kind }); + } + if kind == 0 && id > u64::from(u8::MAX) { + return Err(StoreRefused::UnknownKeyKind { kind }); + } + let key = if kind == 0 { + KeyLane::Field(id as u8) + } else { + KeyLane::Computed(id) + }; + let n_rows = u64_at(bytes, layout::OFF_N_ROWS); + let expected = n_rows + .checked_mul(8) + .and_then(|b| b.checked_add(layout::HEADER_BYTES as u64)) + .unwrap_or(u64::MAX); + if expected != bytes.len() as u64 { + return Err(StoreRefused::Length { + expected, + found: bytes.len() as u64, + }); + } + let table_population = u64_at(bytes, layout::OFF_TABLE_POPULATION); + let index_population = u64_at(bytes, layout::OFF_INDEX_POPULATION); + if index_population == table_population { + return Err(StoreRefused::OwnRowOrderClaimed); + } + let n = n_rows as usize; + let rows_at = layout::HEADER_BYTES; + let keys_at = rows_at + 4 * n; + let order: Vec = bytes[rows_at..keys_at] + .as_chunks::<4>() + .0 + .iter() + .map(|c| u32::from_le_bytes(*c)) + .collect(); + let keys: Vec = bytes[keys_at..] + .as_chunks::<4>() + .0 + .iter() + .map(|c| i32::from_le_bytes(*c)) + .collect(); + let mut seen = vec![0u64; n.div_ceil(64)]; + for (at, &row) in order.iter().enumerate() { + let r = row as usize; + if r >= n || (seen[r / 64] >> (r % 64)) & 1 == 1 { + return Err(StoreRefused::NotAPermutation { at }); + } + seen[r / 64] |= 1 << (r % 64); + } + for i in 1..n { + if keys[i - 1] > keys[i] { + return Err(StoreRefused::OutOfOrder { at: i }); + } + if keys[i - 1] == keys[i] && order[i - 1] >= order[i] { + return Err(StoreRefused::OutOfOrder { at: i }); + } + } + Ok(SealedOrder { + order, + keys, + index_population, + origin: Origin { + class: u32_at(bytes, layout::OFF_CLASS), + population: table_population, + n_rows: n, + revisions: FrameRevisions { + source: u64_at(bytes, layout::OFF_SOURCE), + relation_map: u64_at(bytes, layout::OFF_RELATION_MAP), + coordinates: u64_at(bytes, layout::OFF_COORDINATES), + }, + }, + key, + presence: u64_at(bytes, layout::OFF_PRESENCE), + }) + } + + pub fn write_to(&self, path: &Path) -> Result<(), StoreRefused> { + std::fs::write(path, self.to_bytes()).map_err(|e| StoreRefused::Io(e.kind())) + } + + pub fn read_from(path: &Path) -> Result { + let bytes = std::fs::read(path).map_err(|e| StoreRefused::Io(e.kind()))?; + SealedOrder::from_bytes(&bytes) + } + + /// O(1): do the witness fields equal this table, key and presence + /// version? No lane is read; the cost does not depend on `n_rows`. + pub fn attest( + &self, + table: &Table<'_>, + key: KeyLane, + presence: u64, + ) -> Result<(), StoreRefused> { + let now = Origin::of(table); + if now.class != self.origin.class + || now.population != self.origin.population + || now.n_rows != self.origin.n_rows + { + return Err(StoreRefused::AnotherTable); + } + if now.revisions.relation_map != self.origin.revisions.relation_map + || now.revisions.coordinates != self.origin.revisions.coordinates + { + return Err(StoreRefused::MappingChanged); + } + if now.revisions.source != self.origin.revisions.source { + return Err(StoreRefused::SourceChanged { + sealed: self.origin.revisions.source, + found: now.revisions.source, + }); + } + if key != self.key { + return Err(StoreRefused::AnotherKey); + } + if presence != self.presence { + return Err(StoreRefused::PresenceChanged { + sealed: self.presence, + found: presence, + }); + } + Ok(()) + } + + /// `attest`, then hand the permutation to the existing kernel. The + /// vectors move; nothing is copied or reordered. + pub fn into_index( + self, + table: &Table<'_>, + key: KeyLane, + presence: u64, + ) -> Result { + self.attest(table, key, presence)?; + Ok(OrderIndex { + order: self.order, + keys: self.keys, + population: self.index_population, + origin: self.origin, + }) + } + + /// Whether this seal holds exactly `index`: permutation, keys, index + /// population and origin. Read-only; compares lanes by position. + pub fn matches_index(&self, index: &OrderIndex) -> bool { + self.order == index.order + && self.keys == index.keys + && self.index_population == index.population + && self.origin == index.origin + } + + /// Index position -> frame-relative table row. + pub fn rows(&self) -> &[u32] { + &self.order + } + + /// The key at each index position (a derived cache of source content). + pub fn keys(&self) -> &[i32] { + &self.keys + } + + pub fn len(&self) -> usize { + self.order.len() + } + + pub fn is_empty(&self) -> bool { + self.order.is_empty() + } + + pub fn key(&self) -> KeyLane { + self.key + } + + pub fn presence(&self) -> u64 { + self.presence + } + + pub fn source_revision(&self) -> u64 { + self.origin.revisions.source + } +} diff --git a/crates/dr256-ternlog-lab/tests/order_store.rs b/crates/dr256-ternlog-lab/tests/order_store.rs new file mode 100644 index 000000000..fa0e8926b --- /dev/null +++ b/crates/dr256-ternlog-lab/tests/order_store.rs @@ -0,0 +1,475 @@ +//! The sealed order index: persist, reopen, attest. Every refusal has a test +//! that fires and a silent twin on a non-trivial input. Expected sequences are +//! literals derived by hand from the fixture; nothing here orders rows. + +use dr256_ternlog_lab::order_store::{ + layout, KeyLane, SealedOrder, StoreRefused, TIE_KEY_THEN_FRAME_POSITION, +}; +use dr256_ternlog_lab::*; +use lance_graph_dt64::dr::Scope; +use lance_graph_mask_risc::LaneRef; + +const READER: Scope = Scope { id: 7 }; +const AGE: [i32; 12] = [30, 20, 30, 10, 20, 30, 10, 40, 20, 10, 40, 30]; +// ids are not the positions: id[i] = 1000 + 7 * i +const BY_AGE_THEN_POSITION: [u32; 12] = [3, 6, 9, 1, 4, 8, 0, 2, 5, 11, 7, 10]; +const KEY: KeyLane = KeyLane::Field(person::AGE); +const PRESENCE: u64 = 5; +const INDEX_POP: u64 = 99; + +struct Fx { + id: Vec, + age: Vec, +} + +impl Fx { + fn new() -> Fx { + Fx::with_age(AGE.to_vec()) + } + fn with_age(age: Vec) -> Fx { + Fx { + id: (0..12u32).map(|i| 1000 + 7 * i).collect(), + age, + } + } + fn table(&self, rev: u64) -> Table<'_> { + Table::new( + PERSON, + 1, + 12, + rev, + READER, + &[ + (person::ID, LaneRef::U32(&self.id)), + (person::AGE, LaneRef::I32(&self.age)), + ], + ) + } + fn index(&self, t: &Table<'_>) -> OrderIndex { + OrderIndex::by_i32(t, person::AGE, INDEX_POP).unwrap() + } + fn sealed(&self) -> SealedOrder { + let t = self.table(1); + SealedOrder::seal(self.index(&t), &t, KEY, PRESENCE).unwrap() + } +} + +// Keys at each index position, by hand: age 10 at rows 3,6,9; 20 at 1,4,8; +// 30 at 0,2,5,11; 40 at 7,10. +const KEYS_BY_AGE: [i32; 12] = [10, 10, 10, 20, 20, 20, 30, 30, 30, 30, 40, 40]; +// With AGE[3] = 50: 10 at 6,9; 20 at 1,4,8; 30 at 0,2,5,11; 40 at 7,10; 50 at 3. +const BY_AGE_THEN_POSITION_MOVED: [u32; 12] = [6, 9, 1, 4, 8, 0, 2, 5, 11, 7, 10, 3]; + +fn patch_u32(b: &mut [u8], off: usize, v: u32) { + b[off..off + 4].copy_from_slice(&v.to_le_bytes()); +} +fn patch_u64(b: &mut [u8], off: usize, v: u64) { + b[off..off + 8].copy_from_slice(&v.to_le_bytes()); +} +fn get_u32(b: &[u8], off: usize) -> u32 { + u32::from_le_bytes([b[off], b[off + 1], b[off + 2], b[off + 3]]) +} +fn order_off(i: usize) -> usize { + layout::HEADER_BYTES + 4 * i +} +fn key_off(i: usize) -> usize { + layout::HEADER_BYTES + 4 * 12 + 4 * i +} +fn swap_u32(b: &mut [u8], a: usize, c: usize) { + let (x, y) = (get_u32(b, a), get_u32(b, c)); + patch_u32(b, a, y); + patch_u32(b, c, x); +} +fn tmp(name: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("dr256-order-{}-{name}.ordix", std::process::id())) +} + +#[test] +fn a_sealed_order_survives_persist_and_reopen_and_reproduces_the_sequence() { + let fx = Fx::new(); + let want_rows: Vec = BY_AGE_THEN_POSITION.to_vec(); + + // The index as the real constructor builds it, sealed, written, read back. + let sealed = fx.sealed(); + assert!(sealed.matches_index(&fx.index(&fx.table(1)))); + let bytes = sealed.to_bytes(); + assert_eq!(bytes.len(), 88 + 8 * 12); + assert_eq!( + get_u32(&bytes, layout::OFF_TIE_BREAK), + TIE_KEY_THEN_FRAME_POSITION + ); + let path = tmp("t1"); + sealed.write_to(&path).unwrap(); + assert_eq!(std::fs::metadata(&path).unwrap().len(), 88 + 8 * 12); + + let reopened = SealedOrder::read_from(&path).unwrap(); + let fresh = fx.table(1); + assert_eq!(reopened.attest(&fresh, KEY, PRESENCE), Ok(())); + assert_eq!(reopened.rows(), &BY_AGE_THEN_POSITION[..]); + assert_eq!(reopened.rows(), &want_rows[..]); + assert_eq!(reopened.keys(), &KEYS_BY_AGE[..]); + // Lossless: the reopened seal equals the original index (permutation, + // keys, population, origin). + assert!(reopened.matches_index(&fx.index(&fresh))); + let ids: Vec = reopened.rows().iter().map(|&r| fx.id[r as usize]).collect(); + let want_ids: Vec = want_rows.iter().map(|&r| 1000 + 7 * r).collect(); + assert_eq!(ids, want_ids); + assert_eq!( + SealedOrder::read_from(&path).unwrap().rows(), + reopened.rows() + ); + assert_eq!(reopened.to_bytes(), bytes); + assert_eq!(reopened.len(), 12); + assert_eq!(reopened.key(), KEY); + assert_eq!(reopened.presence(), PRESENCE); + std::fs::remove_file(&path).unwrap(); +} + +#[test] +fn a_reopened_index_feeds_the_existing_range_count() { + let world = World::new(); + let fx = Fx::new(); + let table = fx.table(1); + let mask = select(&world.view, PERSON, &["person_id", "age"]).unwrap(); + let reopened = SealedOrder::from_bytes(&fx.sealed().to_bytes()).unwrap(); + let original = fx.index(&table); + assert!(reopened.matches_index(&original)); + let index = reopened.into_index(&table, KEY, PRESENCE).unwrap(); + // Same answers as the original through the existing functions. + assert_eq!(index.positions(), original.positions()); + for (lo, hi) in [(10, 10), (20, 30), (25, 40), (41, 60), (0, 100)] { + assert_eq!(index.between(lo, hi), original.between(lo, hi)); + } + assert_eq!(index.gt(20), original.gt(20)); + + let visible = |r: usize| !r.is_multiple_of(3); + let mut prepared = prepare_order(&world, &table, &mask, &index, visible).unwrap(); + let got = prepared + .count(&world, &table, &index, index.between(20, 30)) + .unwrap(); + let in_band = |r: usize| (20..=30).contains(&AGE[r]); + let want = (0..12).filter(|&r| visible(r) && in_band(r)).count() as u64; + let all = (0..12).filter(|&r| in_band(r)).count() as u64; + assert_eq!(got, want); + assert!(want > 0 && want < all, "visibility removes rows"); + for (at, &row) in BY_AGE_THEN_POSITION.iter().enumerate() { + assert_eq!(index.row(at), Some(row)); + } + let mut again = prepare_order(&world, &table, &mask, &original, visible).unwrap(); + assert_eq!( + again + .count(&world, &table, &original, original.between(20, 30)) + .unwrap(), + want + ); +} + +#[test] +fn content_changed_with_the_mapping_unchanged_is_refused() { + let fx = Fx::new(); + let mut moved = AGE.to_vec(); + moved[3] = 50; + let after_fx = Fx::with_age(moved.clone()); + let after = after_fx.table(2); + assert_ne!( + BY_AGE_THEN_POSITION_MOVED, BY_AGE_THEN_POSITION, + "the old order would be wrong" + ); + let rebuilt = after_fx.index(&after); + for (at, &row) in BY_AGE_THEN_POSITION_MOVED.iter().enumerate() { + assert_eq!( + rebuilt.row(at), + Some(row), + "the literal matches the real path" + ); + } + + let err = StoreRefused::SourceChanged { + sealed: 1, + found: 2, + }; + assert_eq!(fx.sealed().attest(&after, KEY, PRESENCE), Err(err.clone())); + assert_eq!( + fx.sealed().into_index(&after, KEY, PRESENCE).err(), + Some(err) + ); + assert_eq!(fx.sealed().attest(&fx.table(1), KEY, PRESENCE), Ok(())); +} + +#[test] +fn advancing_the_source_refuses_and_a_rebuilt_seal_answers() { + let fx = Fx::new(); + let sealed = fx.sealed(); + let mut table = fx.table(1); + table.advance_source(); + assert_eq!( + sealed.attest(&table, KEY, PRESENCE), + Err(StoreRefused::SourceChanged { + sealed: 1, + found: 2 + }) + ); + let rebuilt = SealedOrder::seal(fx.index(&table), &table, KEY, PRESENCE).unwrap(); + assert_eq!(rebuilt.attest(&table, KEY, PRESENCE), Ok(())); + assert_eq!(rebuilt.source_revision(), 2); +} + +#[test] +fn another_table_is_refused() { + let fx = Fx::new(); + let sealed = fx.sealed(); + let friend = Table::new(FRIEND_OF, 1, 12, 1, READER, &[(0, LaneRef::U32(&fx.id))]); + assert_eq!( + sealed.attest(&friend, KEY, PRESENCE), + Err(StoreRefused::AnotherTable) + ); + let other_pop = Table::new( + PERSON, + 2, + 12, + 1, + READER, + &[ + (person::ID, LaneRef::U32(&fx.id)), + (person::AGE, LaneRef::I32(&fx.age)), + ], + ); + assert_eq!( + sealed.attest(&other_pop, KEY, PRESENCE), + Err(StoreRefused::AnotherTable) + ); + let short = Table::new( + PERSON, + 1, + 11, + 1, + READER, + &[ + (person::ID, LaneRef::U32(&fx.id[..11])), + (person::AGE, LaneRef::I32(&fx.age[..11])), + ], + ); + assert_eq!( + sealed.attest(&short, KEY, PRESENCE), + Err(StoreRefused::AnotherTable) + ); + assert_eq!(sealed.attest(&fx.table(1), KEY, PRESENCE), Ok(())); +} + +#[test] +fn a_moved_mapping_is_refused() { + let fx = Fx::new(); + let table = fx.table(1); + let good = fx.sealed().to_bytes(); + let silent = SealedOrder::from_bytes(&good).unwrap(); + assert_eq!(silent.attest(&table, KEY, PRESENCE), Ok(())); + for off in [layout::OFF_COORDINATES, layout::OFF_RELATION_MAP] { + let mut b = good.clone(); + patch_u64(&mut b, off, 2); + let s = SealedOrder::from_bytes(&b).unwrap(); + assert_eq!( + s.attest(&table, KEY, PRESENCE), + Err(StoreRefused::MappingChanged) + ); + } +} + +#[test] +fn another_key_and_another_presence_version_are_refused() { + let fx = Fx::new(); + let table = fx.table(1); + let s = fx.sealed(); + assert_eq!( + s.attest(&table, KeyLane::Field(person::ID), PRESENCE), + Err(StoreRefused::AnotherKey) + ); + assert_eq!( + s.attest(&table, KeyLane::Computed(u64::from(person::AGE)), PRESENCE), + Err(StoreRefused::AnotherKey) + ); + assert_eq!( + s.attest(&table, KEY, 6), + Err(StoreRefused::PresenceChanged { + sealed: 5, + found: 6 + }) + ); + assert_eq!(s.attest(&table, KEY, PRESENCE), Ok(())); +} + +#[test] +fn the_key_then_frame_position_tie_break_is_part_of_the_file() { + let good = Fx::new().sealed().to_bytes(); + assert!(SealedOrder::from_bytes(&good).is_ok()); + let mut b = good.clone(); + // Index positions 0 and 1 hold rows 3 and 6, both key 10. + swap_u32(&mut b, order_off(0), order_off(1)); + assert_eq!( + SealedOrder::from_bytes(&b).err(), + Some(StoreRefused::OutOfOrder { at: 1 }) + ); +} + +#[test] +fn keys_out_of_order_are_refused() { + let mut b = Fx::new().sealed().to_bytes(); + // Keys are 10,10,10,20,...; swapping positions 2 and 3 gives 10,10,20,10: + // the first descending pair is at position 3. + swap_u32(&mut b, key_off(2), key_off(3)); + assert_eq!( + SealedOrder::from_bytes(&b).err(), + Some(StoreRefused::OutOfOrder { at: 3 }) + ); +} + +#[test] +fn a_repeated_or_out_of_range_row_is_refused() { + let good = Fx::new().sealed().to_bytes(); + // Row 7 sits at position 10; writing it at 11 repeats it. + let mut repeat = good.clone(); + patch_u32(&mut repeat, order_off(11), 7); + assert_eq!( + SealedOrder::from_bytes(&repeat).err(), + Some(StoreRefused::NotAPermutation { at: 11 }) + ); + let mut range = good.clone(); + patch_u32(&mut range, order_off(0), 12); + assert_eq!( + SealedOrder::from_bytes(&range).err(), + Some(StoreRefused::NotAPermutation { at: 0 }) + ); + assert!(SealedOrder::from_bytes(&good).is_ok()); +} + +#[test] +fn a_damaged_header_is_refused() { + let good = Fx::new().sealed().to_bytes(); + let refused = |b: &[u8]| SealedOrder::from_bytes(b).err(); + let mut b = good.clone(); + b[0] ^= 0xFF; + assert_eq!(refused(&b), Some(StoreRefused::BadMagic)); + let mut b = good.clone(); + patch_u32(&mut b, layout::OFF_VERSION, 2); + assert_eq!( + refused(&b), + Some(StoreRefused::UnknownLayout { version: 2 }) + ); + let mut b = good.clone(); + patch_u32(&mut b, layout::OFF_TIE_BREAK, 2); + assert_eq!(refused(&b), Some(StoreRefused::UnknownTieBreak { rule: 2 })); + let mut b = good.clone(); + patch_u32(&mut b, layout::OFF_KEY_KIND, 7); + assert_eq!(refused(&b), Some(StoreRefused::UnknownKeyKind { kind: 7 })); + let mut b = good.clone(); + patch_u64(&mut b, layout::OFF_KEY_ID, 256); + assert_eq!(refused(&b), Some(StoreRefused::UnknownKeyKind { kind: 0 })); + let mut b = good.clone(); + patch_u64(&mut b, layout::OFF_INDEX_POPULATION, 1); + assert_eq!(refused(&b), Some(StoreRefused::OwnRowOrderClaimed)); + assert!(SealedOrder::from_bytes(&good).is_ok()); +} + +#[test] +fn a_wrong_length_is_refused() { + let good = Fx::new().sealed().to_bytes(); + assert_eq!(good.len(), 184); + let refused = |b: &[u8]| SealedOrder::from_bytes(b).err(); + assert_eq!( + refused(&good[..183]), + Some(StoreRefused::Length { + expected: 184, + found: 183 + }) + ); + let mut longer = good.clone(); + longer.push(0); + assert_eq!( + refused(&longer), + Some(StoreRefused::Length { + expected: 184, + found: 185 + }) + ); + assert_eq!( + refused(&good[..10]), + Some(StoreRefused::Length { + expected: 88, + found: 10 + }) + ); + let mut forged = good.clone(); + patch_u64(&mut forged, layout::OFF_N_ROWS, u64::MAX); + assert_eq!( + refused(&forged), + Some(StoreRefused::Length { + expected: u64::MAX, + found: 184 + }) + ); + assert!(SealedOrder::from_bytes(&good).is_ok()); +} + +#[test] +fn seal_checks_the_cached_keys_against_the_lane_once() { + let fx = Fx::new(); + let table = fx.table(1); + let negated = |t: &Table<'_>| OrderIndex::by_key(t, INDEX_POP, |r| -AGE[r]).unwrap(); + assert_eq!( + SealedOrder::seal(negated(&table), &table, KEY, PRESENCE).err(), + Some(StoreRefused::KeyMismatch { at: 0 }) + ); + assert!(SealedOrder::seal(negated(&table), &table, KeyLane::Computed(1), PRESENCE).is_ok()); + assert!(SealedOrder::seal(fx.index(&table), &table, KEY, PRESENCE).is_ok()); +} + +#[test] +fn seal_refuses_a_missing_key_lane_and_another_tables_index() { + let fx = Fx::new(); + let table = fx.table(1); + for field in [person::ID, person::NAME] { + assert_eq!( + SealedOrder::seal(fx.index(&table), &table, KeyLane::Field(field), PRESENCE).err(), + Some(StoreRefused::KeyLaneMissing { field }) + ); + } + let later = fx.table(2); + assert_eq!( + SealedOrder::seal(fx.index(&table), &later, KEY, PRESENCE).err(), + Some(StoreRefused::IndexOfAnotherTable) + ); +} + +#[test] +fn an_index_over_more_rows_than_the_table_states_is_refused() { + let fx = Fx::new(); + // The lane holds 12 rows but the table states 10. + let table = Table::new( + PERSON, + 1, + 10, + 1, + READER, + &[(person::AGE, LaneRef::I32(&fx.age))], + ); + let index = OrderIndex::by_i32(&table, person::AGE, INDEX_POP).unwrap(); + assert_eq!( + SealedOrder::seal(index, &table, KEY, PRESENCE).err(), + Some(StoreRefused::IndexOfAnotherTable) + ); + assert!(SealedOrder::seal(fx.index(&fx.table(1)), &fx.table(1), KEY, PRESENCE).is_ok()); +} + +#[test] +fn a_missing_file_is_an_io_refusal() { + let path = tmp("missing"); + assert_eq!( + SealedOrder::read_from(&path).err(), + Some(StoreRefused::Io(std::io::ErrorKind::NotFound)) + ); +} + +#[test] +fn the_module_never_sorts() { + let src = include_str!("../src/order_store.rs"); + assert!(!src.contains(".sort")); +}