766 lines
34 KiB
Rust
766 lines
34 KiB
Rust
//! The canonical round-trip harness (QUICKSTART, Agent F):
|
|
//!
|
|
//! > the canonical round-trip harness (serialize → bytes → deserialize → assert
|
|
//! > byte-identical re-serialization)
|
|
//!
|
|
//! This is v0 acceptance criterion 4 (canonical serialization stability), which
|
|
//! tests Appendix D's canonical-serialization layer. All tiers are real (A, B,
|
|
//! C, and D have shipped):
|
|
//!
|
|
//! 1. [`assert_roundtrip`] — the generic property over any
|
|
//! [`CanonicalEncode`] + [`CanonicalDecode`] value:
|
|
//! `decode(encode(x)) == x` and `encode(decode(encode(x))) == encode(x)`.
|
|
//! [`run_roundtrip_corpus`] sweeps it across **every** canonical-serialized
|
|
//! public type in A and B (all typed identifiers, both `RationalTime` arms,
|
|
//! every `TypedObjectId` discriminant, the time types).
|
|
//! 2. [`assert_manifest_roundtrip`] — the real bundle [`Manifest`], plus the
|
|
//! [`FixedHeader`] and [`Superblock`] slot encodings, round-tripped. The
|
|
//! manifest is exercised with [`crate::generators::rich_manifest`], so
|
|
//! snapshots, blobs, extensions, profiles, retention, and the optional
|
|
//! roots — not just `operation_roots` — are covered.
|
|
//! 3. [`assert_reduction_serialization_stable`] — a score's canonical state: an
|
|
//! [`epiphany_ops::OperationSet`] is reduced to its canonical
|
|
//! [`epiphany_ops::MaterializedState`]'s `canonical_bytes` (the canonical
|
|
//! serialized score state), which survives content-addressed storage in a real
|
|
//! bundle, decodes back into the same materialized state, and re-serializes
|
|
//! byte-identically. Musical sensitivity is
|
|
//! proven by [`assert_content_mutation_changes_serialization`] (same
|
|
//! identities, changed content → different bytes) and
|
|
//! [`assert_distinct_scores_serialize_differently`].
|
|
|
|
use std::fmt::Debug;
|
|
|
|
use epiphany_bundle::{
|
|
pack_operation_blocks, Bundle, ChunkKind, CommitContext, DocumentId, FileUuid, FixedHeader,
|
|
FrontierBytes, Manifest, MemStore, OperationBlockSummary, ProfileId, ReductionAlgorithmVersion,
|
|
SchemaVersion, SlotParse, SnapshotId, SnapshotRef, StagedChunk, Superblock,
|
|
};
|
|
use epiphany_core::Score;
|
|
use epiphany_determinism::{CanonicalDecode, CanonicalEncode};
|
|
use epiphany_ops::{MaterializedState, OperationEnvelope, OperationSet};
|
|
|
|
use crate::generators;
|
|
use crate::rng::Rng;
|
|
|
|
/// The generic round-trip property. Returns the canonical bytes so callers can
|
|
/// sanity-check widths. Panics on any violation.
|
|
pub fn assert_roundtrip<T>(value: &T) -> Vec<u8>
|
|
where
|
|
T: CanonicalEncode + CanonicalDecode + PartialEq + Debug,
|
|
{
|
|
let bytes = value.to_canonical_bytes();
|
|
let decoded =
|
|
T::decode_canonical(&bytes).unwrap_or_else(|e| panic!("decode of {value:?} failed: {e}"));
|
|
assert_eq!(&decoded, value, "round-trip changed the value: {value:?}");
|
|
let re_encoded = decoded.to_canonical_bytes();
|
|
assert_eq!(
|
|
re_encoded, bytes,
|
|
"re-encode not byte-identical for {value:?}"
|
|
);
|
|
bytes
|
|
}
|
|
|
|
/// Sweeps [`assert_roundtrip`] over every canonical-serialized public type in
|
|
/// Agents A and B, drawing `iters` random values from `seed`. This is the
|
|
/// type-level half of acceptance criterion 4.
|
|
pub fn run_roundtrip_corpus(iters: u64, seed: u64) {
|
|
let mut rng = Rng::new(seed);
|
|
for _ in 0..iters {
|
|
match rng.below(43) {
|
|
// --- Agent A: epiphany-determinism ---
|
|
0 => drop(assert_roundtrip(&generators::quantized_coord(&mut rng))),
|
|
1 => drop(assert_roundtrip(&generators::canonical_f64(&mut rng))),
|
|
2 => drop(assert_roundtrip(&generators::content_hash(&mut rng))),
|
|
3 => drop(assert_roundtrip(&generators::chunk_id_gen(&mut rng))),
|
|
4 => drop(assert_roundtrip(&generators::domain_tag(&mut rng))),
|
|
// --- Agent B: the full typed-identifier family ---
|
|
5 => drop(assert_roundtrip(&generators::event_id(&mut rng))),
|
|
6 => drop(assert_roundtrip(&generators::pitch_id(&mut rng))),
|
|
7 => drop(assert_roundtrip(&generators::voice_id(&mut rng))),
|
|
8 => drop(assert_roundtrip(&generators::staff_id(&mut rng))),
|
|
9 => drop(assert_roundtrip(&generators::staff_instance_id(&mut rng))),
|
|
10 => drop(assert_roundtrip(&generators::staff_group_id(&mut rng))),
|
|
11 => drop(assert_roundtrip(&generators::region_id(&mut rng))),
|
|
12 => drop(assert_roundtrip(&generators::instrument_id(&mut rng))),
|
|
13 => drop(assert_roundtrip(&generators::part_definition_id(&mut rng))),
|
|
14 => drop(assert_roundtrip(&generators::measure_id(&mut rng))),
|
|
15 => drop(assert_roundtrip(&generators::barline_alignment_group_id(
|
|
&mut rng,
|
|
))),
|
|
16 => drop(assert_roundtrip(&generators::tuplet_id(&mut rng))),
|
|
17 => drop(assert_roundtrip(&generators::slur_id(&mut rng))),
|
|
18 => drop(assert_roundtrip(&generators::tie_id(&mut rng))),
|
|
19 => drop(assert_roundtrip(&generators::beam_id(&mut rng))),
|
|
20 => drop(assert_roundtrip(&generators::spanner_id(&mut rng))),
|
|
21 => drop(assert_roundtrip(&generators::marker_id(&mut rng))),
|
|
22 => drop(assert_roundtrip(&generators::analytical_annotation_id(
|
|
&mut rng,
|
|
))),
|
|
23 => drop(assert_roundtrip(&generators::comment_id(&mut rng))),
|
|
24 => drop(assert_roundtrip(&generators::time_signature_id(&mut rng))),
|
|
25 => drop(assert_roundtrip(&generators::analysis_layer_id(&mut rng))),
|
|
26 => drop(assert_roundtrip(&generators::repeat_structure_id(&mut rng))),
|
|
27 => drop(assert_roundtrip(&generators::lyric_line_id(&mut rng))),
|
|
28 => drop(assert_roundtrip(&generators::chord_symbol_id(&mut rng))),
|
|
29 => drop(assert_roundtrip(&generators::operation_id(&mut rng))),
|
|
// The tagged union over the whole family (every discriminant + Registered).
|
|
30 => drop(assert_roundtrip(&generators::typed_object_id(&mut rng))),
|
|
31 => drop(assert_roundtrip(&generators::graphic_object_id(&mut rng))),
|
|
32 => drop(assert_roundtrip(&generators::graphic_gesture_id(&mut rng))),
|
|
33 => drop(assert_roundtrip(&generators::view_id(&mut rng))),
|
|
34 => drop(assert_roundtrip(&generators::object_kind_registry_id(
|
|
&mut rng,
|
|
))),
|
|
35 => drop(assert_roundtrip(&generators::replica_id(&mut rng))),
|
|
36 => drop(assert_roundtrip(&generators::transaction_id(&mut rng))),
|
|
37 => drop(assert_roundtrip(&generators::integrity_anomaly_id(
|
|
&mut rng,
|
|
))),
|
|
// --- Agent B: time (both RationalTime arms via the generator) ---
|
|
38 => drop(assert_roundtrip(&generators::rational_time(&mut rng))),
|
|
39 => drop(assert_roundtrip(&generators::musical_position(&mut rng))),
|
|
40 => drop(assert_roundtrip(&generators::musical_duration(&mut rng))),
|
|
41 => drop(assert_roundtrip(&generators::wallclock_time(&mut rng))),
|
|
_ => drop(assert_roundtrip(&generators::wallclock_duration(&mut rng))),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Runs Agent A's own 1,000,000-iteration determinism round-trip gate (the
|
|
/// QUICKSTART hand-off gate), re-exposed here so the whole conformance suite has
|
|
/// a single entry point.
|
|
pub fn run_determinism_roundtrip_gate(iters: u64, seed: u64) {
|
|
epiphany_determinism::fuzz::run_round_trip_fuzz(iters, seed);
|
|
}
|
|
|
|
/// The commit-context closure used to advance a bundle: append the commit's new
|
|
/// chunks to the previous manifest's `operation_roots`.
|
|
fn append_roots(ctx: &CommitContext) -> Manifest {
|
|
let mut m = ctx.previous_manifest.clone();
|
|
m.operation_roots.extend(ctx.new_chunks.iter().copied());
|
|
m
|
|
}
|
|
|
|
/// Asserts the real bundle manifest serialization round-trips byte-stably:
|
|
/// `encode → decode → encode` is byte-identical and `decode` is a fixpoint.
|
|
/// (The manifest's reference vectors are put into canonical order at encode time,
|
|
/// so this is the bundle layer's statement of criterion 4.)
|
|
pub fn assert_manifest_roundtrip(manifest: &Manifest) {
|
|
let bytes = manifest.encode();
|
|
let decoded = Manifest::decode(&bytes).expect("manifest must decode");
|
|
let re_encoded = decoded.encode();
|
|
assert_eq!(
|
|
bytes, re_encoded,
|
|
"manifest re-encode not byte-identical (criterion 4)"
|
|
);
|
|
let decoded2 = Manifest::decode(&re_encoded).expect("re-decode");
|
|
assert_eq!(decoded, decoded2, "manifest decode is not a fixpoint");
|
|
}
|
|
|
|
/// Asserts the [`FixedHeader`] round-trips: `decode(encode(h)) == h` and the
|
|
/// re-encode is byte-identical.
|
|
pub fn assert_header_roundtrip(header: &FixedHeader) {
|
|
let bytes = header.encode();
|
|
let decoded = FixedHeader::decode(&bytes).expect("header decodes");
|
|
assert_eq!(&decoded, header, "header round-trip changed the value");
|
|
assert_eq!(
|
|
decoded.encode(),
|
|
bytes,
|
|
"header re-encode not byte-identical"
|
|
);
|
|
}
|
|
|
|
/// Asserts a committed [`Superblock`] round-trips through its 256-byte slot
|
|
/// encoding via [`Superblock::parse_slot`].
|
|
pub fn assert_superblock_roundtrip(sb: &Superblock) {
|
|
let bytes = sb.encode();
|
|
match Superblock::parse_slot(&bytes) {
|
|
SlotParse::Valid(parsed) => {
|
|
assert_eq!(&parsed, sb, "superblock round-trip changed the value");
|
|
assert_eq!(
|
|
parsed.encode(),
|
|
bytes,
|
|
"superblock re-encode not byte-identical"
|
|
);
|
|
}
|
|
SlotParse::Rejected(reject) => {
|
|
panic!("a committed superblock must parse as Valid, got {reject:?}")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Builds a non-trivial manifest by driving a real bundle through several
|
|
/// commits, then returns it.
|
|
pub fn committed_manifest(seed: u64) -> Manifest {
|
|
let mut rng = Rng::new(seed);
|
|
let uuid = FileUuid(rng.array16());
|
|
let doc = DocumentId(rng.array16());
|
|
let mut bundle =
|
|
Bundle::create(MemStore::new(), uuid, Manifest::empty(doc)).expect("create bundle");
|
|
for _ in 0..3 {
|
|
let n = rng.range_usize(1, 3);
|
|
let payloads: Vec<Vec<u8>> = (0..n).map(|_| rng.byte_vec(1, 80)).collect();
|
|
let blocks: Vec<StagedChunk> = epiphany_bundle::pack_operation_blocks(&payloads)
|
|
.into_iter()
|
|
.map(StagedChunk::operation_block)
|
|
.collect();
|
|
bundle.commit(&blocks, append_roots).expect("commit");
|
|
}
|
|
bundle.manifest().clone()
|
|
}
|
|
|
|
/// Reduces `envelopes` to the canonical serialized score state (Chapter 6: the
|
|
/// materialized graph is a deterministic reduction of the operation set).
|
|
fn canonical_score_state(envelopes: &[OperationEnvelope]) -> MaterializedState {
|
|
let mut set = OperationSet::new();
|
|
set.accept_all(envelopes.iter().cloned());
|
|
set.reduce()
|
|
}
|
|
|
|
fn canonical_score_bytes(envelopes: &[OperationEnvelope]) -> Vec<u8> {
|
|
canonical_score_state(envelopes).canonical_bytes()
|
|
}
|
|
|
|
/// Reduction-serialization stability for a **score's canonical state**
|
|
/// (acceptance criterion 4): the operation
|
|
/// set reduces to canonical bytes; re-reducing the same set yields byte-identical
|
|
/// bytes; and those bytes survive content-addressed storage in a real bundle —
|
|
/// stored as a `Snapshot` chunk referenced by the manifest's `canonical_base`
|
|
/// (its correct semantic home), hash-verified on reopen and read back
|
|
/// byte-identically.
|
|
///
|
|
/// The snapshot's `covers_causal_frontier` is the frontier the snapshot actually
|
|
/// materializes ([`crate::generators::frontier_bytes`] over the reduced
|
|
/// envelopes), so it is semantically consistent — not a falsely-empty frontier
|
|
/// that would invite a replay layer to reapply already-materialized effects.
|
|
///
|
|
/// After reopen, the snapshot payload is decoded through
|
|
/// [`MaterializedState::decode_canonical`], compared structurally with the
|
|
/// pre-storage reduction, and re-serialized byte-identically. This is the real
|
|
/// serialize → load → deserialize → reserialize cycle required by criterion 4.
|
|
pub fn assert_reduction_serialization_stable(envelopes: &[OperationEnvelope], seed: u64) {
|
|
let state = canonical_score_state(envelopes);
|
|
let canonical = state.canonical_bytes();
|
|
// re-reduce the same operation set: byte-identical canonical state.
|
|
assert_eq!(
|
|
canonical,
|
|
canonical_score_bytes(envelopes),
|
|
"re-reduction changed the canonical score bytes"
|
|
);
|
|
|
|
// serialize: stage the canonical state as a real **Snapshot** chunk and
|
|
// reference it from the manifest's `canonical_base` — its correct semantic
|
|
// home (a materialized snapshot), with the right chunk kind.
|
|
let mut rng = Rng::new(seed);
|
|
let uuid = FileUuid(rng.array16());
|
|
let doc = DocumentId(rng.array16());
|
|
let mut bundle =
|
|
Bundle::create(MemStore::new(), uuid, Manifest::empty(doc)).expect("create bundle");
|
|
let snapshot = StagedChunk {
|
|
kind: ChunkKind::Snapshot,
|
|
schema_version: SchemaVersion::V0,
|
|
payload: canonical.clone(),
|
|
};
|
|
bundle
|
|
.commit(&[snapshot], |ctx| {
|
|
let mut m = ctx.previous_manifest.clone();
|
|
let root = ctx.new_chunks[0];
|
|
let mut sid = [0u8; 16];
|
|
sid.copy_from_slice(&root.hash.as_bytes()[..16]);
|
|
m.canonical_base = Some(SnapshotRef {
|
|
snapshot_id: SnapshotId(sid),
|
|
// The frontier the snapshot actually materializes (covering every
|
|
// reduced envelope), not a falsely-empty one.
|
|
covers_causal_frontier: FrontierBytes::from_bytes(generators::frontier_bytes(
|
|
envelopes,
|
|
)),
|
|
reduction_algorithm_version: ReductionAlgorithmVersion(0),
|
|
profile_id: ProfileId::Full,
|
|
hash: root.hash,
|
|
root,
|
|
});
|
|
m
|
|
})
|
|
.expect("commit snapshot");
|
|
let image = bundle.into_store().into_bytes();
|
|
|
|
// load: reopen from exactly those bytes; the snapshot chunk is hash-verified
|
|
// on open and read back byte-identically.
|
|
let reopened = Bundle::open(MemStore::from_bytes(image)).expect("reopen bundle");
|
|
reopened
|
|
.verify_canonical_chunks()
|
|
.expect("canonical chunks intact");
|
|
let base = reopened
|
|
.manifest()
|
|
.canonical_base
|
|
.as_ref()
|
|
.expect("a canonical base");
|
|
let loaded = reopened
|
|
.read_chunk(&base.root)
|
|
.expect("read snapshot chunk back");
|
|
assert_eq!(
|
|
loaded, canonical,
|
|
"canonical state was not preserved through content-addressed storage"
|
|
);
|
|
let decoded = MaterializedState::decode_canonical(&loaded)
|
|
.expect("loaded materialized snapshot must decode");
|
|
assert_eq!(decoded, state, "decoded materialized state changed");
|
|
assert_eq!(
|
|
decoded.canonical_bytes(),
|
|
loaded,
|
|
"decoded snapshot did not reserialize byte-identically"
|
|
);
|
|
|
|
// The reopened bundle's manifest is itself a real decode→reencode fixpoint.
|
|
assert_manifest_roundtrip(reopened.manifest());
|
|
}
|
|
|
|
/// **Full-`Score` canonical serialization stability** (acceptance criterion 4,
|
|
/// the whole-graph tier — item 5's whole-score codec). The real
|
|
/// [`epiphany_core::Score`] encodes to canonical bytes, survives
|
|
/// content-addressed storage as a `Snapshot` chunk in a real bundle
|
|
/// (hash-verified on reopen), decodes back to an **equal** `Score`, and
|
|
/// re-encodes byte-identically. Unlike [`assert_reduction_serialization_stable`]
|
|
/// (which round-trips the Chapter 6 bookkeeping projection), this round-trips the
|
|
/// whole musical graph — the arena, voices, regions, cross-cutting, and
|
|
/// tombstones — through [`Score::canonical_bytes`] / [`Score::decode_canonical`].
|
|
///
|
|
/// `frontier` is the causal frontier the snapshot materializes (so the snapshot
|
|
/// reference is semantically consistent, not falsely empty).
|
|
pub fn assert_score_serialization_stable(score: &Score, frontier: &[u8], seed: u64) {
|
|
let canonical = score.canonical_bytes();
|
|
// Determinism: re-encoding the same score is byte-identical.
|
|
assert_eq!(
|
|
canonical,
|
|
score.canonical_bytes(),
|
|
"re-encoding the same score changed its bytes"
|
|
);
|
|
|
|
// serialize: stage the score as a properly-roled ACCELERATION snapshot
|
|
// (Binary Format §Schema Major 2): a `ChunkKind::Snapshot` stamped with
|
|
// the current schema major and referenced from the manifest's
|
|
// `acceleration_snapshots` — NOT the canonical base, which is the
|
|
// MaterializedState's role and stays major 0. (The `SnapshotId` here is a
|
|
// hash-truncation stand-in; its derivation is a companion open question.)
|
|
let mut rng = Rng::new(seed);
|
|
let uuid = FileUuid(rng.array16());
|
|
let doc = DocumentId(rng.array16());
|
|
let mut bundle =
|
|
Bundle::create(MemStore::new(), uuid, Manifest::empty(doc)).expect("create bundle");
|
|
let snapshot = StagedChunk {
|
|
kind: ChunkKind::Snapshot,
|
|
schema_version: SchemaVersion::for_major(2),
|
|
payload: canonical.clone(),
|
|
};
|
|
let frontier = frontier.to_vec();
|
|
bundle
|
|
.commit(&[snapshot], |ctx| {
|
|
let mut m = ctx.previous_manifest.clone();
|
|
let root = ctx.new_chunks[0];
|
|
let mut sid = [0u8; 16];
|
|
sid.copy_from_slice(&root.hash.as_bytes()[..16]);
|
|
m.acceleration_snapshots.push(SnapshotRef {
|
|
snapshot_id: SnapshotId(sid),
|
|
covers_causal_frontier: FrontierBytes::from_bytes(frontier.clone()),
|
|
reduction_algorithm_version: ReductionAlgorithmVersion(0),
|
|
profile_id: ProfileId::Full,
|
|
hash: root.hash,
|
|
root,
|
|
});
|
|
m
|
|
})
|
|
.expect("commit snapshot");
|
|
let image = bundle.into_store().into_bytes();
|
|
|
|
// load: reopen (read-write — an acceleration snapshot at the current
|
|
// major is within the snapshot role's accept-set), hash-verify, read the
|
|
// referenced chunk back byte-identically.
|
|
let reopened = Bundle::open(MemStore::from_bytes(image)).expect("reopen bundle");
|
|
assert!(
|
|
!reopened.is_read_only(),
|
|
"a current-major acceleration snapshot must not force read-only"
|
|
);
|
|
reopened
|
|
.verify_canonical_chunks()
|
|
.expect("canonical chunks intact");
|
|
let accel = reopened
|
|
.manifest()
|
|
.acceleration_snapshots
|
|
.first()
|
|
.expect("an acceleration snapshot");
|
|
assert_eq!(accel.root.schema_version, SchemaVersion::for_major(2));
|
|
let loaded = reopened
|
|
.read_chunk(&accel.root)
|
|
.expect("read snapshot chunk back");
|
|
assert_eq!(
|
|
loaded, canonical,
|
|
"score bytes were not preserved through content-addressed storage"
|
|
);
|
|
|
|
// deserialize through the SCHEMA-VERSION DISPATCH SEAM, keyed by the
|
|
// chunk's stamped major — the read path a real acceleration-snapshot
|
|
// consumer uses — then re-serialize byte-identically.
|
|
let decoded = Score::decode_canonical_versioned(&loaded, accel.root.schema_version.major)
|
|
.expect("loaded score must decode at its stamped major");
|
|
assert_eq!(&decoded, score, "decoded score changed");
|
|
assert_eq!(
|
|
decoded.canonical_bytes(),
|
|
loaded,
|
|
"decoded score did not reserialize byte-identically"
|
|
);
|
|
}
|
|
|
|
/// Confirms criterion 4 is *musically sensitive* in the strongest form: a score
|
|
/// whose operations keep **identical identities and ordering metadata** but whose
|
|
/// payload *content* changes must reduce to **different** canonical bytes. This
|
|
/// is the exact rebuttal to an id-only "serializer" that would collapse distinct
|
|
/// scores: the ids/stamps/causal contexts are byte-for-byte the same, so only the
|
|
/// content differs.
|
|
pub fn assert_content_mutation_changes_serialization() {
|
|
let (base, mutated) = generators::content_mutation_pair();
|
|
|
|
// The operation identities and ordering metadata are byte-for-byte identical;
|
|
// only one payload's *content* differs.
|
|
assert_eq!(base.len(), mutated.len());
|
|
for (b, m) in base.iter().zip(&mutated) {
|
|
assert_eq!(b.id, m.id, "operation identity changed");
|
|
assert_eq!(b.stamp, m.stamp, "operation stamp changed");
|
|
assert_eq!(
|
|
b.causal_context, m.causal_context,
|
|
"operation causal context changed"
|
|
);
|
|
}
|
|
let differing = base
|
|
.iter()
|
|
.zip(&mutated)
|
|
.filter(|(b, m)| b.payload != m.payload)
|
|
.count();
|
|
assert_eq!(differing, 1, "exactly one payload's content should differ");
|
|
|
|
assert_ne!(
|
|
canonical_score_bytes(&base),
|
|
canonical_score_bytes(&mutated),
|
|
"changing operation content (with identities held fixed) must change the canonical bytes"
|
|
);
|
|
}
|
|
|
|
/// Confirms two independently-generated operation sets reduce to different
|
|
/// canonical bytes (a coarse sensitivity check; the strong form is
|
|
/// [`assert_content_mutation_changes_serialization`]).
|
|
pub fn assert_distinct_scores_serialize_differently(
|
|
a: &[OperationEnvelope],
|
|
b: &[OperationEnvelope],
|
|
) {
|
|
assert_ne!(
|
|
canonical_score_bytes(a),
|
|
canonical_score_bytes(b),
|
|
"distinct operation sets must reduce to distinct canonical bytes"
|
|
);
|
|
}
|
|
|
|
/// Asserts the real `Manifest` **decoder** rejects corrupted bytes — exercising
|
|
/// the decode/canonicalization validation path, not just the happy round-trip.
|
|
pub fn assert_manifest_decode_rejects_corruption(manifest: &Manifest) {
|
|
let bytes = manifest.encode();
|
|
assert!(bytes.len() > 4);
|
|
// Flip a byte in the body: the stored manifest id will no longer match the
|
|
// id re-derived from the (corrupted) body, so decode must reject it.
|
|
let mut corrupt = bytes.clone();
|
|
let i = corrupt.len() / 2;
|
|
corrupt[i] ^= 0xFF;
|
|
assert!(
|
|
Manifest::decode(&corrupt).is_err(),
|
|
"a corrupted manifest must be rejected by the decoder"
|
|
);
|
|
}
|
|
|
|
/// Asserts the real `FixedHeader` decoder rejects a corrupted header (CRC).
|
|
pub fn assert_header_decode_rejects_corruption(header: &FixedHeader) {
|
|
let mut bytes = header.encode().to_vec();
|
|
bytes[8] ^= 0xFF; // a byte inside the CRC-covered region
|
|
assert!(
|
|
FixedHeader::decode(&bytes).is_err(),
|
|
"a corrupted header must be rejected by the decoder"
|
|
);
|
|
}
|
|
|
|
/// The ops-computed summary of an operation block (Chapter 8: an
|
|
/// `OperationEnvelopeBlock`'s `dvv_summary`/`min_stamp`/`max_stamp`). This is the
|
|
/// **C/D integration point**: the operation layer (Agent C) computes the
|
|
/// semantic summary by reading the envelopes — the causal frontier they cover
|
|
/// and the canonical bytes of the minimum and maximum operation stamps — and the
|
|
/// bundle (Agent D) carries it opaquely, keyed by the block's chunk id, so a
|
|
/// reader can select a block by frontier/stamp range without decoding it.
|
|
pub fn operation_block_summary(envelopes: &[OperationEnvelope]) -> OperationBlockSummary {
|
|
let stamp_bytes = |e: &OperationEnvelope| e.stamp.to_canonical_bytes();
|
|
OperationBlockSummary {
|
|
dvv_summary: FrontierBytes::from_bytes(generators::frontier_bytes(envelopes)),
|
|
min_stamp: envelopes
|
|
.iter()
|
|
.min_by_key(|e| e.stamp.reduction_tuple())
|
|
.map(stamp_bytes)
|
|
.unwrap_or_default(),
|
|
max_stamp: envelopes
|
|
.iter()
|
|
.max_by_key(|e| e.stamp.reduction_tuple())
|
|
.map(stamp_bytes)
|
|
.unwrap_or_default(),
|
|
}
|
|
}
|
|
|
|
/// Asserts an ops-computed [`operation_block_summary`] survives a real bundle
|
|
/// commit + reopen and is selectable by the block's chunk id without decoding the
|
|
/// block payload (Chapter 8 operation-block summary metadata, C/D integration).
|
|
pub fn assert_operation_block_summary_survives_storage(envelopes: &[OperationEnvelope], seed: u64) {
|
|
let summary = operation_block_summary(envelopes);
|
|
assert!(
|
|
!summary.dvv_summary.as_bytes().is_empty()
|
|
&& !summary.min_stamp.is_empty()
|
|
&& !summary.max_stamp.is_empty(),
|
|
"a non-empty envelope set must produce a non-vacuous summary"
|
|
);
|
|
|
|
let mut rng = Rng::new(seed);
|
|
let uuid = FileUuid(rng.array16());
|
|
let doc = DocumentId(rng.array16());
|
|
let mut bundle =
|
|
Bundle::create(MemStore::new(), uuid, Manifest::empty(doc)).expect("create bundle");
|
|
// A real operation block (opaque payload bytes) carrying the summary.
|
|
let blocks: Vec<StagedChunk> = pack_operation_blocks(&[rng.byte_vec(4, 64)])
|
|
.into_iter()
|
|
.map(StagedChunk::operation_block)
|
|
.collect();
|
|
bundle
|
|
.commit(&blocks, |ctx| {
|
|
let mut m = ctx.previous_manifest.clone();
|
|
let root = ctx.new_chunks[0];
|
|
m.operation_roots.push(root);
|
|
m.operation_block_summaries.insert(root.id, summary.clone());
|
|
m
|
|
})
|
|
.expect("commit operation block + summary");
|
|
|
|
// Reopen and select the summary by block id — no block payload is decoded.
|
|
let image = bundle.into_store().into_bytes();
|
|
let reopened = Bundle::open(MemStore::from_bytes(image)).expect("reopen bundle");
|
|
let root_id = reopened.manifest().operation_roots[0].id;
|
|
assert_eq!(
|
|
reopened.manifest().operation_block_summary(root_id),
|
|
Some(&summary),
|
|
"the ops-computed block summary must survive storage and be selectable"
|
|
);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use epiphany_bundle::encode_block;
|
|
|
|
#[test]
|
|
fn operation_block_summaries_survive_storage_and_select() {
|
|
let mut rng = Rng::new(0x05_5077_5044_0B0B);
|
|
for seed in 0..16u64 {
|
|
let envelopes = generators::operation_envelopes(&mut rng, 24, 3, 8, 8);
|
|
assert_operation_block_summary_survives_storage(&envelopes, seed.wrapping_add(1));
|
|
}
|
|
}
|
|
|
|
/// A real `CreateRegion` operation envelope (schema major 1: its payload
|
|
/// carries `Region.permits_spanning_slurs`).
|
|
fn create_region_envelope() -> OperationEnvelope {
|
|
use epiphany_core::{OperationId, RegionId, ReplicaId, WallClockTime};
|
|
use epiphany_ops::{
|
|
AuthorId, CausalContext, CreateRegionOp, HybridLogicalClock, OperationKind,
|
|
OperationPayload, OperationStamp,
|
|
};
|
|
let rid = RegionId::new(ReplicaId(9), 3);
|
|
let mut region = epiphany_ops::valuegen::region(rid);
|
|
region.permits_spanning_slurs = true;
|
|
let id = OperationId::new(ReplicaId(9), 1);
|
|
OperationEnvelope {
|
|
id,
|
|
author: AuthorId(0),
|
|
stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(1), 0), id),
|
|
causal_context: CausalContext::new(),
|
|
transaction: None,
|
|
payload: OperationPayload::Primitive(OperationKind::CreateRegion(CreateRegionOp {
|
|
region,
|
|
})),
|
|
}
|
|
}
|
|
|
|
/// Commits a single already-staged operation block into a fresh bundle and
|
|
/// returns the reopened bundle.
|
|
fn reopen_with_op_block(seed: u64, block: StagedChunk) -> Bundle<MemStore> {
|
|
let mut rng = Rng::new(seed);
|
|
let mut bundle = Bundle::create(
|
|
MemStore::new(),
|
|
FileUuid(rng.array16()),
|
|
Manifest::empty(DocumentId(rng.array16())),
|
|
)
|
|
.expect("create bundle");
|
|
bundle
|
|
.commit(&[block], |ctx| {
|
|
let mut m = ctx.previous_manifest.clone();
|
|
m.operation_roots.push(ctx.new_chunks[0]);
|
|
m
|
|
})
|
|
.expect("commit op block");
|
|
let image = bundle.into_store().into_bytes();
|
|
Bundle::open(MemStore::from_bytes(image)).expect("reopen bundle")
|
|
}
|
|
|
|
#[test]
|
|
fn create_region_op_block_is_stamped_major_1_and_reopens_read_write() {
|
|
let env = create_region_envelope();
|
|
assert_eq!(
|
|
env.schema_major(),
|
|
1,
|
|
"CreateRegion encodes at schema major 1"
|
|
);
|
|
// The WRITER *derives* the block major from its operations — the same
|
|
// `stage_operation_block` the real-envelope harness uses — so this proves
|
|
// derivation, not a hand-picked version. A v1 CreateRegion → major 1.
|
|
let block = crate::bundle_harness::stage_operation_block(std::slice::from_ref(&env));
|
|
let reopened = reopen_with_op_block(0xD2_0001, block);
|
|
// Major 1 is within the op-block accept-set [0,1], so the bundle opens
|
|
// read-write and the block reads back opaquely.
|
|
assert_eq!(
|
|
reopened.manifest().operation_roots[0].schema_version,
|
|
SchemaVersion::V1
|
|
);
|
|
assert!(!reopened.is_read_only());
|
|
let blocks = reopened
|
|
.read_operation_block(&reopened.manifest().operation_roots[0])
|
|
.expect("major-1 op block is admitted by the accept-set");
|
|
assert_eq!(blocks, vec![env.to_canonical_bytes()]);
|
|
}
|
|
|
|
#[test]
|
|
fn cross_cutting_op_block_is_stamped_major_2_and_reopens_read_write() {
|
|
// Schema major 2 (minimal stamping): a CreateCrossCutting payload's
|
|
// v2 fills are mandatory appended fields, so the kind is always
|
|
// major 2; the writer derives the block stamp from its operations.
|
|
use epiphany_core::{OperationId, ReplicaId, SlurId, WallClockTime};
|
|
use epiphany_ops::{
|
|
AuthorId, CausalContext, CreateCrossCuttingOp, CrossCuttingValue, HybridLogicalClock,
|
|
OperationKind, OperationPayload, OperationStamp,
|
|
};
|
|
let slur = epiphany_ops::valuegen::slur(
|
|
SlurId::new(ReplicaId(9), 5),
|
|
epiphany_core::EventId::new(ReplicaId(9), 100),
|
|
epiphany_core::EventId::new(ReplicaId(9), 101),
|
|
);
|
|
let id = OperationId::new(ReplicaId(9), 2);
|
|
let env = OperationEnvelope {
|
|
id,
|
|
author: AuthorId(0),
|
|
stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(2), 0), id),
|
|
causal_context: CausalContext::new(),
|
|
transaction: None,
|
|
payload: OperationPayload::Primitive(OperationKind::CreateCrossCutting(
|
|
CreateCrossCuttingOp {
|
|
structure: CrossCuttingValue::Slur(slur),
|
|
},
|
|
)),
|
|
};
|
|
assert_eq!(
|
|
env.schema_major(),
|
|
2,
|
|
"CreateCrossCutting encodes at schema major 2"
|
|
);
|
|
let block = crate::bundle_harness::stage_operation_block(std::slice::from_ref(&env));
|
|
let reopened = reopen_with_op_block(0xD2_0003, block);
|
|
// Major 2 is within the op-block accept-set [0,2]: read-write.
|
|
assert_eq!(
|
|
reopened.manifest().operation_roots[0].schema_version,
|
|
SchemaVersion::V2
|
|
);
|
|
assert!(!reopened.is_read_only());
|
|
let blocks = reopened
|
|
.read_operation_block(&reopened.manifest().operation_roots[0])
|
|
.expect("major-2 op block is admitted by the accept-set");
|
|
assert_eq!(blocks, vec![env.to_canonical_bytes()]);
|
|
}
|
|
|
|
#[test]
|
|
fn op_block_beyond_the_accept_set_opens_read_only() {
|
|
use epiphany_bundle::IntegrityAnomaly;
|
|
// A newer writer's op block, stamped schema major 3 — beyond the reader's
|
|
// op-block accept-set [0,2]. The bundle opens read-only preservation (the
|
|
// canonical base and manifest still read) rather than hard-rejecting.
|
|
let block = StagedChunk::operation_block_versioned(
|
|
encode_block(&[vec![1u8, 2, 3, 4]]),
|
|
SchemaVersion::new(3, 0),
|
|
);
|
|
let reopened = reopen_with_op_block(0xD2_0002, block);
|
|
assert!(
|
|
reopened.is_read_only(),
|
|
"a beyond-accept-set canonical root opens read-only"
|
|
);
|
|
assert!(reopened.anomalies().iter().any(|a| matches!(
|
|
a,
|
|
IntegrityAnomaly::UnsupportedCanonicalChunkMajor { schema_major: 3 }
|
|
)));
|
|
}
|
|
|
|
#[test]
|
|
fn corpus_round_trips() {
|
|
run_roundtrip_corpus(60_000, 0x00C0_FFEE_1234_5678);
|
|
}
|
|
|
|
#[test]
|
|
fn manifest_round_trips_and_rejects_corruption() {
|
|
for seed in 0..64u64 {
|
|
let m = committed_manifest(seed.wrapping_mul(0x9E37_79B9).wrapping_add(3));
|
|
assert_manifest_roundtrip(&m);
|
|
// The rich manifest: every optional field and reference vector.
|
|
let mut rng = Rng::new(seed.wrapping_mul(0x100_0193).wrapping_add(17));
|
|
let rich = generators::rich_manifest(&mut rng);
|
|
assert_manifest_roundtrip(&rich);
|
|
// The decoder rejects corruption (canonicalization/validation path).
|
|
assert_manifest_decode_rejects_corruption(&rich);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn header_and_superblock_round_trip() {
|
|
let mut rng = Rng::new(0x4845_4144); // "HEAD"
|
|
for _ in 0..64 {
|
|
let header = FixedHeader::new(FileUuid(rng.array16()));
|
|
assert_header_roundtrip(&header);
|
|
assert_header_decode_rejects_corruption(&header);
|
|
}
|
|
// A real committed superblock from a live bundle.
|
|
let uuid = FileUuid(rng.array16());
|
|
let doc = DocumentId(rng.array16());
|
|
let mut bundle =
|
|
Bundle::create(MemStore::new(), uuid, Manifest::empty(doc)).expect("create");
|
|
bundle
|
|
.commit(
|
|
&[StagedChunk::operation_block(encode_block(&[vec![1u8; 8]]))],
|
|
append_roots,
|
|
)
|
|
.expect("commit");
|
|
assert_header_roundtrip(bundle.header());
|
|
assert_superblock_roundtrip(bundle.superblock());
|
|
// Generated committed superblocks round-trip through the slot encoding.
|
|
for _ in 0..32 {
|
|
assert_superblock_roundtrip(&generators::superblock(&mut rng));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn scores_serialize_stably_and_distinctly() {
|
|
for seed in 0..48u64 {
|
|
let mut rng = Rng::new(seed.wrapping_mul(0x9E37_79B9));
|
|
let score = generators::operation_envelopes(&mut rng, 40, 3, 6, 6);
|
|
assert_reduction_serialization_stable(&score, seed);
|
|
let other = generators::operation_envelopes(&mut rng, 41, 3, 6, 6);
|
|
assert_distinct_scores_serialize_differently(&score, &other);
|
|
}
|
|
// Strong sensitivity: same identities, changed content → different bytes.
|
|
assert_content_mutation_changes_serialization();
|
|
}
|
|
}
|