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:
Levi Neuwirth 2026-07-09 20:59:43 -04:00
parent 567e8214a4
commit 3baf8d050d
4 changed files with 1239 additions and 0 deletions

View File

@ -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
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.
## 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

View File

@ -83,6 +83,7 @@ mod conflict;
mod decode;
mod effect;
mod encode;
mod envdecode;
mod envelope;
mod migrate;
mod opset;
@ -111,6 +112,7 @@ pub use effect::{
NoOpReason, OperationEffect, PreconditionFailureReason, ReanchorReason, ReanchorResult,
RepairKind, RepairRecord, TupletCompensationKind,
};
pub use envdecode::{decode_envelope, EnvelopeDecodeError};
pub use envelope::{
peek_operation_id, well_formed, EnvelopeHash, OperationEnvelope, WellFormednessError,
};

View File

@ -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"
);
}