Push 5 / P5: the missing inverse -- bytes back to an operation envelope
Text Projection requires decoding operation envelopes. No such decoder existed, and the hole was bigger than the task: the format was WRITE-ONLY for operations. A bundle's envelope blocks decoded to opaque byte strings, OperationKind had an encoder and no decoder, nothing outside epiphany-bundle even called decode_block, and nothing anywhere reconstructed an OperationEnvelope. Chapter 6 holds that a score's canonical state IS the set of operations committed to it -- so a bundle could be written and its score never reopened. The envelope's byte layout was fully pinned in the Binary Format companion. Nobody wrote the inverse. epiphany_ops::decode_envelope is that inverse. The first thing built on it is testkit/tests/bundle_reopen.rs: create a bundle from 400 generated envelopes, commit, take the bytes, reopen from nothing but bytes, decode every envelope, rebuild the OperationSet, reduce -- and get the same canonical state. That test could not have been written before this commit. Strict in two layers, per the P2 lesson. A whole-envelope re-encode-and-compare guard, sound here because every sequence in this encoding is normalized by its encoder. Plus per-site checks where the rule deserves its own error and a future encoder change must not silently relax it: TransposeInterval.targets is a SET (seq-strictly-increasing; a duplicate is rejected, never absorbed by the BTreeSet it collects into), and the frozen Transpose.targets is a MULTISET (non-decreasing, duplicates preserved). That is the rule Push 4a wrote into the wire table and left for whoever built this decoder. And a bounded count(): a declared count past the bytes remaining is rejected before it can drive an allocation. Coverage measured, not assumed -- again. The obvious oracle (gen_envelope_set, 4000 envelopes) reaches only 28 of 31 kinds and 1 of 4 payload variants. ChangeRegionTimeModel, DeclareTransaction, Registered and all three meta payloads were untouched, and they hold the trickiest decoders: PositionRemapping, NFC strings, ResolutionAction, EnvelopeHash. So the exhaustive test drives a match on OperationKindTag, and the compiler forces a sample for every future kind. Two mutations verified. Removing the seq-strictly-increasing check still rejects -- the guard is a real backstop there -- but with the wrong error, so the per-site check earns its place on the error rather than the verdict. Removing the whole-envelope guard leaves every round-trip test green, because round-trips only ever feed canonical bytes; an_unsorted_sequence_is_rejected_by_the_whole_envelope_guard is the test that locks it, and it fails under that mutation. A trap worth remembering: PitchId::new(ReplicaId(7), 1) and OperationId::new(ReplicaId(7), 1) have identical canonical bytes -- typed ids share their byte form -- so a byte-patching test that searches for an id finds the envelope's own leading id first. Gate: fmt clean, clippy 0, 31 targets / 1031 passed / 0 failed, docs 0 under -D warnings, conformance 8/8, zero golden churn. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
567e8214a4
commit
3baf8d050d
|
|
@ -1611,3 +1611,56 @@ vector, and MUST re-encode an accepted value to its own bytes — and that
|
||||||
**accepting a `reject` vector and then normalizing it is accepting it**. Binary
|
**accepting a `reject` vector and then normalizing it is accepting it**. Binary
|
||||||
Format 0.8.0 → 0.9.0. The wire-format fuzzer stays an implementation deliverable.
|
Format 0.8.0 → 0.9.0. The wire-format fuzzer stays an implementation deliverable.
|
||||||
The corpus header now cites the requirement rather than asserting one.
|
The corpus header now cites the requirement rather than asserting one.
|
||||||
|
|
||||||
|
## Push 5 / P5 — the missing inverse: bytes back to an operation envelope
|
||||||
|
|
||||||
|
Text Projection is blocked on this, but the blocker was bigger than the task.
|
||||||
|
|
||||||
|
**The format was write-only for operations.** A bundle's envelope blocks decoded
|
||||||
|
to opaque byte strings; `OperationKind` had an encoder and no decoder; nothing in
|
||||||
|
the workspace called `decode_block` outside `epiphany-bundle` itself; and nothing
|
||||||
|
anywhere reconstructed an `OperationEnvelope`. Chapter 6 holds that a score's
|
||||||
|
canonical state *is* the set of operations committed to it — so a bundle could be
|
||||||
|
written and its score could never be reopened. The envelope's byte layout was
|
||||||
|
fully pinned (`binary_format.tex` §"Operation Envelope Encoding"); nobody wrote
|
||||||
|
the inverse.
|
||||||
|
|
||||||
|
`epiphany_ops::decode_envelope` is that inverse, and the first thing built on it
|
||||||
|
is `testkit/tests/bundle_reopen.rs`: create a bundle from 400 generated
|
||||||
|
envelopes, commit, take the bytes, reopen from nothing but bytes, decode every
|
||||||
|
envelope, rebuild the `OperationSet`, reduce — and get the same canonical state.
|
||||||
|
That test could not have been written before.
|
||||||
|
|
||||||
|
**Strictness, in two layers, per the P2 lesson.**
|
||||||
|
- A whole-envelope re-encode-and-compare guard. Sound *here* because every
|
||||||
|
sequence in this encoding is normalized by its encoder (`sorted_canonical`,
|
||||||
|
`BTreeSet`, `BTreeMap`), so non-canonical bytes cannot survive a round trip.
|
||||||
|
- Per-site checks where the rule deserves its own error, and where a future
|
||||||
|
encoder change must not silently relax it: `TransposeInterval.targets` is a
|
||||||
|
**set** (`seq^⇑`, strictly increasing — a duplicate rejected, never absorbed by
|
||||||
|
the `BTreeSet` it collects into), and the frozen `Transpose.targets` is a
|
||||||
|
**multiset** (non-decreasing, duplicates preserved). This is the rule Push 4a
|
||||||
|
wrote into the wire table and left for whoever built this decoder; it is now
|
||||||
|
enforced.
|
||||||
|
- A bounded `count()`: a declared count past the bytes remaining is rejected
|
||||||
|
before it can drive an allocation.
|
||||||
|
|
||||||
|
**Coverage, measured rather than assumed — again.** The obvious oracle
|
||||||
|
(`gen_envelope_set`, 4,000 envelopes) reaches only **28 of 31 kinds and 1 of 4
|
||||||
|
payload variants**. `ChangeRegionTimeModel`, `DeclareTransaction`, `Registered`,
|
||||||
|
and all three meta payloads were untouched — which happen to hold the trickiest
|
||||||
|
decoders (`PositionRemapping`, NFC strings, `ResolutionAction`, `EnvelopeHash`).
|
||||||
|
`every_operation_kind_and_payload_round_trips` drives an exhaustive `match` on
|
||||||
|
`OperationKindTag`, so the compiler forces a sample for every future kind.
|
||||||
|
|
||||||
|
**Mutations verified.** Removing the `seq^⇑` check still rejects (the guard is a
|
||||||
|
genuine backstop for that field) but with the wrong error — so the per-site check
|
||||||
|
earns its place on the error, not the verdict. Removing the whole-envelope guard
|
||||||
|
leaves every round-trip test green, because round-trips only ever feed canonical
|
||||||
|
bytes; `an_unsorted_sequence_is_rejected_by_the_whole_envelope_guard` is the test
|
||||||
|
that locks it, and it fails under that mutation.
|
||||||
|
|
||||||
|
**A trap worth remembering.** `PitchId::new(ReplicaId(7), 1)` and
|
||||||
|
`OperationId::new(ReplicaId(7), 1)` have *identical canonical bytes* — typed ids
|
||||||
|
share their byte form. A byte-patching test that searches for an id finds the
|
||||||
|
envelope's own leading id first. Patch by framing, or from the end.
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -83,6 +83,7 @@ mod conflict;
|
||||||
mod decode;
|
mod decode;
|
||||||
mod effect;
|
mod effect;
|
||||||
mod encode;
|
mod encode;
|
||||||
|
mod envdecode;
|
||||||
mod envelope;
|
mod envelope;
|
||||||
mod migrate;
|
mod migrate;
|
||||||
mod opset;
|
mod opset;
|
||||||
|
|
@ -111,6 +112,7 @@ pub use effect::{
|
||||||
NoOpReason, OperationEffect, PreconditionFailureReason, ReanchorReason, ReanchorResult,
|
NoOpReason, OperationEffect, PreconditionFailureReason, ReanchorReason, ReanchorResult,
|
||||||
RepairKind, RepairRecord, TupletCompensationKind,
|
RepairKind, RepairRecord, TupletCompensationKind,
|
||||||
};
|
};
|
||||||
|
pub use envdecode::{decode_envelope, EnvelopeDecodeError};
|
||||||
pub use envelope::{
|
pub use envelope::{
|
||||||
peek_operation_id, well_formed, EnvelopeHash, OperationEnvelope, WellFormednessError,
|
peek_operation_id, well_formed, EnvelopeHash, OperationEnvelope, WellFormednessError,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
//! The document round trip the format could not do until Push 5 / P5: write a
|
||||||
|
//! bundle, close it, reopen it from bytes, and rebuild the operation set the
|
||||||
|
//! score's canonical state *is* (Chapter 6 §"Design Principles").
|
||||||
|
//!
|
||||||
|
//! Before `epiphany_ops::decode_envelope` existed, a bundle's operation blocks
|
||||||
|
//! decoded to opaque byte strings and stopped there. Everything below the
|
||||||
|
//! `decode_block` call was unreachable: no `OperationEnvelope`, no
|
||||||
|
//! `OperationSet`, no reduction, no score.
|
||||||
|
|
||||||
|
use epiphany_bundle::{
|
||||||
|
decode_block, pack_operation_blocks, Bundle, DocumentId, FileUuid, Manifest, MemStore,
|
||||||
|
StagedChunk,
|
||||||
|
};
|
||||||
|
use epiphany_determinism::fuzz::SplitMix64;
|
||||||
|
use epiphany_ops::{decode_envelope, OperationSet};
|
||||||
|
|
||||||
|
fn append_roots(ctx: &epiphany_bundle::CommitContext) -> Manifest {
|
||||||
|
let mut m = ctx.previous_manifest.clone();
|
||||||
|
m.operation_roots.extend(ctx.new_chunks.iter().copied());
|
||||||
|
m
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bundle_round_trips_through_bytes_back_into_a_reduced_score() {
|
||||||
|
// A real, varied operation set: every kind the generator reaches.
|
||||||
|
let mut rng = SplitMix64::new(0x00DE_C0DE_0B00_C1E5);
|
||||||
|
let envelopes = epiphany_ops::fuzz::gen_envelope_set(&mut rng, 400);
|
||||||
|
assert!(envelopes.len() > 300, "a meaningful document");
|
||||||
|
|
||||||
|
let expected = {
|
||||||
|
let mut set = OperationSet::new();
|
||||||
|
set.accept_all(envelopes.clone());
|
||||||
|
set.reduce().canonical_bytes()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Write it.
|
||||||
|
let payloads: Vec<Vec<u8>> = envelopes
|
||||||
|
.iter()
|
||||||
|
.map(epiphany_determinism::CanonicalEncode::to_canonical_bytes)
|
||||||
|
.collect();
|
||||||
|
let staged: Vec<StagedChunk> = pack_operation_blocks(&payloads)
|
||||||
|
.into_iter()
|
||||||
|
.map(StagedChunk::operation_block)
|
||||||
|
.collect();
|
||||||
|
assert!(!staged.is_empty());
|
||||||
|
|
||||||
|
let mut bundle = Bundle::create(
|
||||||
|
MemStore::new(),
|
||||||
|
FileUuid([3; 16]),
|
||||||
|
Manifest::empty(DocumentId([9; 16])),
|
||||||
|
)
|
||||||
|
.expect("create");
|
||||||
|
bundle.commit(&staged, append_roots).expect("commit");
|
||||||
|
let image = bundle.into_store().into_bytes();
|
||||||
|
|
||||||
|
// Close it, reopen it from nothing but the bytes.
|
||||||
|
let reopened = Bundle::open(MemStore::from_bytes(image)).expect("reopen");
|
||||||
|
|
||||||
|
let mut recovered = Vec::new();
|
||||||
|
for chunk in reopened.manifest().operation_roots.clone() {
|
||||||
|
let payload = reopened.read_chunk(&chunk).expect("chunk reads");
|
||||||
|
for envelope_bytes in decode_block(&payload).expect("block frames") {
|
||||||
|
// The inverse that did not exist.
|
||||||
|
recovered.push(decode_envelope(&envelope_bytes).expect("envelope decodes"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(recovered.len(), envelopes.len(), "every envelope came back");
|
||||||
|
|
||||||
|
// Same operations, and therefore the same canonical state. Reduction is
|
||||||
|
// permutation-invariant, so this compares the document, not the file layout.
|
||||||
|
let mut set = OperationSet::new();
|
||||||
|
set.accept_all(recovered);
|
||||||
|
assert_eq!(
|
||||||
|
set.reduce().canonical_bytes(),
|
||||||
|
expected,
|
||||||
|
"the reopened bundle reduces to the same canonical state"
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue