Pushes 1+3: fix the MUST-level violations, wire the types-only machinery

Two audit pushes whose code edits interleave line-by-line in the same
files (reduce.rs, bundle.rs, the DECISIONS logs), committed together so
the tree at every commit builds. Gate: 784 workspace tests pass, clippy
-D warnings clean, fmt clean.

Push 1 — the true MUST violations, all fixed:
- bundle: zstd read support on both read paths, output bounded by the
  declared uncompressed_length, typed decompression errors, explicit
  CompressedManifest rejection (zstd 0.13 workspace dep; write path
  stays uncompressed per the Phase-3 deferral).
- ops: system-derived counter collision check — mint registry seeded
  from the base graph, canonical-order pre-walk, halt via the new
  PendingReason::HaltedBySystemCollision (discriminant 4, additive)
  with transaction-atomicity and causal-dependent closure; neither
  input set occupies a collided counter. canonical_pitch_bytes made
  pub in core for the MUSCSPCH preimage.
- ops: Transpose skips tombstoned targets per the catalog; missing
  targets still refuse the whole operation.
- ops: marker re-anchoring recorded as a RepairRecord in the
  triggering operation's effect; ResolveConflict meta-conflicts name
  both resolvers; base-free pitch-id freshness; reserved effect
  vocabulary annotated.
- core: decomposition pre-pass honors authored attachments
  (resolve_decomposition, spec-default precedence); inversion
  tolerance typed as a TempoIntegration-class Tolerance.
- CONFORMANCE.md: the determinism conformance statement required by
  Appendix D — all seven declarations.

Push 3 — wiring the types-only machinery:
- layout-ir/engrave: to_constrained emits real constraints (successive
  notehead no-collision chains, per-glyph region containment, soft
  user-break constraints); ConstraintStrength{Required, Preferred}
  with strength-by-rule; Preferred violations surface as warnings, not
  failures; StubSolver reworked honest-but-renderable. SVG goldens
  byte-identical; snapshot constraint counts regenerated (0->90/15).
- layout-ir: to_logical projects user system/page breaks as anchored
  EngravingOverrides with paired UserOverride-sourced decisions
  (OverrideKind::SystemBreak/PageBreak carry TimeAnchor, ratified in
  the spec alongside).
- layout-ir/ops/editor-core: edit-barrier bridge — decode mirrors for
  the whole barrier tree (reject-never-normalize, NFC revalidation,
  MAX_CONDITION_DEPTH = 64), golden-locked blob codec for the
  ExtensionDeclaration fields, a barrier gate in apply and
  apply_transaction backed by a Score oracle and real containment
  contexts, and apply_unsafe recording the crossed extensions in
  extensions_requiring_tombstone() for the next bundle write.
- ops: ResolveEquivocation meta-operation per the newly ratified
  catalog entry — payload discriminant 3 (appended), set-level
  earliest-resolve-governs promotion, ResolveConflict-mirrored
  meta-conflicts, permutation-invariance fuzz; the missing golden
  locks on the OperationKind/OperationPayload wire tables added.
- ops/editor-core: validation modes — ValidationMode + a non-canonical
  advisory layer (validate.rs), an authoring gate before minting, and
  reduction pinned as replay mode by construction (canonical bytes
  untouched).
- bundle: the operation index (opindex.rs) — provisional golden-locked
  payload, binary-search locate, staleness defined as full-ChunkRef
  set equality against operation_roots, and the reject-and-rebuild
  discipline (a defective index is never bundle corruption).
- ops: re-anchoring rule table completed — the four-key "nearest"
  ordering computed from base-free ledger indices; markers re-anchor
  to the nearest live event in the same staff instance (replacing the
  Push-1 region-start stand-in); cue-source cascade; graphic-gesture
  Events/Range/Free rows; comment and analytical-annotation orphaning.
  Zero appended discriminants.

Spec enablers ratified with Push 3: catalog §ResolveEquivocation
(0.3.0 -> 0.4.0) and anchored break overrides; 16 new Pass-12 rows
filed (C1-C4, K5-K7, I4-I6, D1, E1-E5). The data-model payload
expansion (SlurKind, beam geometry, voltas, instrument bodies,
metadata) is deliberately staged to the Binary Format companion — the
positional graph codec has no value-level versioning, so filling those
structs is a schema-major break that should land once, with J.

Also carries the pre-existing editor-track increment: the atomic
tuplet overwrite (CascadeDeleteTuplets prunes decomposition
attachments naming the cascaded tuplet).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
This commit is contained in:
Levi Neuwirth 2026-07-02 17:10:50 -04:00
parent 80e3fed99c
commit 92aaccf7e2
56 changed files with 9936 additions and 338 deletions

127
CONFORMANCE.md Normal file
View File

@ -0,0 +1,127 @@
# Determinism Conformance Statement
This is the published conformance statement required by the core
specification's Determinism Contract (`core_spec` Appendix D,
§"Conformance Statement", `sec:det:conformance`). It covers the reference
implementation in this repository — the `epiphany-*` workspace crates — as of
the tree that carries this file. Every claim below is anchored by a test named
in the Canonical Byte-Layout Reference (Appendix E) or cited inline.
## Platform and floating-point library
The reference build and CI platform is Linux on x86-64, compiled with stable
Rust (MSRV pinned in `Cargo.toml`, `rust-version = "1.77"`), default target
options — no `fast-math`-class flags anywhere in the workspace.
Canonical state contains no *computed* floating-point values. Every float that
enters canonical state is a stored `CanonicalF64` (finite by construction,
`-0.0` canonicalized) serialized as its 8 little-endian IEEE 754 bytes
(`epiphany-determinism/src/float.rs`); no canonical algorithm derives new
float values into canonical state.
**Transcendental functions.** The only transcendentals in the workspace are
`f64::ln`/`f64::exp` from the platform's `std`/libm, used exclusively by tempo
integration (`epiphany-core/src/tempo.rs`, speed-linear and exponential
segments). Per the contract's required disposition, those conversion outputs
are declared **advisory and non-canonical**: musical time is the exact
rational, wall-clock time is exact integer nanoseconds, and no tempo-derived
float is stored in canonical chunks or hashed into canonical identity. If a
future feature promotes tempo-derived values into canonical state, adopting a
documented portable math library (or quantizing at the canonical boundary) is
a precondition, not an afterthought.
Content hashing is BLAKE3 (the `blake3` crate, workspace-pinned), which is
bit-exact by specification on all platforms.
## Parallel execution strategy
None. Every canonical algorithm — reduction, materialization, pre-passes,
encoding, hashing, layout solving — is single-threaded by construction; the
workspace contains no `rayon`, `std::thread::spawn`, or other concurrency in
any canonical path. The implementation *is* the single-threaded baseline, so
equivalence to that baseline holds by identity. Any future parallel execution
must demonstrate byte-identical output against this baseline before it ships.
## `-0.0` canonicalization and NaN/infinity rejection
`CanonicalF64::new` rejects NaN and ±infinity at runtime in **all build
profiles** (not just debug assertions) and canonicalizes `-0.0` to `+0.0`
before storage, so both are unrepresentable in canonical state. Decoding
treats non-finite float bytes as corruption (typed error, never silent
acceptance), and hash preimages accept only `CanonicalF64` values. Locked by
the unit tests in `epiphany-determinism/src/float.rs` and
`src/serialize.rs`.
## Rounding
Round-to-nearest-ties-to-even is used at every canonical quantization
boundary: `QuantizedCoord` (1/1024 staff space) quantizes via
`round_ties_even` and rejects NaN/infinity/overflow rather than saturating
(`epiphany-determinism/src/coord.rs`), and `ResolvedLayoutIR` quantizes its
f32 working coordinates through the same type at canonical emission.
Declared deviation, inside the advisory surface only: the non-canonical
wall-clock conversion in `tempo.rs` rounds nanoseconds with `f64::round`
(half-away-from-zero). It shares the tempo-integration surface declared
non-canonical above; it must be converted to ties-to-even if that surface is
ever promoted.
## Canonical iteration orders
All canonically-serialized collections iterate in the contract's orders
(§`sec:det:ordering`):
- Generic containers are `BTreeMap`/`BTreeSet` or explicitly sorted through
`CanonicalMap`/`CanonicalSet`/`sort_canonical`, whose element types must
implement the `CanonicalByteOrder` marker — a type whose `Ord` does not
match its canonical byte order is rejected at compile time
(`epiphany-determinism/src/order.rs`).
- Operation envelopes reduce in the canonical order: causal (Kahn topological
over DVV coverage), then the HLC tuple
(`epiphany-ops/src/reduce.rs::canonical_reduction_order`), property-tested
for permutation invariance at 1,000 envelopes × 10 orders plus a
10,000-set fuzz gate.
- Conflicts serialize ascending by `ConflictId`; integrity anomalies ascending
by `IntegrityAnomalyId`; chunk references by `(kind, hash, offset)`;
extension declarations by `(ExtensionId, SemVer)` as numeric tuples —
each rejected (not normalized) on decode when out of order.
`HashMap`/`HashSet` appear only in non-canonical lookup indexes, caches, and
diagnostics, or are projected through a sort before any canonical output.
## NFC normalization
Unicode normalization uses the `unicode-normalization` crate
(workspace-pinned, `0.1.x`). Catalog identifiers NFC-normalize at
construction (`epiphany-core/src/pitch.rs`); envelope string fields
NFC-normalize before hashing (`epiphany-ops/src/encode.rs`, test
`nfc_normalizes_before_hashing`); system-derived pitch identity NFC-normalizes
at the derivation boundary. Free-text fields are raw UTF-8 by the ratified
codec convention (`req:format:codec-conventions`) and are not folded.
## Declared open-question algorithms
Per §"Open Algorithm Hooks", every open-question area is either
profile-declared with a versioned identifier or errors rather than
substituting a vendor heuristic:
- **Spelling**: `SpellingAlgorithmId` `"default"` — a Temperley-style
line-of-fifths preference algorithm, v1 (`epiphany-core/src/prepass.rs`).
The identifier is the crate's proposal pending ratification (P12-H1). A
profile requesting any other id errors; nothing is silently substituted.
- **Notational decomposition**: `DecompositionAlgorithmId` `"default"` — the
integer-grid metric splitter, v1, with its scope bounds recorded as
P12-H4 (single governing meter, `MAX_DOTS = 1`, tuplet-nesting deferred).
Same no-substitution rule.
- Both pre-pass outputs are **canonical derived annotations**: deterministic
functions of `(materialized graph, profile, algorithm id)` recomputed on
materialization, never stored canonical state — so an algorithm version
change deterministically invalidates derived output without state
migration.
- **Tempo curves**: `TempoShape::Curve` integration is unimplemented and
declared as such — conversion returns `CurveIntegrationUnsupported`
rather than a wrong or vendor-specific answer. The linear/exponential
segment integration and the `wallclock_to_musical` inverse (deterministic
continued-fraction with documented iteration/denominator bounds and a
typed `TempoIntegration`-class tolerance) are advisory, per the
floating-point declaration above.

29
Cargo.lock generated
View File

@ -1061,6 +1061,7 @@ name = "epiphany-bundle"
version = "0.0.0"
dependencies = [
"epiphany-determinism",
"zstd",
]
[[package]]
@ -4540,6 +4541,34 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "zstd"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
version = "7.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.0.16+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
dependencies = [
"cc",
"pkg-config",
]
[[package]]
name = "zune-core"
version = "0.4.12"

View File

@ -81,6 +81,13 @@ num-traits = { version = "0.2", default-features = false }
# Unicode": canonical text fields MUST be NFC). Used to normalize registry /
# catalog names so canonically-equivalent spellings compare equal.
unicode-normalization = "0.1"
# Zstandard, for the bundle's chunk *read* path (Chapter 8 §"Compression":
# conforming implementations MUST support reading zstd-compressed chunks; the
# write path deliberately stays uncompressed in v0). epiphany-bundle only ever
# calls the decoder in production code; the encoder is used by its tests to
# produce compressed fixtures. Default features (legacy formats, dictionary
# building) are off — none are needed to decode standard frames.
zstd = { version = "0.13", default-features = false }
# Determinism-sensitive: never enable fast-math-style codegen on canonical
# numerical paths (Appendix D, "Rounding and CPU Behavior"). The default

View File

@ -9,6 +9,11 @@ description = "The Epiphany .musc bundle file format (spec Chapter 8): fixed hea
[dependencies]
epiphany-determinism.workspace = true
# Decode-only in production code: the read path MUST support zstd-compressed
# chunks (spec Chapter 8 §"Compression"); the writer still emits every chunk
# uncompressed (the documented Phase-3 deferral). See DECISIONS.md for why the
# libzstd binding was chosen over a pure-Rust decoder.
zstd.workspace = true
[[example]]
name = "fuzz_crash"

View File

@ -148,6 +148,123 @@ items batched."*).
- **Generation exhaustion** returns `BundleError::GenerationExhausted` rather
than overflow-panicking at `u64::MAX`.
- **Zstd chunk *reading* is supported; the write path stays uncompressed
(2026-07-01 spec-audit fix).** A spec audit flagged the read paths as the
file-format chapter's only exercised-path MUST violation: Chapter 8
§"Compression" requires conforming implementations to support *reading*
chunks "compressed with Zstandard at any level zstd defines", but
`read_and_verify_chunk`/`read_and_verify_blob` returned
`UnsupportedCompression` for anything but `None`. They now decompress
`Zstd` payloads (`Reserved` remains `UnsupportedCompression`; the writer
still emits only `None` — the QUICKSTART's compression deferral is about
the *write* path, which the spec leaves as MAY). Decisions taken:
- **Dependency: the `zstd` crate (libzstd bindings), not pure-Rust
`ruzstd`.** (1) `zstd::bulk::decompress_to_buffer` writes into a
caller-allocated buffer sized *exactly* from the declared
`uncompressed_length` — which is validated against the reader's
resource limits *before* allocation — so a hostile stream has a hard
output bound, and libzstd's decoding window is capped internally;
(2) libzstd is the reference implementation, battle-tested against
malformed frames, matching this crate's hostile-input posture;
(3) the workspace already requires a C toolchain (`blake3`'s `cc`
build), so pure Rust bought nothing here; (4) tests need an *encoder*
to produce fixtures and `ruzstd` is decode-only, so picking it would
have pulled `zstd` in anyway as a dev-dependency — two zstd
implementations in one build graph. The read-only mandate is enforced
at the call sites instead: production code never calls the encoder.
- **Length rule (spec: "reject chunks whose decompressed size
disagrees").** The output buffer is sized exactly by the declared
length: a stream that ends short yields a precise
`ChunkLengthMismatch`; one that would exceed the declaration hits
libzstd's destination-full error; malformed, truncated, and
trailing-garbage streams all fail — the latter three as the new typed
`BundleError::Decompression`. No path panics or allocates past the
declaration. Hashing (including the `id == hash` redundancy) is
unchanged and runs strictly *after* decompression, over the
uncompressed bytes — compression stays outside content identity.
- **The manifest stays mandatorily uncompressed** (§"Manifest
Encoding"). The superblock deliberately has no compression field, so
stored manifest bytes are the payload; an image whose manifest bytes
are compressed anyway fails to open (hash mismatch →
`NoValidSuperblock`, or, with a colluding hash over the compressed
bytes, a manifest decode failure). A `ChunkKind::Manifest` *chunk
reference* declaring compression is additionally refused outright with
the new typed `BundleError::CompressedManifest`, before any bytes are
read.
- `CompressionAlgorithm`'s golden-locked two-byte encoding
(`req:format:chunkkind-discriminants`) is untouched; the ratified
discriminants already modeled `Zstd { level } = 1`.
- **The operation index is implemented with a provisional, golden-locked
payload (Push-3).** Chapter 8 §"The Operation Index" defines the semantics —
an *optional, non-canonical* accelerator mapping each `OperationId` to the
`ChunkRef` of its enclosing block plus an offset within the block, O(log n)
lookup, absent → rebuild by scanning, present-but-corrupt-or-stale → MUST
reject and rebuild — but defers the byte format to the Binary Format
companion (P11-D2). Until that lands, `OperationIndex` encodes under this
crate's fixed codec conventions and the exact bytes are **golden-locked**
(`opindex::tests::payload_encoding_is_golden`), so a layout change breaks
deliberately:
```text
u32 block_count
block_count × ChunkRef — strictly ascending canonical order
(kind discriminant, hash, offset)
u32 entry_count
entry_count × { id: [u8;16], block: u32 LE, offset: u32 LE }
— strictly ascending by id bytes
```
`block` is an ordinal into the block vector; `offset` is the byte offset of
the envelope's **first content byte** within the block's *decoded*
(uncompressed) payload — exactly the coordinate `envelope_offsets` reports
(its `u32` length prefix sits at `offset - 4`). Decisions taken:
- **Layering: raw id bytes in the bundle, the peek in ops.** The bundle
stays semantics-free — entries key on the opaque 16 canonical id bytes.
That a canonical envelope *leads* with those bytes is an `epiphany-ops`
invariant, vouched for by ops' `peek_operation_id` (tested against
`encode_canonical`); builders pair it with the bundle's
`envelope_offsets` (which shares `decode_block`'s exact validation) to
produce index entries. The same "ops computes, bundle carries" split as
the block-summary metadata.
- **Reject, never normalize.** `OperationIndex::decode` rejects unsorted or
duplicated blocks or ids, a non-`OperationEnvelopeBlock` reference, an
out-of-range ordinal, and trailing bytes — the manifest decoder's
discipline, so accepted bytes are byte-stable. `build` rejects duplicate
ids (an `OperationId` occupies exactly one slot in one block) and
duplicate blocks at construction.
- **Staleness is coverage equality over full `ChunkRef`s.**
`OperationIndex::covers` is true iff the index's block set equals the
manifest's `operation_roots` set as *full references*, not just chunk
ids: `locate` hands out the index's stored refs for reading, so a ref
agreeing in hash but differing in any locator field (offset, lengths,
compression) is not the manifest's block and must count as stale rather
than steering reads elsewhere. `false` = stale → reject and rebuild.
- **A defective index is never bundle corruption** (Chapter 8 §"Canonical
and Non-Canonical Manifest Roots"). `Bundle::usable_operation_index`
packages the whole discipline: `Some` only for a declared, readable,
hash-intact, well-formed index covering the current operation roots;
`None` on *any* defect, meaning "rebuild by scanning all blocks".
`Bundle::read_operation_index` exposes the underlying failure for
diagnostics only. The testkit proves the boundary: a garbage or
byte-flipped index chunk leaves the bundle opening cleanly with all
canonical reads intact
(`bundle_harness::assert_corrupt_operation_index_is_not_bundle_corruption`).
- **The commit-time SHOULD is a builder, not a policy.** The spec says
writers SHOULD rebuild/update the index at commit when the operation set
has grown significantly. v0 deliberately ships the *mechanism*
`OperationIndex::build` from per-block `(id bytes, offset)` lists,
`StagedChunk::operation_index`, and the testkit's commit-time
rebuild-and-wire demonstration
(`bundle_harness::assert_operation_index_end_to_end` /
`scan_rebuild_operation_index`) — and no automatic "grown significantly"
heuristic; when/how often to refresh is editor policy layered above this
crate.
- **The write path stays uncompressed.** The spec's *MAY* compress
operation indexes is honored on the read side (an index chunk reads
through the same zstd-capable `read_chunk` path as any chunk); writing
compressed indexes is deferred with the rest of write-path compression.
## Known v0 limitations (deliberately deferred, not defects)
These are bounded by v0 scope (QUICKSTART "Don't do these" / "decisions you'll

View File

@ -88,8 +88,10 @@ cargo run --release --example fuzz_crash -- 1000000 # extended crash soak
## Scope boundaries (per QUICKSTART "Don't do these")
v0 writes only uncompressed chunks (zstd is deferred; the manifest is
mandatory-uncompressed regardless). It carries the text-projection *root* but does
v0 writes only uncompressed chunks (compression on the *write* path is deferred),
but *reading* zstd-compressed chunks and blobs is supported, per the spec's
§Compression MUST (the manifest is mandatory-uncompressed regardless, and a
compressed manifest is rejected). It carries the text-projection *root* but does
not implement the s-expression projection content, and it *preserves* extension
declarations and chunks but does not *evaluate* edit barriers — barrier operands
(`OperationKindTag`, `ObjectKind`, `EditBarrier`) are owned by Agents C and E.

View File

@ -46,11 +46,53 @@ pub fn encode_block(envelopes: &[Vec<u8>]) -> Vec<u8> {
/// Splits a block payload back into its opaque envelope byte strings. Total and
/// bounds-checked: a corrupt payload yields a [`DecodeError`], never a panic.
/// Implemented over [`envelope_offsets`], so the two apply *identical*
/// validation.
pub fn decode_block(payload: &[u8]) -> Result<Vec<Vec<u8>>, DecodeError> {
Ok(envelope_offsets(payload)?
.into_iter()
.map(|(_, bytes)| bytes.to_vec())
.collect())
}
/// Splits a block payload into each envelope's `(offset, bytes)` pair, under
/// exactly the validation [`decode_block`] applies (they share one code path).
///
/// The offset is the byte offset of the envelope's **first content byte**
/// within the uncompressed (decoded) block payload — that is,
/// `payload[offset .. offset + bytes.len()]` *is* the envelope's encoded
/// bytes, and the envelope's `u32` length prefix sits at `offset - 4`. This is
/// the coordinate the operation index records (Chapter 8 §"The Operation
/// Index": the `ChunkRef` of the enclosing block plus an offset within it),
/// deterministically recoverable from the block framing alone.
pub fn envelope_offsets(payload: &[u8]) -> Result<Vec<(u32, &[u8])>, DecodeError> {
// Offsets are recorded as `u32`; a payload past that range is far beyond
// every profile's block bound and unrepresentable in the index.
if payload.len() > u32::MAX as usize {
return Err(DecodeError::Malformed(
"block payload exceeds the u32 offset range",
));
}
let mut r = Reader::new(payload);
let envelopes = r.get_seq(|r| r.get_var_bytes())?;
// Mirror `Reader::get_seq`'s pre-allocation guards: the declared count is
// checked against the bytes remaining (each envelope costs at least its
// length prefix), and the reservation is capped.
const MAX_RESERVE: usize = 1024;
let count = r.get_u32()? as usize;
if count > r.remaining() {
return Err(DecodeError::LengthOverflow {
declared: count as u64,
remaining: r.remaining(),
});
}
let mut out = Vec::with_capacity(count.min(MAX_RESERVE));
for _ in 0..count {
let prefix_at = payload.len() - r.remaining();
let bytes = r.get_var_slice()?;
out.push((prefix_at as u32 + ENVELOPE_FRAMING as u32, bytes));
}
r.finish()?;
Ok(envelopes)
Ok(out)
}
/// Packs opaque envelope byte strings into block payloads at the 1 MiB soft
@ -109,6 +151,48 @@ mod tests {
assert_eq!(decode_block(&payload).unwrap(), envs);
}
#[test]
fn envelope_offsets_address_each_first_content_byte() {
let envs = vec![b"aa".to_vec(), b"b".to_vec(), b"cccc".to_vec()];
let payload = encode_block(&envs);
let got = envelope_offsets(&payload).unwrap();
assert_eq!(got.len(), 3);
// The first envelope's content begins after the u32 count and its own
// u32 length prefix: offset 8. Each subsequent offset advances by the
// previous content plus the next 4-byte prefix.
assert_eq!(got[0].0, 8);
assert_eq!(got[1].0, 8 + 2 + 4);
assert_eq!(got[2].0, 8 + 2 + 4 + 1 + 4);
for ((off, bytes), env) in got.iter().zip(&envs) {
assert_eq!(bytes, env, "the returned slice is the envelope");
// The offset definition: payload[offset .. offset+len] IS the
// envelope's encoded bytes within the decoded block payload.
let start = *off as usize;
assert_eq!(&payload[start..start + env.len()], &env[..]);
}
}
#[test]
fn envelope_offsets_validate_like_decode_block() {
let payload = encode_block(&[b"xyz".to_vec()]);
// Truncation fails both, with the same verdict.
let cut = &payload[..payload.len() - 1];
assert!(envelope_offsets(cut).is_err());
assert!(decode_block(cut).is_err());
// Trailing garbage fails both.
let mut long = payload.clone();
long.push(0);
assert!(envelope_offsets(&long).is_err());
assert!(decode_block(&long).is_err());
// A corrupt count cannot over-allocate in either.
let mut bytes = u32::MAX.to_le_bytes().to_vec();
bytes.push(0);
assert!(matches!(
envelope_offsets(&bytes),
Err(DecodeError::LengthOverflow { .. })
));
}
#[test]
fn small_envelopes_share_one_block_and_preserve_order() {
let envs: Vec<Vec<u8>> = (0..100).map(|i| vec![i as u8; 8]).collect();

View File

@ -21,6 +21,7 @@ use crate::error::{BundleError, IntegrityAnomaly};
use crate::header::{FixedHeader, SLOT_A_OFFSET, SLOT_B_OFFSET};
use crate::ids::{BlobId, FileUuid, ReductionAlgorithmVersion, SchemaVersion, WallClockTime};
use crate::manifest::{BlobRef, Manifest, ProfileDeclaration};
use crate::opindex::OperationIndex;
use crate::store::{read_vec, BlockStore, MemStore};
use crate::superblock::{
select_active, CommitState, ProfileId, Slot, SlotParse, SlotReject, Superblock, SUPERBLOCK_LEN,
@ -78,6 +79,19 @@ impl StagedChunk {
payload,
}
}
/// A staged operation-index chunk at the current schema version (Chapter 8
/// §"The Operation Index") — a non-canonical accelerator; the payload is an
/// [`OperationIndex::encode`](crate::OperationIndex::encode). v0 writes it
/// uncompressed, like every chunk (the spec's *MAY* compress indexes is a
/// write-path option this version defers).
pub fn operation_index(payload: Vec<u8>) -> Self {
StagedChunk {
kind: ChunkKind::OperationIndex,
schema_version: SchemaVersion::V0,
payload,
}
}
}
/// Context handed to a commit's manifest builder: the previous manifest, the
@ -409,6 +423,47 @@ impl<S: BlockStore> Bundle<S> {
block::decode_block(&payload).map_err(BundleError::Decode)
}
/// Reads and decodes an operation-index chunk (Chapter 8 §"The Operation
/// Index"). Rejects a reference of the wrong kind; the read itself is
/// bounded by the reader's [`MAX_CHUNK_BYTES`] policy and hash-verified
/// like any chunk read.
///
/// **The index is not canonical**: a failure here — corrupt bytes, a
/// malformed payload, even an I/O error on the index region — must NOT be
/// treated as bundle corruption. The spec's discipline is *reject and
/// rebuild from blocks*; [`Bundle::usable_operation_index`] packages it
/// (including the staleness check). Call this directly only when the
/// underlying failure itself is wanted, e.g. for diagnostics.
pub fn read_operation_index(&self, r: &ChunkRef) -> Result<OperationIndex, BundleError> {
if r.kind != ChunkKind::OperationIndex {
return Err(BundleError::Decode(DecodeError::Malformed(
"chunk reference is not an operation index",
)));
}
let payload = self.read_chunk(r)?;
OperationIndex::decode(&payload).map_err(BundleError::Decode)
}
/// The manifest's operation index, if — and only if — it is *usable*:
/// declared, readable, hash-intact, well-formed, and covering exactly the
/// manifest's current `operation_roots` ([`OperationIndex::covers`]).
/// `None` on **any** defect: absent, stale (its block set differs from the
/// operation roots), corrupt, malformed, or unreadable.
///
/// `None` always means "rebuild by scanning all blocks", never "the bundle
/// is corrupt": the operation index is an acceleration structure, not
/// canonical (Chapter 8 §"The Operation Index"), and failed verification
/// of a non-canonical chunk MUST NOT be surfaced as bundle corruption
/// (Chapter 8 §"Canonical and Non-Canonical Manifest Roots") — the reader
/// discards the index and rebuilds it from the blocks.
pub fn usable_operation_index(&self) -> Option<OperationIndex> {
let root = self.manifest.operation_index_root.as_ref()?;
let index = self.read_operation_index(root).ok()?;
index
.covers(&self.manifest.operation_roots)
.then_some(index)
}
/// The active profile's maximum uncompressed operation-block size
/// (Chapter 8 §"Operation Envelope Blocks"). The *active* profile is the one
/// the selected superblock names — a bundle opened under `Lite` must read
@ -753,10 +808,17 @@ fn reduction_version_for(manifest: &Manifest) -> ReductionAlgorithmVersion {
/// Reads and fully verifies a chunk against its reference — the shared core of
/// [`Bundle::read_chunk`] and the commit-time canonical-root validation. Checks
/// body-placement, schema-major support, declared length, the content hash, and
/// the `id == hash` redundancy (Chapter 8 §"Chunks").
/// compression support (decompressing zstd payloads), body-placement,
/// schema-major support, declared length, the content hash, and the
/// `id == hash` redundancy (Chapter 8 §"Chunks").
fn read_and_verify_chunk(store: &dyn BlockStore, r: &ChunkRef) -> Result<Vec<u8>, BundleError> {
if r.compression != CompressionAlgorithm::None {
// The manifest chunk is mandatorily uncompressed in this format version
// (Chapter 8 §"Manifest Encoding"): a compressed manifest reference is
// rejected outright, before any bytes are read.
if r.kind == ChunkKind::Manifest && r.compression != CompressionAlgorithm::None {
return Err(BundleError::CompressedManifest);
}
if let CompressionAlgorithm::Reserved(_) = r.compression {
return Err(BundleError::UnsupportedCompression);
}
// A chunk reference must point into the body, never the fixed prelude.
@ -773,16 +835,11 @@ fn read_and_verify_chunk(store: &dyn BlockStore, r: &ChunkRef) -> Result<Vec<u8>
version: r.schema_version,
});
}
// Bound the allocation by the reader's policy before touching the length.
// Bound both allocations by the reader's policy before touching a length.
enforce_limit(r.compressed_length, MAX_CHUNK_BYTES)?;
enforce_limit(r.uncompressed_length, MAX_CHUNK_BYTES)?;
let payload = read_chunk_bytes(store, r.offset, r.compressed_length)?;
if payload.len() as u64 != r.uncompressed_length {
return Err(BundleError::ChunkLengthMismatch {
expected: r.uncompressed_length,
actual: payload.len() as u64,
});
}
let stored = read_chunk_bytes(store, r.offset, r.compressed_length)?;
let payload = decode_stored_payload(stored, r.compression, r.uncompressed_length)?;
let actual = content_hash_for(r.kind, r.schema_version, &payload);
if actual != r.hash {
return Err(BundleError::ChunkHashMismatch {
@ -809,13 +866,16 @@ fn read_and_verify_blob(
b: &BlobRef,
max_bytes: u64,
) -> Result<Vec<u8>, BundleError> {
if b.compression != CompressionAlgorithm::None {
if let CompressionAlgorithm::Reserved(_) = b.compression {
return Err(BundleError::UnsupportedCompression);
}
let limit = b
.declared_max_uncompressed_length
.unwrap_or(u64::MAX)
.min(max_bytes);
// Chapter 8 §"Blobs": the declared uncompressed length is checked against
// the reader's policy *before decompression begins* (and before any
// allocation keyed on it).
enforce_limit(b.uncompressed_length, limit)?;
enforce_limit(b.compressed_length, max_bytes)?;
if b.offset < BODY_START {
@ -825,13 +885,8 @@ fn read_and_verify_blob(
file_len: store.len(),
});
}
let payload = read_chunk_bytes(store, b.offset, b.compressed_length)?;
if payload.len() as u64 != b.uncompressed_length {
return Err(BundleError::ChunkLengthMismatch {
expected: b.uncompressed_length,
actual: payload.len() as u64,
});
}
let stored = read_chunk_bytes(store, b.offset, b.compressed_length)?;
let payload = decode_stored_payload(stored, b.compression, b.uncompressed_length)?;
let actual = BlobId::of_payload(&payload).0;
if actual != b.hash || b.blob_id.0 != b.hash {
return Err(BundleError::ChunkHashMismatch {
@ -851,6 +906,70 @@ fn enforce_limit(length: u64, limit: u64) -> Result<(), BundleError> {
}
}
/// Recovers a chunk's uncompressed payload from its stored (possibly
/// compressed) bytes, verifying it is *exactly* `declared_len` bytes long
/// (Chapter 8 §"Compression" / §"Chunks": a decompressed size that disagrees
/// with the declared `uncompressed_length` is corruption). The caller has
/// already validated `declared_len` against its resource-limit policy, so
/// every allocation here is bounded. Content hashing happens strictly *after*
/// this step, over the uncompressed bytes — compression is `ChunkRef`
/// metadata, never part of content identity.
fn decode_stored_payload(
stored: Vec<u8>,
compression: CompressionAlgorithm,
declared_len: u64,
) -> Result<Vec<u8>, BundleError> {
match compression {
CompressionAlgorithm::None => {
if stored.len() as u64 != declared_len {
return Err(BundleError::ChunkLengthMismatch {
expected: declared_len,
actual: stored.len() as u64,
});
}
Ok(stored)
}
// Reading zstd at any level is a conformance MUST (Chapter 8
// §"Compression"); the declared level byte is advisory metadata the
// decoder does not need.
CompressionAlgorithm::Zstd { .. } => decompress_zstd(&stored, declared_len),
CompressionAlgorithm::Reserved(_) => Err(BundleError::UnsupportedCompression),
}
}
/// Decompresses a zstd frame sequence into a buffer sized *exactly* by the
/// declared uncompressed length, so a hostile stream can never allocate past
/// the (already limit-checked) declaration:
///
/// * a stream that would exceed `declared_len` hits libzstd's
/// destination-full error → [`BundleError::Decompression`];
/// * a stream that ends short of `declared_len` →
/// [`BundleError::ChunkLengthMismatch`];
/// * a truncated or otherwise malformed stream (including trailing garbage
/// after the final frame) → [`BundleError::Decompression`].
///
/// No path panics or allocates beyond `declared_len` plus libzstd's own
/// bounded decoding context (whose window is capped internally).
fn decompress_zstd(stored: &[u8], declared_len: u64) -> Result<Vec<u8>, BundleError> {
let declared = usize::try_from(declared_len).map_err(|_| {
// Unreachable on 64-bit targets; on narrower ones an unaddressable
// declaration is a resource-limit refusal, not a wrap.
BundleError::ResourceLimitExceeded {
length: declared_len,
limit: usize::MAX as u64,
}
})?;
let mut payload = vec![0u8; declared];
match zstd::bulk::decompress_to_buffer(stored, payload.as_mut_slice()) {
Ok(n) if n as u64 == declared_len => Ok(payload),
Ok(n) => Err(BundleError::ChunkLengthMismatch {
expected: declared_len,
actual: n as u64,
}),
Err(e) => Err(BundleError::Decompression(e)),
}
}
/// Validates that every canonical root a manifest declares resolves to a
/// present, hash-intact chunk of the *right kind and shape* before a commit
/// publishes it — so a bundle that opens normally can never carry a dangling,
@ -911,6 +1030,14 @@ fn validate_canonical_roots(
/// the body — Chapter 8 fixes the prelude layout, so a "manifest" overlapping a
/// header or superblock slot is foreign — and within the reader's manifest size
/// limit, before allocating.
///
/// The manifest chunk is mandatorily *uncompressed* in this format version
/// (Chapter 8 §"Manifest Encoding"): the superblock deliberately carries no
/// compression field, so the stored bytes ARE the payload. A bundle whose
/// manifest bytes are compressed anyway therefore fails downstream as
/// malformed: hash verification (over the uncompressed preimage) rejects the
/// slot, and even a colluding hash-over-compressed-bytes cannot survive
/// `Manifest::decode`.
fn read_manifest_payload(store: &dyn BlockStore, sb: &Superblock) -> Result<Vec<u8>, BundleError> {
if sb.manifest_offset < BODY_START {
return Err(BundleError::ChunkOutOfBounds {
@ -1857,4 +1984,323 @@ mod tests {
Err(BundleError::GenerationExhausted)
));
}
// ------------------------------------------------------------------
// Zstd read support (Chapter 8 §"Compression": reading zstd-compressed
// chunks is a conformance MUST; this crate's writer still emits only
// uncompressed chunks, so tests plant externally-compressed bytes).
// ------------------------------------------------------------------
/// Appends externally-zstd-compressed bytes to the bundle body (as a
/// foreign compressing writer would have) and returns the reference
/// describing them. `declared_len` lets a test lie about the uncompressed
/// length; honest callers pass `payload.len()`.
fn plant_zstd_chunk(
bundle: &mut Bundle<MemStore>,
kind: ChunkKind,
payload: &[u8],
declared_len: u64,
) -> ChunkRef {
let compressed = zstd::bulk::compress(payload, 3).unwrap();
let offset = bundle.write_cursor;
bundle.store.write_at(offset, &compressed).unwrap();
bundle.write_cursor += compressed.len() as u64;
let hash = content_hash_for(kind, SchemaVersion::V0, payload);
ChunkRef {
id: ChunkId(hash),
kind,
schema_version: SchemaVersion::V0,
offset,
compressed_length: compressed.len() as u64,
uncompressed_length: declared_len,
compression: CompressionAlgorithm::Zstd { level: 3 },
hash,
}
}
/// Like [`plant_zstd_chunk`], but for a blob (bare `MUSCBLOB` addressing).
fn plant_zstd_blob(
bundle: &mut Bundle<MemStore>,
payload: &[u8],
declared_len: u64,
) -> BlobRef {
let r = plant_zstd_chunk(bundle, ChunkKind::Blob, payload, declared_len);
BlobRef {
blob_id: BlobId(r.hash),
media_type: "application/octet-stream".to_string(),
offset: r.offset,
compressed_length: r.compressed_length,
uncompressed_length: declared_len,
compression: r.compression,
hash: r.hash,
declared_max_uncompressed_length: None,
}
}
#[test]
fn zstd_compressed_chunk_round_trips_with_hash_verified() {
// §Compression round-trip: an externally-compressed chunk reads back
// byte-identical, and the content hash is verified over the
// *uncompressed* bytes (the preimage rule: compression is metadata).
let mut bundle = fresh_bundle();
let payload: Vec<u8> = b"layout-cache-bytes ".repeat(64); // compressible
let r = plant_zstd_chunk(
&mut bundle,
ChunkKind::LayoutCache,
&payload,
payload.len() as u64,
);
assert!(
r.compressed_length < r.uncompressed_length,
"fixture actually compressed"
);
assert_eq!(bundle.read_chunk(&r).unwrap(), payload);
// The hash check runs on the decompressed payload: a tampered declared
// hash is caught even though the stored (compressed) bytes are intact.
let mut tampered = r;
tampered.hash = ContentHash([0; 32]);
tampered.id = ChunkId(ContentHash([0; 32])); // keep id == hash
assert!(matches!(
bundle.read_chunk(&tampered),
Err(BundleError::ChunkHashMismatch { .. })
));
}
#[test]
fn compressed_operation_root_commits_and_reopens() {
// End to end: a compressed operation block can be published as a
// canonical root (commit-time validation decompresses + verifies it),
// survives a reopen, and streams back through read_operation_block.
let mut bundle = fresh_bundle();
let envelopes = vec![b"env-1".to_vec(), b"env-2".to_vec()];
let payload = block::encode_block(&envelopes);
let root = plant_zstd_chunk(
&mut bundle,
ChunkKind::OperationEnvelopeBlock,
&payload,
payload.len() as u64,
);
bundle
.commit(&[], |ctx| {
let mut m = ctx.previous_manifest.clone();
m.operation_roots.push(root);
m
})
.unwrap();
let image = bundle.into_store().into_bytes();
let reopened = Bundle::open(MemStore::from_bytes(image)).unwrap();
reopened.verify_canonical_chunks().unwrap();
let stored_root = reopened.manifest().operation_roots[0];
assert_eq!(
stored_root.compression,
CompressionAlgorithm::Zstd { level: 3 }
);
assert_eq!(
reopened.read_operation_block(&stored_root).unwrap(),
envelopes
);
}
#[test]
fn zstd_stream_ending_short_of_declared_length_is_rejected() {
// §Compression: "Decompression MUST verify the output length against
// the declared uncompressed_length" — a stream that ends short of the
// declaration is corruption, reported with both lengths.
let mut bundle = fresh_bundle();
let payload = b"short-stream".to_vec();
let r = plant_zstd_chunk(
&mut bundle,
ChunkKind::LayoutCache,
&payload,
payload.len() as u64 + 5, // declares more than the stream yields
);
assert!(matches!(
bundle.read_chunk(&r),
Err(BundleError::ChunkLengthMismatch {
expected: 17,
actual: 12,
})
));
}
#[test]
fn zstd_stream_exceeding_declared_length_is_rejected() {
// The dual failure: a stream that would decompress *past* the declared
// length must be refused without allocating beyond the declaration
// (the output buffer is sized by the declared length, so libzstd hits
// destination-full and errors).
let mut bundle = fresh_bundle();
let payload: Vec<u8> = b"overlong ".repeat(32);
let r = plant_zstd_chunk(
&mut bundle,
ChunkKind::LayoutCache,
&payload,
payload.len() as u64 - 1, // declares less than the stream yields
);
assert!(matches!(
bundle.read_chunk(&r),
Err(BundleError::Decompression(_))
));
}
#[test]
fn corrupt_or_truncated_zstd_stream_is_a_typed_error() {
let mut bundle = fresh_bundle();
let payload: Vec<u8> = b"to-be-corrupted ".repeat(16);
let r = plant_zstd_chunk(
&mut bundle,
ChunkKind::LayoutCache,
&payload,
payload.len() as u64,
);
// Corrupt the frame header magic in the stored bytes: malformed stream.
bundle.store.write_at(r.offset, &[0xFF]).unwrap();
assert!(matches!(
bundle.read_chunk(&r),
Err(BundleError::Decompression(_))
));
// Truncated stream: same compressed bytes, but the reference claims
// fewer of them than the frame needs.
let mut bundle = fresh_bundle();
let mut truncated = plant_zstd_chunk(
&mut bundle,
ChunkKind::LayoutCache,
&payload,
payload.len() as u64,
);
truncated.compressed_length /= 2;
assert!(matches!(
bundle.read_chunk(&truncated),
Err(BundleError::Decompression(_))
));
}
#[test]
fn zstd_compressed_blob_round_trips_and_fails_typed() {
// The blob path shares the decode: round-trip, short-stream, corrupt.
let mut bundle = fresh_bundle();
let payload: Vec<u8> = b"blob-audio-bytes ".repeat(64);
let b = plant_zstd_blob(&mut bundle, &payload, payload.len() as u64);
assert_eq!(bundle.read_blob(&b).unwrap(), payload);
// Declared length beyond the stream's yield → typed error.
let mut short = b.clone();
short.uncompressed_length = payload.len() as u64 + 3;
assert!(matches!(
bundle.read_blob(&short),
Err(BundleError::ChunkLengthMismatch { .. })
));
// The declared_max cap still applies *before* decompression begins.
let mut capped = b.clone();
capped.declared_max_uncompressed_length = Some(4);
assert!(matches!(
bundle.read_blob(&capped),
Err(BundleError::ResourceLimitExceeded { .. })
));
// Corrupt stored stream → typed error, no panic.
bundle.store.write_at(b.offset, &[0xFF]).unwrap();
assert!(matches!(
bundle.read_blob(&b),
Err(BundleError::Decompression(_))
));
}
#[test]
fn reserved_compression_is_still_unsupported() {
// §Compression: Reserved algorithms belong to future format majors.
let mut bundle = fresh_bundle();
op_block(&mut bundle, &[b"env"]);
let mut r = bundle.manifest().operation_roots[0];
r.compression = CompressionAlgorithm::Reserved(7);
assert!(matches!(
bundle.read_chunk(&r),
Err(BundleError::UnsupportedCompression)
));
commit_blob(&mut bundle, b"blob");
let mut b = bundle.manifest().blob_roots[0].clone();
b.compression = CompressionAlgorithm::Reserved(7);
assert!(matches!(
bundle.read_blob(&b),
Err(BundleError::UnsupportedCompression)
));
}
#[test]
fn compressed_manifest_chunk_ref_is_rejected() {
// §Manifest Encoding: the manifest chunk MUST be stored uncompressed in
// this format version. A manifest reference declaring compression is
// refused outright — even when the compressed bytes are a perfectly
// valid zstd stream of a perfectly valid manifest.
let mut bundle = fresh_bundle();
let manifest_payload = Manifest::empty(DocumentId([7; 16])).encode();
let r = plant_zstd_chunk(
&mut bundle,
ChunkKind::Manifest,
&manifest_payload,
manifest_payload.len() as u64,
);
assert!(matches!(
bundle.read_chunk(&r),
Err(BundleError::CompressedManifest)
));
}
/// A minimal image whose superblock points at `stored` as the manifest
/// payload, declaring `manifest_hash` for it.
fn craft_image_with_manifest_bytes(stored: &[u8], manifest_hash: ContentHash) -> Vec<u8> {
let mut image = vec![0u8; BODY_START as usize];
image.extend_from_slice(stored);
let sb = Superblock {
generation: 0,
manifest_offset: BODY_START,
manifest_length: stored.len() as u64,
manifest_hash,
manifest_schema_version: SchemaVersion::V0,
reduction_algorithm_version: ReductionAlgorithmVersion(0),
profile_id: ProfileId::Full,
commit_state: CommitState::Committed,
commit_timestamp: WallClockTime(0),
};
image[0..crate::header::HEADER_LEN as usize]
.copy_from_slice(&FixedHeader::new(FileUuid([1; 16])).encode());
image[SLOT_A_OFFSET as usize..SLOT_A_OFFSET as usize + SUPERBLOCK_LEN as usize]
.copy_from_slice(&sb.encode());
image
}
#[test]
fn open_rejects_a_bundle_whose_manifest_bytes_are_compressed() {
// §Manifest Encoding: "Implementations MUST reject as malformed any
// bundle whose manifest payload is not directly parseable as a
// canonical manifest chunk's uncompressed bytes." The superblock
// carries no compression field, so the stored bytes are treated as the
// payload — a compressed manifest fails with a typed error either way
// a hostile writer declares its hash.
let payload = Manifest::empty(DocumentId([1; 16])).encode();
let compressed = zstd::bulk::compress(&payload, 3).unwrap();
// (a) Hash declared over the true (uncompressed) manifest content:
// the stored bytes fail hash verification → no valid superblock.
let image = craft_image_with_manifest_bytes(&compressed, manifest_chunk_hash(&payload));
assert!(matches!(
Bundle::open(MemStore::from_bytes(image)),
Err(BundleError::NoValidSuperblock)
));
// (b) Colluding hash over the compressed bytes: the slot verifies, but
// the payload is not parseable as a manifest → rejected as
// malformed.
let image = craft_image_with_manifest_bytes(&compressed, manifest_chunk_hash(&compressed));
assert!(matches!(
Bundle::open(MemStore::from_bytes(image)),
Err(BundleError::Decode(_))
));
}
}

View File

@ -109,15 +109,18 @@ impl ChunkKind {
}
/// Per-chunk compression metadata (Chapter 8 §"Compression"). **Not** part of
/// content identity. v0 writes only [`CompressionAlgorithm::None`] (the
/// QUICKSTART defers compression: *"Don't implement compression in the bundle …
/// add zstd later as a non-breaking minor version"*); the other variants are
/// modeled for forward compatibility and reading.
/// content identity. v0 *writes* only [`CompressionAlgorithm::None`] (the
/// QUICKSTART defers compression on the write path: *"Don't implement
/// compression in the bundle … add zstd later as a non-breaking minor
/// version"*), but *reading* zstd-compressed chunks is a conformance MUST and
/// is supported. The manifest chunk is mandatorily uncompressed either way
/// (Chapter 8 §"Manifest Encoding").
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
pub enum CompressionAlgorithm {
/// No compression. Stored bytes equal the uncompressed payload.
None,
/// Zstandard at the given level. (Reading is deferred in v0.)
/// Zstandard at the given level. Readers MUST support any level zstd
/// defines; the level byte is writer metadata the decoder does not need.
Zstd { level: u8 },
/// Reserved for future format major versions.
Reserved(u8),

View File

@ -309,9 +309,13 @@ impl<'a> Reader<'a> {
Ok(i64::from_le_bytes(self.take_array()?))
}
/// A `u32`-length-prefixed byte string, copied out. The length prefix is
/// checked against the bytes remaining *before* any allocation.
pub fn get_var_bytes(&mut self) -> Result<Vec<u8>, DecodeError> {
/// A `u32`-length-prefixed byte string, *borrowed* from the input rather
/// than copied. The length prefix is checked against the bytes remaining
/// before the slice is taken. The zero-copy counterpart of
/// [`Reader::get_var_bytes`], for callers that need positions or slices
/// into the original buffer (e.g. the per-envelope offsets the operation
/// index records).
pub fn get_var_slice(&mut self) -> Result<&'a [u8], DecodeError> {
let len = self.get_u32()? as usize;
if len > self.remaining() {
return Err(DecodeError::LengthOverflow {
@ -319,7 +323,13 @@ impl<'a> Reader<'a> {
remaining: self.remaining(),
});
}
Ok(self.take(len)?.to_vec())
self.take(len)
}
/// A `u32`-length-prefixed byte string, copied out. The length prefix is
/// checked against the bytes remaining *before* any allocation.
pub fn get_var_bytes(&mut self) -> Result<Vec<u8>, DecodeError> {
Ok(self.get_var_slice()?.to_vec())
}
/// A `u32`-length-prefixed UTF-8 string.

View File

@ -83,10 +83,24 @@ pub enum BundleError {
/// an untrusted length in a (possibly sparse) file cannot drive an OOM.
ResourceLimitExceeded { length: u64, limit: u64 },
/// A compressed chunk was encountered. v0 writes only uncompressed chunks
/// and does not implement decompression (QUICKSTART: zstd is deferred).
/// A chunk declared a `Reserved` compression algorithm. This format version
/// defines only `None` and `Zstd`; further algorithms require a new format
/// major version (Chapter 8 §"Compression").
UnsupportedCompression,
/// A zstd-compressed chunk's stream failed to decompress: malformed,
/// truncated, or producing more bytes than the declared
/// `uncompressed_length` (Chapter 8 §"Compression": decompression MUST
/// verify the output length against the declared length). Surfaced as
/// typed corruption — never a panic or an unbounded allocation.
Decompression(std::io::Error),
/// A manifest chunk reference declared compression. The manifest chunk
/// MUST be stored uncompressed in this format version (Chapter 8
/// §"Manifest Encoding"): it is the bootstrap entry into the chunk graph,
/// decodable from header + superblock information alone.
CompressedManifest,
/// A chunk declared a schema major version this reader cannot parse
/// (Chapter 8 §"Schema Versioning").
UnsupportedSchemaVersion { version: SchemaVersion },
@ -154,9 +168,13 @@ impl core::fmt::Display for BundleError {
"declared length {length} exceeds the reader limit {limit}"
)
}
BundleError::UnsupportedCompression => {
f.write_str("compressed chunk encountered; v0 supports only uncompressed chunks")
}
BundleError::UnsupportedCompression => f.write_str(
"reserved compression algorithm; this format version defines None and Zstd",
),
BundleError::Decompression(e) => write!(f, "zstd decompression failed: {e}"),
BundleError::CompressedManifest => f.write_str(
"manifest chunk is compressed; the manifest MUST be stored uncompressed in this format version",
),
BundleError::UnsupportedSchemaVersion { version } => {
write!(
f,
@ -177,6 +195,7 @@ impl std::error::Error for BundleError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
BundleError::Io(e) => Some(e),
BundleError::Decompression(e) => Some(e),
BundleError::Decode(e) => Some(e),
_ => None,
}

View File

@ -37,10 +37,12 @@
//!
//! ## Scope (per QUICKSTART "Don't do these")
//!
//! v0 writes only uncompressed chunks (zstd is deferred), does not implement the
//! text-projection *content* (only carries its root), and does not *evaluate*
//! edit barriers (their operand types live in other crates). The manifest is
//! mandatory-uncompressed in this format version regardless. See `DECISIONS.md`
//! v0 writes only uncompressed chunks (compression on write is deferred), but
//! *reads* zstd-compressed chunks and blobs, as the spec's §Compression MUST
//! requires. It does not implement the text-projection *content* (only carries
//! its root), and does not *evaluate* edit barriers (their operand types live
//! in other crates). The manifest is mandatory-uncompressed in this format
//! version regardless, and a compressed manifest is rejected. See `DECISIONS.md`
//! for the prototype byte-layout choices that anticipate the deferred Binary
//! Format companion, and the batched Pass 11 candidates.
@ -53,13 +55,15 @@ mod error;
mod header;
mod ids;
mod manifest;
mod opindex;
mod store;
mod superblock;
pub mod fuzz;
pub use block::{
decode_block, encode_block, pack_operation_blocks, BLOCK_SOFT_LIMIT, MAX_BLOCK_DEFAULT,
decode_block, encode_block, envelope_offsets, pack_operation_blocks, BLOCK_SOFT_LIMIT,
MAX_BLOCK_DEFAULT,
};
pub use bundle::{
manifest_chunk_hash, Bundle, CommitContext, StagedChunk, BODY_START, MAX_BLOB_BYTES,
@ -87,6 +91,9 @@ pub use manifest::{
BlobRef, ExtensionDeclaration, Manifest, OperationBlockSummary, ProfileConstraints,
ProfileDeclaration, RetentionPolicy, SnapshotRef,
};
pub use opindex::{
IndexedBlock, OperationIdBytes, OperationIndex, OperationIndexBuildError, OperationIndexEntry,
};
pub use store::{BlockStore, CrashPoint, FaultStore, MemStore, Tear};
pub use superblock::{
select_active, CommitState, ProfileId, Selection, Slot, SlotParse, SlotReject, Superblock,

View File

@ -0,0 +1,504 @@
//! The operation index (Chapter 8 §"The Operation Index").
//!
//! An optional chunk of kind [`ChunkKind::OperationIndex`] mapping each
//! operation id to the [`ChunkRef`] of its enclosing operation-envelope block
//! plus a byte offset within the block, for O(log n) lookup of an operation
//! without scanning every block. It is **an acceleration structure, not
//! canonical**: *"If absent, readers rebuild it by scanning all blocks. If
//! present but corrupt or stale, readers MUST reject the index and rebuild
//! from blocks"* — and failed verification of a non-canonical chunk is *not*
//! bundle corruption (Chapter 8 §"Canonical and Non-Canonical Manifest
//! Roots"). [`crate::Bundle::usable_operation_index`] packages that
//! reject-and-rebuild discipline.
//!
//! ## Layering
//!
//! The bundle stays semantics-free: entries key on the **raw 16 canonical
//! bytes** of an operation id, which the bundle never interprets. That the
//! leading 16 bytes of a canonically encoded envelope *are* its operation id
//! is an `epiphany-ops` invariant, vouched for by ops' `peek_operation_id`;
//! index builders pair that helper with [`crate::envelope_offsets`] to produce
//! this module's `(id bytes, offset)` inputs.
//!
//! ## Provisional payload layout (see `DECISIONS.md`)
//!
//! The spec defers the index's byte format to the Binary Format companion; the
//! encoding here is a deterministic, golden-locked prototype under the crate's
//! codec conventions:
//!
//! ```text
//! u32 block_count
//! block_count × ChunkRef — strictly ascending canonical order
//! u32 entry_count
//! entry_count × { id: [u8;16], block: u32, offset: u32 }
//! — strictly ascending by id bytes
//! ```
//!
//! `block` is an ordinal into the block vector; `offset` is the byte offset of
//! the envelope's first content byte within the block's *decoded*
//! (uncompressed) payload, exactly as [`crate::envelope_offsets`] reports it.
//! The decoder **rejects** non-canonical bytes (unsorted or duplicated blocks
//! or ids, out-of-range ordinals, wrong-kind block references, trailing
//! bytes) rather than normalizing — the manifest decoder's discipline.
use crate::chunk::{ChunkKind, ChunkRef};
use crate::codec::{DecodeError, Reader, Writer};
/// The raw 16 canonical bytes of an operation id. Opaque to the bundle (the
/// semantics-free layering split): ops' `peek_operation_id` is what vouches
/// that an envelope's leading 16 bytes are its id.
pub type OperationIdBytes = [u8; 16];
/// One [`OperationIndex::build`] input: an operation-envelope block's
/// [`ChunkRef`] plus, for each envelope it contains, the envelope's id bytes
/// and its offset within the decoded block payload (the
/// [`crate::envelope_offsets`] coordinate).
pub type IndexedBlock = (ChunkRef, Vec<(OperationIdBytes, u32)>);
/// One index entry: an operation id (raw canonical bytes — the bundle never
/// interprets them), the ordinal of its enclosing block in the index's block
/// vector, and the byte offset of the envelope's first content byte within
/// that block's decoded payload.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub struct OperationIndexEntry {
/// The operation's 16 canonical id bytes (opaque to the bundle).
pub id: [u8; 16],
/// Ordinal into [`OperationIndex::blocks`] of the enclosing block.
pub block: u32,
/// Byte offset of the envelope's first content byte within the block's
/// decoded payload (`crate::envelope_offsets` coordinates).
pub offset: u32,
}
/// Why [`OperationIndex::build`] refused its inputs. Building is the writer's
/// act, so these are writer bugs — unlike a decode failure, which is just an
/// unusable (discard-and-rebuild) index.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum OperationIndexBuildError {
/// An operation id was supplied under two blocks (or twice in one block).
/// An `OperationId` names exactly one slot, so it lives in exactly one
/// enclosing block; two coordinates for one id is a builder bug.
DuplicateOperationId([u8; 16]),
/// The same block reference was supplied twice: the block set is a set.
DuplicateBlock,
/// A supplied block reference is not an operation-envelope block.
NotAnOperationBlock,
}
impl core::fmt::Display for OperationIndexBuildError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
OperationIndexBuildError::DuplicateOperationId(id) => {
write!(f, "operation id {id:02x?} indexed more than once")
}
OperationIndexBuildError::DuplicateBlock => {
f.write_str("the same block reference was supplied twice")
}
OperationIndexBuildError::NotAnOperationBlock => {
f.write_str("an index block reference is not an operation-envelope block")
}
}
}
}
impl std::error::Error for OperationIndexBuildError {}
/// The decoded operation index. Construct via [`OperationIndex::build`] (the
/// writer path) or [`OperationIndex::decode`] (the reader path); both uphold
/// the canonical-order invariants [`OperationIndex::locate`]'s binary search
/// relies on.
#[derive(Clone, PartialEq, Eq, Debug, Default)]
pub struct OperationIndex {
/// The indexed operation-envelope blocks, in canonical [`ChunkRef`] order.
blocks: Vec<ChunkRef>,
/// The entries, strictly ascending by id bytes.
entries: Vec<OperationIndexEntry>,
}
impl OperationIndex {
/// Builds an index from per-block entry lists: for each operation-envelope
/// block's [`ChunkRef`], the `(id bytes, offset)` of every envelope it
/// contains (as `peek_operation_id` over [`crate::envelope_offsets`]
/// yields them). Usable at commit time from the [`ChunkRef`]s a commit's
/// builder closure receives. Blocks are put into canonical order and the
/// entry ordinals remapped accordingly; duplicate ids, duplicate blocks,
/// and wrong-kind block references are rejected.
pub fn build(blocks: &[IndexedBlock]) -> Result<OperationIndex, OperationIndexBuildError> {
assert!(
blocks.len() <= u32::MAX as usize,
"block count {} overflows the u32 ordinal space",
blocks.len()
);
if blocks
.iter()
.any(|(r, _)| r.kind != ChunkKind::OperationEnvelopeBlock)
{
return Err(OperationIndexBuildError::NotAnOperationBlock);
}
// Canonical block order (ChunkRef's total Ord), remembering where each
// input block landed so entry ordinals can be remapped.
let mut order: Vec<usize> = (0..blocks.len()).collect();
order.sort_by(|&a, &b| blocks[a].0.cmp(&blocks[b].0));
if order.windows(2).any(|w| blocks[w[0]].0 == blocks[w[1]].0) {
return Err(OperationIndexBuildError::DuplicateBlock);
}
let mut ordinal_of = vec![0u32; blocks.len()];
for (ordinal, &input) in order.iter().enumerate() {
ordinal_of[input] = ordinal as u32;
}
let sorted_blocks: Vec<ChunkRef> = order.iter().map(|&i| blocks[i].0).collect();
let mut entries: Vec<OperationIndexEntry> = Vec::new();
for (i, (_, ids)) in blocks.iter().enumerate() {
entries.extend(ids.iter().map(|&(id, offset)| OperationIndexEntry {
id,
block: ordinal_of[i],
offset,
}));
}
entries.sort_by_key(|e| e.id);
if let Some(w) = entries.windows(2).find(|w| w[0].id == w[1].id) {
return Err(OperationIndexBuildError::DuplicateOperationId(w[0].id));
}
Ok(OperationIndex {
blocks: sorted_blocks,
entries,
})
}
/// Encodes the index to its (provisional, golden-locked) canonical chunk
/// payload. Deterministic: [`OperationIndex::build`]/[`OperationIndex::decode`]
/// established the canonical orders, so re-encoding is byte-stable.
pub fn encode(&self) -> Vec<u8> {
let mut w = Writer::new();
w.put_seq(&self.blocks, |w, b| b.encode(w));
w.put_seq(&self.entries, |w, e| {
w.put_bytes(&e.id);
w.put_u32(e.block);
w.put_u32(e.offset);
});
w.into_bytes()
}
/// Decodes an index payload, **rejecting** (never normalizing) any
/// non-canonical form: unsorted or duplicated blocks or entry ids, a
/// non-operation-block reference, an out-of-range block ordinal, or
/// trailing bytes. A rejection means the index is unusable and must be
/// rebuilt from blocks — it is *not* bundle corruption (the index is
/// non-canonical).
pub fn decode(bytes: &[u8]) -> Result<OperationIndex, DecodeError> {
let mut r = Reader::new(bytes);
let blocks = r.get_seq(ChunkRef::decode)?;
let entries = r.get_seq(|r| {
Ok(OperationIndexEntry {
id: r.take_array::<16>()?,
block: r.get_u32()?,
offset: r.get_u32()?,
})
})?;
r.finish()?;
if blocks
.iter()
.any(|b| b.kind != ChunkKind::OperationEnvelopeBlock)
{
return Err(DecodeError::Malformed(
"operation index references a non-operation-block chunk",
));
}
// Strictly ascending (so also duplicate-free) in both vectors.
if blocks.windows(2).any(|w| w[0] >= w[1]) {
return Err(DecodeError::Malformed(
"operation index blocks are not strictly canonically ordered",
));
}
if entries.windows(2).any(|w| w[0].id >= w[1].id) {
return Err(DecodeError::Malformed(
"operation index entries are not strictly ascending by id",
));
}
if entries.iter().any(|e| e.block as usize >= blocks.len()) {
return Err(DecodeError::Malformed(
"operation index entry names an out-of-range block ordinal",
));
}
Ok(OperationIndex { blocks, entries })
}
/// Locates an operation by its 16 canonical id bytes: the [`ChunkRef`] of
/// its enclosing block and the byte offset of the envelope's first content
/// byte within that block's decoded payload. Binary search — the O(log n)
/// lookup the spec names as the index's purpose.
pub fn locate(&self, id: &[u8; 16]) -> Option<(&ChunkRef, u32)> {
let i = self.entries.binary_search_by(|e| e.id.cmp(id)).ok()?;
let e = &self.entries[i];
Some((&self.blocks[e.block as usize], e.offset))
}
/// Whether this index covers exactly the given operation roots — the
/// staleness gate (Chapter 8 §"The Operation Index": a stale index MUST be
/// rejected and rebuilt). True iff the index's block set equals the root
/// set as **full [`ChunkRef`]s** (not just chunk ids): `locate` hands out
/// the index's *stored* references for reading, so a reference agreeing in
/// content hash but differing in any locator field (offset, lengths,
/// compression) is not the manifest's block and must count as stale rather
/// than silently steering reads elsewhere. `false` = stale: reject and
/// rebuild from blocks; it is *not* bundle corruption.
pub fn covers(&self, operation_roots: &[ChunkRef]) -> bool {
// The index's own blocks are strictly sorted; canonicalize the roots
// the same way (a decoded manifest's roots already are — this only
// shields against hand-assembled inputs).
let mut roots = operation_roots.to_vec();
roots.sort();
roots.dedup();
roots == self.blocks
}
/// The indexed blocks, in canonical order.
pub fn blocks(&self) -> &[ChunkRef] {
&self.blocks
}
/// The entries, strictly ascending by id bytes.
pub fn entries(&self) -> &[OperationIndexEntry] {
&self.entries
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chunk::CompressionAlgorithm;
use crate::ids::SchemaVersion;
use epiphany_determinism::{ChunkId, ContentHash};
fn block_ref(hash_byte: u8, offset: u64, len: u64) -> ChunkRef {
ChunkRef {
id: ChunkId(ContentHash([hash_byte; 32])),
kind: ChunkKind::OperationEnvelopeBlock,
schema_version: SchemaVersion::V0,
offset,
compressed_length: len,
uncompressed_length: len,
compression: CompressionAlgorithm::None,
hash: ContentHash([hash_byte; 32]),
}
}
fn sample() -> OperationIndex {
OperationIndex::build(&[
(block_ref(0x22, 1000, 64), vec![([3; 16], 8), ([1; 16], 40)]),
(block_ref(0x11, 576, 32), vec![([2; 16], 8)]),
])
.expect("valid build inputs")
}
#[test]
fn payload_encoding_is_golden() {
// PROVISIONAL golden lock (DECISIONS.md "Operation index"): the byte
// form awaits Binary Format companion ratification, but until then it
// is deterministic and locked — a layout change must break here
// deliberately. The expected bytes are spelled out literally.
let index =
OperationIndex::build(&[(block_ref(0x11, 576, 64), vec![([2; 16], 8), ([1; 16], 44)])])
.expect("valid build inputs");
let mut expected: Vec<u8> = Vec::new();
// u32 block count = 1.
expected.extend_from_slice(&[1, 0, 0, 0]);
// ChunkRef: id (32 raw bytes) …
expected.extend_from_slice(&[0x11; 32]);
// … kind discriminant (OperationEnvelopeBlock = 0) …
expected.push(0);
// … schema version 0.1 (u16 major LE, u16 minor LE) …
expected.extend_from_slice(&[0, 0, 1, 0]);
// … offset 576 (u64 LE), compressed and uncompressed length 64 …
expected.extend_from_slice(&[0x40, 0x02, 0, 0, 0, 0, 0, 0]);
expected.extend_from_slice(&[64, 0, 0, 0, 0, 0, 0, 0]);
expected.extend_from_slice(&[64, 0, 0, 0, 0, 0, 0, 0]);
// … compression None (discriminant 0, parameter 0) …
expected.extend_from_slice(&[0, 0]);
// … restated hash (32 raw bytes).
expected.extend_from_slice(&[0x11; 32]);
// u32 entry count = 2; entries ascend by id bytes.
expected.extend_from_slice(&[2, 0, 0, 0]);
// Entry { id [1;16], block ordinal 0 (u32 LE), offset 44 (u32 LE) }.
expected.extend_from_slice(&[1; 16]);
expected.extend_from_slice(&[0, 0, 0, 0]);
expected.extend_from_slice(&[44, 0, 0, 0]);
// Entry { id [2;16], block ordinal 0, offset 8 }.
expected.extend_from_slice(&[2; 16]);
expected.extend_from_slice(&[0, 0, 0, 0]);
expected.extend_from_slice(&[8, 0, 0, 0]);
assert_eq!(index.encode(), expected);
}
#[test]
fn empty_index_round_trips() {
let empty = OperationIndex::build(&[]).expect("empty build");
let bytes = empty.encode();
// Two zero u32 counts.
assert_eq!(bytes, vec![0, 0, 0, 0, 0, 0, 0, 0]);
let decoded = OperationIndex::decode(&bytes).expect("decodes");
assert_eq!(decoded, empty);
assert!(decoded.covers(&[]));
assert_eq!(decoded.locate(&[0; 16]), None);
}
#[test]
fn round_trips_and_reencodes_byte_stably() {
let index = sample();
let bytes = index.encode();
let decoded = OperationIndex::decode(&bytes).expect("decodes");
assert_eq!(decoded, index);
assert_eq!(decoded.encode(), bytes, "re-encode must be byte-identical");
}
#[test]
fn build_sorts_blocks_canonically_and_remaps_ordinals() {
// Inputs arrive with the higher-hash block first; build must order by
// the canonical ChunkRef key and keep each entry pointing at its own
// block through the remapped ordinal.
let index = sample();
assert_eq!(index.blocks()[0].hash.as_bytes()[0], 0x11);
assert_eq!(index.blocks()[1].hash.as_bytes()[0], 0x22);
let (b, off) = index.locate(&[2; 16]).expect("hit");
assert_eq!((b.hash.as_bytes()[0], off), (0x11, 8));
let (b, off) = index.locate(&[1; 16]).expect("hit");
assert_eq!((b.hash.as_bytes()[0], off), (0x22, 40));
let (b, off) = index.locate(&[3; 16]).expect("hit");
assert_eq!((b.hash.as_bytes()[0], off), (0x22, 8));
}
#[test]
fn locate_misses_an_unknown_id() {
assert_eq!(sample().locate(&[9; 16]), None);
}
#[test]
fn build_rejects_duplicate_ids_blocks_and_wrong_kinds() {
// The same id under two blocks: one operation, one slot.
assert_eq!(
OperationIndex::build(&[
(block_ref(0x11, 576, 32), vec![([1; 16], 8)]),
(block_ref(0x22, 1000, 32), vec![([1; 16], 8)]),
]),
Err(OperationIndexBuildError::DuplicateOperationId([1; 16]))
);
// The same block twice: the block set is a set.
assert_eq!(
OperationIndex::build(&[
(block_ref(0x11, 576, 32), vec![([1; 16], 8)]),
(block_ref(0x11, 576, 32), vec![([2; 16], 8)]),
]),
Err(OperationIndexBuildError::DuplicateBlock)
);
// A non-operation-block reference.
let mut wrong = block_ref(0x11, 576, 32);
wrong.kind = ChunkKind::Snapshot;
assert_eq!(
OperationIndex::build(&[(wrong, vec![])]),
Err(OperationIndexBuildError::NotAnOperationBlock)
);
}
#[test]
fn decode_rejects_unsorted_blocks() {
// Hand-assemble an index whose blocks are out of canonical order;
// encode trusts the construction, so decode must be the gate.
let bad = OperationIndex {
blocks: vec![block_ref(0x22, 1000, 32), block_ref(0x11, 576, 32)],
entries: vec![],
};
assert_eq!(
OperationIndex::decode(&bad.encode()),
Err(DecodeError::Malformed(
"operation index blocks are not strictly canonically ordered"
))
);
// Duplicates violate *strict* ascent too.
let dup = OperationIndex {
blocks: vec![block_ref(0x11, 576, 32), block_ref(0x11, 576, 32)],
entries: vec![],
};
assert!(OperationIndex::decode(&dup.encode()).is_err());
}
#[test]
fn decode_rejects_unsorted_or_duplicate_entries() {
let entry = |id: u8, block: u32| OperationIndexEntry {
id: [id; 16],
block,
offset: 8,
};
let unsorted = OperationIndex {
blocks: vec![block_ref(0x11, 576, 32)],
entries: vec![entry(2, 0), entry(1, 0)],
};
assert!(OperationIndex::decode(&unsorted.encode()).is_err());
let duplicated = OperationIndex {
blocks: vec![block_ref(0x11, 576, 32)],
entries: vec![entry(1, 0), entry(1, 0)],
};
assert!(OperationIndex::decode(&duplicated.encode()).is_err());
}
#[test]
fn decode_rejects_out_of_range_ordinals_wrong_kinds_and_trailing_bytes() {
let out_of_range = OperationIndex {
blocks: vec![block_ref(0x11, 576, 32)],
entries: vec![OperationIndexEntry {
id: [1; 16],
block: 1, // only ordinal 0 exists
offset: 8,
}],
};
assert_eq!(
OperationIndex::decode(&out_of_range.encode()),
Err(DecodeError::Malformed(
"operation index entry names an out-of-range block ordinal"
))
);
let mut wrong = block_ref(0x11, 576, 32);
wrong.kind = ChunkKind::Snapshot;
let wrong_kind = OperationIndex {
blocks: vec![wrong],
entries: vec![],
};
assert_eq!(
OperationIndex::decode(&wrong_kind.encode()),
Err(DecodeError::Malformed(
"operation index references a non-operation-block chunk"
))
);
let mut trailing = sample().encode();
trailing.push(0);
assert_eq!(
OperationIndex::decode(&trailing),
Err(DecodeError::TrailingBytes { remaining: 1 })
);
}
#[test]
fn covers_is_exact_set_equality_over_full_refs() {
let index = sample();
let a = block_ref(0x11, 576, 32);
let b = block_ref(0x22, 1000, 64);
// Equal set (any input order; duplicates collapse).
assert!(index.covers(&[b, a]));
assert!(index.covers(&[a, b, a]));
// Subset / superset are stale.
assert!(!index.covers(&[a]));
assert!(!index.covers(&[a, b, block_ref(0x33, 2000, 8)]));
// Same content hash at a different locator is stale: the index would
// hand out a reference that is not the manifest's block.
let mut moved = b;
moved.offset = 4096;
assert!(!index.covers(&[a, moved]));
}
}

View File

@ -337,3 +337,74 @@ Phase 2). The rule itself is value-independent and final.
question). H spells pitches region-independently (pitch identity does not depend
on the time model) but performs no region-specific aleatoric spelling; defer if
the algorithm does not generalise cleanly.
## Audit follow-up (2026-07-01): decomposition precedence + typed inversion tolerance
### Authored decomposition attachments outrank the pre-pass (`resolve_decomposition`)
Audit finding: `infer_decompositions` never consulted
`Score.decomposition_attachments`, so an authored decomposition was silently
shadowed by the derived one — violating Chapter 3 §"Sounding Duration and
Notational Decomposition": the pre-pass "produces inferred decompositions for
events that **lack a higher-precedence attachment**", with the "same sources,
same precedence machinery, same pre-pass discipline" as spelling.
Fixed by `resolve_decomposition`, the decomposition analogue of
`resolve_spelling`: for each event the pre-pass inferred a decomposition for,
an authored `DecompositionAttachment` targeting that event whose source
**outranks `Inferred`** replaces the derived one in
`DerivedAnnotations.decompositions` (the effective attachment keeps its
authored source, so provenance is visible and enters the derivation
fingerprint). Precedence is the spec's default source order — `UserChosen >
Imported > Propagated > Inferred` — as a fixed rank, because the graph model
carries **no** `DecompositionPrecedence` configuration and the attachment has
no `priority`/`layer` axes (unlike `SpellingAttachment`); among competing
authored attachments the lowest rank wins, and a full rank tie keeps the first
in the score's canonical (codec-fixed) `decomposition_attachments` order, so
resolution is deterministic across replicas.
**Taxonomy decision:** authored-override events are counted **distinctly** in a
new `TaxonomyReport::decompositions_authored` bucket (mirroring
`spellings_authored`); `decompositions_inferred` now counts only events whose
*effective* decomposition is the pre-pass's own. The effective map size equals
`decompositions_inferred + decompositions_authored` (the H harness's accounting
check was updated accordingly). The new bucket is serialized in
`DerivedAnnotations::canonical_fingerprint` with the other counts.
**Scope, mirroring spelling:** resolution layers overrides above the pre-pass's
*inferred* output only. An authored attachment on an event the pre-pass emits
nothing for (ungriddable / non-metric / inapplicable kind) does not surface as
a derived annotation — exactly as a spelling attachment on a
spelling-unavailable pitch does not (the attachment still lives in canonical
`Score` state either way). Two genuine ambiguities are batched, not improvised:
- **P12-H6 — Decomposition precedence configurability.** Chapter 3 says "same
precedence machinery" as spelling, and Chapter 2 makes the spelling order
*configurable* per score (`SpellingPrecedence`, plus `priority`/timestamp
tie-breaks); but the graph model has no `DecompositionPrecedence` field and
`DecompositionAttachment` has no `priority`. Whether decomposition precedence
should be configurable (a new canonical `Score` field — a codec/ratification
change), share `SpellingPrecedence`, or stay the fixed spec default needs a
spec disposition. Until then the fixed default order is implemented.
- **P12-H7 — Authored decompositions for events the pre-pass cannot infer
for.** An authored attachment is precisely how a user would notate an event
the algorithm reports ungriddable, yet the derived-annotation surface only
resolves overrides where an inferred output exists (the spelling mirror).
Whether authored attachments should surface in `DerivedAnnotations` for
inference-ineligible events (and how the taxonomy should count them) is a
spec question for both pre-passes.
### Typed inversion tolerance (`tempo::inversion_tolerance`)
`INVERSION_TOLERANCE_WHOLE_NOTES` was a bare public `f64` documented as
belonging to tolerance class `TempoIntegration` but never constructed as a
`Tolerance` (Appendix D §"Tolerance Classes": no ad-hoc epsilons). It is now
the private raw magnitude behind the public `inversion_tolerance()` — a
`Tolerance { class: TempoIntegration, absolute: 1e-6, relative: None,
governance: Validation }` (the same construction pattern as the existing
`speed_degeneracy_tolerance`). Behavior is numerically identical: the inverse
conversion passes `inversion_tolerance().absolute.get()` (exactly `1e-6`) to
the continued-fraction approximation. Note the class's *non-normative* unit
label is "wallclock seconds" while this residual is measured in whole notes;
the class identity (`TempoIntegration`: conversion residual in either
direction) is what is normative.

View File

@ -74,18 +74,18 @@ pub use time::{
};
pub use pitch::{
derive_system_pitch_id, spell, AccidentalId, AccidentalRegistryId, AcousticPitch,
AcousticRealization, CmnNominal, DecompositionAlgorithmId, ForeignFormatId, IdentifiedPitch,
NominalRegistryId, Pitch, PitchSpaceId, PitchSpacePosition, PitchSpelling, PositionRegistryId,
ReferencePitch, ScalePosition, SpellingAlgorithmId, SpellingAttachment, SpellingContext,
SpellingDirective, SpellingNominal, SpellingPrecedence, SpellingRenderHints, SpellingRule,
SpellingRuleSetId, SpellingScope, SpellingSource, SpellingSourceKind, StaffGroupKindRegistryId,
TieClassRegistryId, TuningReference, TuningSystemId, VoiceSelector,
canonical_pitch_bytes, derive_system_pitch_id, spell, AccidentalId, AccidentalRegistryId,
AcousticPitch, AcousticRealization, CmnNominal, DecompositionAlgorithmId, ForeignFormatId,
IdentifiedPitch, NominalRegistryId, Pitch, PitchSpaceId, PitchSpacePosition, PitchSpelling,
PositionRegistryId, ReferencePitch, ScalePosition, SpellingAlgorithmId, SpellingAttachment,
SpellingContext, SpellingDirective, SpellingNominal, SpellingPrecedence, SpellingRenderHints,
SpellingRule, SpellingRuleSetId, SpellingScope, SpellingSource, SpellingSourceKind,
StaffGroupKindRegistryId, TieClassRegistryId, TuningReference, TuningSystemId, VoiceSelector,
};
pub use prepass::{
derive_annotations, resolve_spelling, simplest_spelling, DerivedAnnotations, PrePassProfile,
ResolvedSpelling, SpellingProvenance, TaxonomyReport,
derive_annotations, resolve_decomposition, resolve_spelling, simplest_spelling,
DerivedAnnotations, PrePassProfile, ResolvedSpelling, SpellingProvenance, TaxonomyReport,
};
pub use event::{
@ -112,8 +112,8 @@ pub use graph::{
};
pub use tempo::{
Tempo, TempoError, TempoMap, TempoSegment, TempoShape, INVERSION_MAX_DENOMINATOR,
INVERSION_MAX_ITERATIONS, INVERSION_TOLERANCE_WHOLE_NOTES,
inversion_tolerance, Tempo, TempoError, TempoMap, TempoSegment, TempoShape,
INVERSION_MAX_DENOMINATOR, INVERSION_MAX_ITERATIONS,
};
pub use codec::{CanonicalValue, ScoreDecodeError};

View File

@ -446,7 +446,13 @@ impl Pitch {
/// system pitch identifier. Strings are length-prefixed and already NFC (the
/// catalog ids normalize on construction); the layout is fixed-shape so equal
/// pitches encode to equal bytes (Appendix D §"Canonical serialization").
fn canonical_pitch_bytes(p: &Pitch) -> Vec<u8> {
///
/// Public because these bytes are the normative "canonical inputs" of the
/// `MUSCSPCH` derivation (`req:graph:system-derived-pitch-id`): the reduction's
/// system-derived counter-collision check (Chapter 5 §"System-Derived Counter
/// Collisions") compares exactly these input bytes to distinguish two pitches
/// contending for one derived counter.
pub fn canonical_pitch_bytes(p: &Pitch) -> Vec<u8> {
// Length-prefixed UTF-8, normalized to NFC at the derivation boundary so the
// canonical input is NFC regardless of how the string was obtained (Appendix
// D §"Text and Unicode"). Catalog ids are already NFC at construction, so this

View File

@ -17,7 +17,12 @@
//! [`SpellingSourceKind::Inferred`] in the score's [`SpellingPrecedence`])
//! takes precedence over the algorithm's default. The default is *derived*;
//! the override is *authored*. H formalizes the precedence rule, not the model
//! (see [`resolve_spelling`]).
//! (see [`resolve_spelling`]). The decomposition side is governed the same way
//! (Chapter 3: "same sources, same precedence machinery, same pre-pass
//! discipline"): an authored [`DecompositionAttachment`] in
//! `Score::decomposition_attachments` whose source outranks
//! [`DecompositionSource::Inferred`] outranks the derived decomposition (see
//! [`resolve_decomposition`]).
//! * **Algorithm version is part of the derivation key.** The
//! [`SpellingAlgorithmId`] / [`DecompositionAlgorithmId`] in [`PrePassProfile`]
//! key the derivation; a profile-declared change deterministically
@ -113,8 +118,15 @@ pub struct TaxonomyReport {
pub spelling_unavailable: usize,
// --- Decomposition outcomes (over events). ---
/// Events that received a decomposition.
/// Eligible events whose effective decomposition is the pre-pass's inferred
/// one.
pub decompositions_inferred: usize,
/// Eligible events whose effective decomposition came from an authored
/// [`DecompositionAttachment`] whose source outranks
/// [`DecompositionSource::Inferred`] (mirroring `spellings_authored`).
/// Counted distinctly from `decompositions_inferred`: the derived map holds
/// the authored components for these events, not the pre-pass's.
pub decompositions_authored: usize,
/// Metric-region events whose duration is wall-clock or indeterminate (no
/// determinate musical duration to decompose).
pub decomposition_skipped_nonmusical: usize,
@ -195,6 +207,7 @@ impl DerivedAnnotations {
t.spellings_authored,
t.spelling_unavailable,
t.decompositions_inferred,
t.decompositions_authored,
t.decomposition_skipped_nonmusical,
t.decomposition_deferred_nonmetric,
t.decomposition_inapplicable,
@ -257,11 +270,18 @@ pub fn derive_annotations(score: &Score, profile: &PrePassProfile) -> DerivedAnn
}
// --- Decomposition pre-pass. ---
let decompositions = if decomposition_supported {
infer_decompositions(score, &layout, &mut taxonomy)
} else {
BTreeMap::new()
};
let mut decompositions = BTreeMap::new();
if decomposition_supported {
let inferred = infer_decompositions(score, &layout, &mut taxonomy);
for (eid, inferred_attachment) in inferred {
let resolved = resolve_decomposition(score, eid, inferred_attachment);
match resolved.source {
DecompositionSource::Inferred => taxonomy.decompositions_inferred += 1,
_ => taxonomy.decompositions_authored += 1,
}
decompositions.insert(eid, resolved);
}
}
DerivedAnnotations {
spellings,
@ -1042,8 +1062,11 @@ fn resolve_measure_units(score: &Score, region: &crate::graph::Region) -> i64 {
GRID_DEN
}
/// Builds the decomposition attachments for every eligible event, counting
/// ineligible / deferred / skipped cases into the taxonomy.
/// Runs the decomposition pre-pass over every eligible event, returning the
/// inferred (pre-precedence) attachment per event. Counts ineligible / deferred
/// / skipped cases into the taxonomy; the inferred/authored split is counted by
/// [`derive_annotations`] after [`resolve_decomposition`], mirroring
/// [`infer_spellings`].
fn infer_decompositions(
score: &Score,
layout: &ScoreLayout,
@ -1133,11 +1156,73 @@ fn infer_decompositions(
source: DecompositionSource::Inferred,
},
);
taxonomy.decompositions_inferred += 1;
}
out
}
/// The precedence rank of a decomposition source: the spec's default order
/// `UserChosen > Imported > Propagated > Inferred` (Chapter 3 §"Sounding
/// Duration and Notational Decomposition" mirrors Chapter 2 §"Configurable
/// Precedence": "same sources, same precedence machinery"). Lower rank wins.
/// The score carries no decomposition-specific precedence configuration (there
/// is no `DecompositionPrecedence` analogue of [`SpellingPrecedence`] in the
/// graph model), so the spec's default order is the fixed rank here; a
/// configurable decomposition precedence is a Pass-12 candidate.
fn decomposition_source_rank(source: &DecompositionSource) -> usize {
match source {
DecompositionSource::UserChosen => 0,
DecompositionSource::Imported { .. } => 1,
DecompositionSource::Propagated { .. } => 2,
DecompositionSource::Inferred => 3,
}
}
/// Resolves the *effective* decomposition for an event: an authored
/// [`DecompositionAttachment`] in `Score::decomposition_attachments` whose
/// source outranks [`DecompositionSource::Inferred`] wins; otherwise the
/// algorithm's inferred decomposition stands. This is the decomposition
/// analogue of [`resolve_spelling`] — Chapter 3: the pre-pass produces inferred
/// decompositions only "for events that lack a higher-precedence attachment",
/// with the "same precedence machinery" as spelling, minus the axes the
/// attachment does not carry (no analysis layers, no `priority` field). Among
/// competing authored attachments the lowest [`decomposition_source_rank`]
/// wins; a remaining tie keeps the first candidate in the score's
/// `decomposition_attachments` order, which is canonical (codec-fixed), so the
/// resolution is deterministic across replicas.
pub fn resolve_decomposition(
score: &Score,
event: EventId,
inferred: DecompositionAttachment,
) -> DecompositionAttachment {
let inferred_rank = decomposition_source_rank(&DecompositionSource::Inferred);
let mut best: Option<(usize, &DecompositionAttachment)> = None;
for att in &score.decomposition_attachments {
if att.target != event {
continue;
}
let rank = decomposition_source_rank(&att.source);
if rank >= inferred_rank {
continue; // does not outrank the inferred default
}
best = match best {
None => Some((rank, att)),
// Lower rank wins; a full tie keeps `cur` (the earlier attachment
// in canonical order).
Some(cur) => {
if rank < cur.0 {
Some((rank, att))
} else {
Some(cur)
}
}
};
}
match best {
Some((_, att)) => att.clone(),
None => inferred,
}
}
#[cfg(test)]
mod tests;

View File

@ -1019,6 +1019,227 @@ fn decomposition_components_sum_to_event_duration() {
}
}
// ---------------------------------------------------------------------------
// Decomposition precedence: authored attachments outrank the derived default
// ---------------------------------------------------------------------------
/// A two-event metric score — two half notes on beats 1 and 3 — plus the two
/// event ids. Each half note sits on a boundary of its own strength, so the
/// pre-pass infers a single (undotted) half for both.
fn two_half_note_score() -> (Score, crate::ids::EventId, crate::ids::EventId) {
let mut ids = Vec::new();
let score = metric_score(|idc, voice| {
let mut events = Vec::new();
for i in 0..2i64 {
let eid = idc.mint();
let pid = idc.mint::<PitchId>();
ids.push(eid);
events.push(pitched(
eid,
voice,
r(i, 2),
r(1, 2),
vec![IdentifiedPitch {
id: pid,
pitch: integer_pitch(48 + i as i32),
}],
));
}
(events, vec![])
});
(score, ids[0], ids[1])
}
/// An authored two-tied-quarters decomposition for `target` — a legitimate
/// alternative to the inferred single half note (components sum to `1/2`).
fn tied_quarters(
target: crate::ids::EventId,
source: DecompositionSource,
) -> DecompositionAttachment {
let quarter = |tied_to_next| NotatedComponent {
base_value: NoteValue::Quarter,
dots: 0,
tuplet: None,
tied_to_next,
};
DecompositionAttachment {
target,
components: vec![quarter(true), quarter(false)],
source,
}
}
/// Another authored alternative summing to `1/2`: dotted quarter tied to an
/// eighth. Distinct from [`tied_quarters`] so rank ties are observable.
fn dotted_quarter_eighth(
target: crate::ids::EventId,
source: DecompositionSource,
) -> DecompositionAttachment {
DecompositionAttachment {
target,
components: vec![
NotatedComponent {
base_value: NoteValue::Quarter,
dots: 1,
tuplet: None,
tied_to_next: true,
},
NotatedComponent {
base_value: NoteValue::Eighth,
dots: 0,
tuplet: None,
tied_to_next: false,
},
],
source,
}
}
#[test]
fn authored_decomposition_outranks_the_inferred_default() {
// Chapter 3: the pre-pass produces inferred decompositions only "for events
// that lack a higher-precedence attachment" — an authored UserChosen
// attachment must not be shadowed by the derived one.
let (mut score, e1, e2) = two_half_note_score();
let authored = tied_quarters(e1, DecompositionSource::UserChosen);
score.decomposition_attachments.push(authored.clone());
let ann = derive_annotations(&score, &PrePassProfile::default());
// The authored attachment is the effective decomposition for its event; no
// derived (single-half-note) decomposition shadows it.
assert_eq!(ann.decompositions[&e1], authored, "authored override wins");
// The un-overridden event keeps the algorithm's inferred half note.
let other = &ann.decompositions[&e2];
assert_eq!(other.source, DecompositionSource::Inferred);
assert_eq!(other.components.len(), 1);
assert_eq!(other.components[0].base_value, NoteValue::Half);
// Taxonomy counts the two outcomes distinctly, and together they cover the
// effective map (the accounting the H harness checks).
assert_eq!(ann.taxonomy.decompositions_authored, 1);
assert_eq!(ann.taxonomy.decompositions_inferred, 1);
assert_eq!(
ann.decompositions.len(),
ann.taxonomy.decompositions_inferred + ann.taxonomy.decompositions_authored
);
}
#[test]
fn inferred_source_attachment_does_not_outrank_the_prepass() {
// An attachment whose source is `Inferred` does not outrank the pre-pass's
// own output (the rank gate, mirroring `resolve_spelling`): the derived
// half note stands.
let (mut score, e1, _e2) = two_half_note_score();
score
.decomposition_attachments
.push(tied_quarters(e1, DecompositionSource::Inferred));
let ann = derive_annotations(&score, &PrePassProfile::default());
let dec = &ann.decompositions[&e1];
assert_eq!(dec.components.len(), 1, "the pre-pass's half note stands");
assert_eq!(dec.components[0].base_value, NoteValue::Half);
assert_eq!(dec.source, DecompositionSource::Inferred);
assert_eq!(ann.taxonomy.decompositions_authored, 0);
assert_eq!(ann.taxonomy.decompositions_inferred, 2);
}
#[test]
fn decomposition_precedence_ranks_sources_then_canonical_order() {
// UserChosen outranks Imported regardless of attachment order (the spec's
// default precedence over the decomposition sources)...
let (mut score, e1, _) = two_half_note_score();
let imported = dotted_quarter_eighth(
e1,
DecompositionSource::Imported {
format: crate::pitch::ForeignFormatId::new("musicxml"),
},
);
let user = tied_quarters(e1, DecompositionSource::UserChosen);
score.decomposition_attachments.push(imported); // listed first — must lose
score.decomposition_attachments.push(user.clone());
let ann = derive_annotations(&score, &PrePassProfile::default());
assert_eq!(
ann.decompositions[&e1], user,
"UserChosen outranks Imported regardless of attachment order"
);
// ...and a full rank tie keeps the first attachment in the score's
// canonical `decomposition_attachments` order (deterministic across
// replicas; the attachment carries no `priority` axis).
let (mut score2, f1, _) = two_half_note_score();
let first = tied_quarters(f1, DecompositionSource::UserChosen);
let second = dotted_quarter_eighth(f1, DecompositionSource::UserChosen);
score2.decomposition_attachments.push(first.clone());
score2.decomposition_attachments.push(second);
let ann2 = derive_annotations(&score2, &PrePassProfile::default());
assert_eq!(
ann2.decompositions[&f1], first,
"rank ties keep the earlier attachment in canonical order"
);
}
#[test]
fn derivation_with_authored_decomposition_is_deterministic() {
// The authored override is reflected deterministically: the same overridden
// score derives byte-identically twice, and the fingerprint distinguishes
// the overridden derivation from the un-overridden one.
let build = || {
let (mut score, e1, _) = two_half_note_score();
score
.decomposition_attachments
.push(tied_quarters(e1, DecompositionSource::UserChosen));
score
};
let a = derive_annotations(&build(), &PrePassProfile::default());
let b = derive_annotations(&build(), &PrePassProfile::default());
assert_eq!(a, b);
assert_eq!(a.canonical_fingerprint(), b.canonical_fingerprint());
let (plain, _, _) = two_half_note_score();
let c = derive_annotations(&plain, &PrePassProfile::default());
assert_ne!(
a.canonical_fingerprint(),
c.canonical_fingerprint(),
"the override is visible in the derivation fingerprint"
);
}
#[test]
fn authored_attachment_on_an_ungriddable_event_does_not_surface() {
// The resolution step mirrors the spelling side: it layers authored
// overrides above the pre-pass's *inferred* output. An event the pre-pass
// cannot grid emits nothing to override, so an authored attachment for it
// stays in canonical score state without surfacing as a derived annotation,
// and the event stays honestly counted as ungriddable. (Whether authored
// decompositions should surface for events the algorithm cannot infer for
// is a Pass-12 question — see DECISIONS.md.)
let mut ids = Vec::new();
let mut score = metric_score(|idc, voice| {
let eid = idc.mint();
let pid = idc.mint::<PitchId>();
ids.push(eid);
let ev = pitched(
eid,
voice,
r(0, 1),
r(1, 128), // finer than a sixty-fourth: ungriddable
vec![IdentifiedPitch {
id: pid,
pitch: integer_pitch(48),
}],
);
(vec![ev], vec![])
});
score
.decomposition_attachments
.push(tied_quarters(ids[0], DecompositionSource::UserChosen));
let ann = derive_annotations(&score, &PrePassProfile::default());
assert!(ann.decompositions.is_empty());
assert_eq!(ann.taxonomy.decomposition_ungriddable, 1);
assert_eq!(ann.taxonomy.decompositions_authored, 0);
assert_eq!(ann.taxonomy.decompositions_inferred, 0);
}
#[test]
fn unknown_algorithm_ids_are_not_honored() {
// A profile requesting an algorithm the pre-pass does not implement must not

View File

@ -24,9 +24,10 @@
//! **Determinism and tolerance.** The inverse [`TempoMap::wallclock_to_musical`]
//! uses a deterministic continued-fraction rational approximation with
//! documented bounds ([`INVERSION_MAX_ITERATIONS`], [`INVERSION_MAX_DENOMINATOR`],
//! [`INVERSION_TOLERANCE_WHOLE_NOTES`]) — the documented tolerance and iteration
//! [`inversion_tolerance`]) — the documented tolerance and iteration
//! bounds Chapter 3 §"Conversion" requires of any numerical inversion. The
//! residual is governed by [`epiphany_determinism::ToleranceClass::TempoIntegration`].
//! residual is a typed [`Tolerance`] of class
//! [`epiphany_determinism::ToleranceClass::TempoIntegration`].
//!
//! Conversion here is **advisory** (it uses `f64`), not canonical state:
//! musical time is the exact rational and wall-clock is exact nanoseconds
@ -152,11 +153,28 @@ pub const INVERSION_MAX_ITERATIONS: u32 = 64;
/// Maximum denominator the inverse will introduce, in whole-note units. Keeps
/// recovered rhythms simple (`1/3`, `1/12`, …) instead of spurious large ratios.
pub const INVERSION_MAX_DENOMINATOR: u64 = 1_000_000;
/// Absolute residual tolerance of the inverse, in whole notes
/// ([`epiphany_determinism::ToleranceClass::TempoIntegration`]). Comfortably
/// Absolute residual magnitude of the inverse, in whole notes. Comfortably
/// larger than the half-nanosecond rounding of the forward direction, so an
/// ordinary rhythm round-trips, yet far smaller than any musical distinction.
pub const INVERSION_TOLERANCE_WHOLE_NOTES: f64 = 1e-6;
/// The typed tolerance built on it is [`inversion_tolerance`]; this raw value
/// never leaves the module.
const INVERSION_TOLERANCE_WHOLE_NOTES: f64 = 1e-6;
/// The typed absolute residual tolerance of the inverse conversion
/// ([`TempoMap::wallclock_to_musical`]): a [`Tolerance`] of class
/// [`ToleranceClass::TempoIntegration`] (Appendix D §"Tolerance Classes" — no
/// ad-hoc epsilons), absolute bound [`INVERSION_TOLERANCE_WHOLE_NOTES`] whole
/// notes, no relative bound, governing validation (it decides whether a
/// continued-fraction candidate is accepted; conversion is advisory, never
/// canonical state).
pub fn inversion_tolerance() -> Tolerance {
Tolerance::absolute(
ToleranceClass::TempoIntegration,
INVERSION_TOLERANCE_WHOLE_NOTES,
ToleranceGovernance::Validation,
)
.expect("constant inversion tolerance is finite and non-negative")
}
/// Relative tolerance below which two segment speeds count as *equal*, so the
/// integration takes the numerically-stable constant-speed limit instead of the
@ -323,7 +341,7 @@ impl TempoMap {
/// §"Conversion"), using `region_relative_resolve` for segment boundaries.
/// The inverse uses a deterministic continued-fraction rational
/// approximation with the documented bounds [`INVERSION_MAX_ITERATIONS`] /
/// [`INVERSION_MAX_DENOMINATOR`] / [`INVERSION_TOLERANCE_WHOLE_NOTES`], so an
/// [`INVERSION_MAX_DENOMINATOR`] / [`inversion_tolerance`], so an
/// ordinary rhythm (a triplet `1/12`, a dotted `3/8`) round-trips exactly
/// rather than being quantized to a fixed grid.
pub fn wallclock_to_musical(&self, time: WallClockTime) -> Result<MusicalPosition, TempoError> {
@ -343,7 +361,7 @@ impl TempoMap {
rational_from_f64(
whole_notes,
INVERSION_MAX_DENOMINATOR,
INVERSION_TOLERANCE_WHOLE_NOTES,
inversion_tolerance().absolute.get(),
)
.map(MusicalPosition)
.ok_or(TempoError::ConversionOverflow)
@ -692,6 +710,19 @@ mod tests {
assert!(Tempo::new(120.0, MusicalDuration::zero()).is_none());
}
#[test]
fn inversion_tolerance_is_a_typed_tempo_integration_tolerance() {
// The inverse's residual bound is a named `Tolerance` of class
// `TempoIntegration` (Appendix D §"Tolerance Classes": no ad-hoc epsilon
// constants), pinned to the documented magnitude: absolute 1e-6 whole
// notes, no relative bound, validation governance.
let t = inversion_tolerance();
assert_eq!(t.class, ToleranceClass::TempoIntegration);
assert_eq!(t.absolute.get(), 1e-6);
assert_eq!(t.relative, None);
assert_eq!(t.governance, ToleranceGovernance::Validation);
}
#[test]
fn linear_segment_is_integrated_not_rejected() {
// A single linear ramp over [0, 1] whole notes from 60 to 120 q-bpm.

View File

@ -0,0 +1,423 @@
//! The edit-barrier gate (Chapter 8 §"Forward Compatibility and Edit Barriers"
//! / §"Behavior Under Unknown Extensions").
//!
//! The spec's MUST: *"Edits MUST be checked against every active edit barrier.
//! An edit matching a barrier's scope, affected object kinds, and operation
//! kinds is prohibited unless the user explicitly performs an unsafe edit."*
//! The session holds the active extensions' decoded barriers
//! ([`ActiveExtension`]); before minting an envelope, [`crate::EditorSession`]
//! derives the candidate edit's **subjects** — the objects the operation's
//! payload names, each with its structural containment ([`EditContext`]) — and
//! evaluates every barrier via [`EditBarrier::prohibits_edit`] against a
//! [`ScoreOracle`] over the session's materialized score.
//!
//! Matching policy (documented, deliberate):
//!
//! * **Object-kind matching is on the payload's named targets** (the object an
//! op mints, tombstones, or overwrites — plus, for a pitch insert, the host
//! event whose pitch list it mutates). Indirect containers (the voice an
//! event sits in, say) are *not* treated as edited objects; protecting a
//! container is what the barrier's **scope** is for, and scope is matched
//! against the target's real containment, precisely.
//! * **Score-level operations** (`SetMetadata`, the transaction descriptor)
//! name no graph object: only a score-wide barrier (empty
//! `affected_object_kinds`, `WholeScore`/`TuningContext`/`Registered` scope)
//! can match them.
//! * **Extension-defined operations** (`OperationKind::Registered`) carry a
//! payload the core cannot read, so their targets are unknowable: a barrier
//! prohibiting that registered kind matches **conservatively** (scope and
//! object kinds are treated as matching — Chapter 8's "never silently drop a
//! barrier you cannot evaluate").
use epiphany_core::{
EventId, PitchId, PitchSpaceId, RegionId, Score, StaffInstanceId, TypedObjectId, VoiceId,
};
use epiphany_layout_ir::{BarrierScope, EditBarrier, EditContext, EditOracle, ExtensionRef};
use epiphany_ops::{OperationKind, OperationKindTag};
/// One active extension declaration's barrier view: the declaring extension
/// (named when its barrier refuses an edit, and recorded for tombstoning when
/// an unsafe edit crosses it) plus its decoded edit barriers.
///
/// The session opens on a bare [`Score`], not a bundle, so it cannot read the
/// manifest itself: whoever opened the bundle decodes each
/// `ExtensionDeclaration.edit_barriers` blob
/// ([`epiphany_layout_ir::decode_edit_barriers`]) and injects the result via
/// [`crate::EditorSession::set_active_extensions`].
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct ActiveExtension {
/// The declaring extension (the manifest `ExtensionDeclaration`'s
/// `extension_id`, as the barrier layer's opaque 128-bit reference).
pub extension: ExtensionRef,
/// The extension's decoded edit barriers.
pub barriers: Vec<EditBarrier>,
}
/// What a candidate operation edits, for barrier matching.
pub(crate) enum BarrierSubjects {
/// The graph objects the payload names, each with its containment.
Objects(Vec<(TypedObjectId, EditContext)>),
/// The operation edits score-level state and names no graph object
/// (`SetMetadata`, a transaction descriptor): only a score-wide barrier
/// can match.
ScoreWide,
/// An extension-defined operation whose payload the core cannot read:
/// scope and object kinds are treated conservatively as matching.
Unknown,
}
/// The [`EditOracle`] over the session's materialized score: **known** barrier
/// conditions are evaluated *precisely* against the graph, so a barrier
/// narrowed to `ObjectExists` really deactivates when the object is gone.
///
/// `has_extension_data` is `false` for every object: the v0 materialized score
/// carries no extension payloads on graph objects, so "carries data declared by
/// the extension" is precisely (not conservatively) false.
pub(crate) struct ScoreOracle<'a>(pub &'a Score);
impl EditOracle for ScoreOracle<'_> {
fn object_exists(&self, object: &TypedObjectId) -> bool {
let score = self.0;
match object {
TypedObjectId::Event(id) => score.events.get(*id).is_some(),
TypedObjectId::Pitch(id) => score.live_pitch_ids().contains(id),
TypedObjectId::Voice(id) => score.voices().any(|(_, _, v)| v.id == *id),
TypedObjectId::Staff(id) => score.staves.iter().any(|s| s.id == *id),
TypedObjectId::StaffInstance(id) => score.staff_instances().any(|(_, si)| si.id == *id),
TypedObjectId::StaffGroup(id) => score.staff_groups.iter().any(|g| g.id == *id),
TypedObjectId::Region(id) => score.canvas.regions.iter().any(|r| r.id == *id),
TypedObjectId::Instrument(id) => score.instruments.iter().any(|i| i.id == *id),
TypedObjectId::PartDefinition(id) => score.parts.iter().any(|p| p.id == *id),
TypedObjectId::BarlineAlignmentGroup(id) => score
.canvas
.regions
.iter()
.flat_map(|r| r.content.barline_alignment_groups())
.any(|g| g.id == *id),
TypedObjectId::Slur(id) => score.cross_cutting.slurs.iter().any(|s| s.id == *id),
TypedObjectId::Tie(id) => score.cross_cutting.ties.iter().any(|t| t.id == *id),
TypedObjectId::Beam(id) => score.cross_cutting.beams.iter().any(|b| b.id == *id),
TypedObjectId::Spanner(id) => score.cross_cutting.spanners.iter().any(|s| s.id == *id),
TypedObjectId::Tuplet(id) => score.cross_cutting.tuplets.iter().any(|t| t.id == *id),
TypedObjectId::Marker(id) => score.cross_cutting.markers.iter().any(|m| m.id == *id),
TypedObjectId::RepeatStructure(id) => {
score.cross_cutting.repeats.iter().any(|r| r.id == *id)
}
TypedObjectId::AnalyticalAnnotation(id) => {
score.cross_cutting.analytical.iter().any(|a| a.id == *id)
}
TypedObjectId::Comment(id) => score.cross_cutting.comments.iter().any(|c| c.id == *id),
TypedObjectId::GraphicGesture(id) => score
.cross_cutting
.graphic_gestures
.iter()
.any(|g| g.id == *id),
TypedObjectId::LyricLine(id) => score.cross_cutting.lyrics.iter().any(|l| l.id == *id),
TypedObjectId::ChordSymbol(id) => score
.cross_cutting
.chord_symbols
.iter()
.any(|c| c.id == *id),
TypedObjectId::GraphicObject(id) => score
.canvas
.regions
.iter()
.flat_map(|r| r.content.graphic_objects())
.any(|g| g.id == *id),
TypedObjectId::TimeSignature(id) => score.time_signatures.iter().any(|t| t.id == *id),
TypedObjectId::AnalysisLayer(id) => score.analysis_layers.iter().any(|l| l.id == *id),
TypedObjectId::View(id) => score.views.iter().any(|v| v.id == *id),
// The v0 graph hosts no measure objects (measures are derived) and
// no extension-registered objects, so these precisely do not exist.
TypedObjectId::Measure(_) | TypedObjectId::Registered(..) => false,
}
}
fn has_extension_data(&self, _object: &TypedObjectId, _extension: ExtensionRef) -> bool {
false
}
}
fn ctx(region: Option<RegionId>, staff_instance: Option<StaffInstanceId>) -> EditContext {
EditContext {
region,
staff_instance,
analysis_layer: None,
pitch_space: None,
}
}
/// The region a staff instance manifests in, if it is in the graph.
fn region_of_staff_instance(score: &Score, instance: StaffInstanceId) -> Option<RegionId> {
score
.staff_instances()
.find(|(_, si)| si.id == instance)
.map(|(region, _)| region)
}
/// The (region, staff instance) a voice lives in, if it is in the graph.
fn voice_location(score: &Score, voice: VoiceId) -> Option<(RegionId, StaffInstanceId)> {
score
.voices()
.find(|(_, _, v)| v.id == voice)
.map(|(region, si, _)| (region, si))
}
/// The (region, staff instance) an event lives in, via the voice listing it.
fn event_location(score: &Score, event: EventId) -> Option<(RegionId, StaffInstanceId)> {
score
.voices()
.find(|(_, _, v)| v.events.contains(&event))
.map(|(region, si, _)| (region, si))
}
/// The containment and pitch space of a live pitch, via the event embedding it.
fn pitch_context(score: &Score, pitch: PitchId) -> EditContext {
let mut buf: Vec<&epiphany_core::IdentifiedPitch> = Vec::new();
for event in score.events.iter() {
buf.clear();
event.collect_identified_pitches(&mut buf);
if let Some(ip) = buf.iter().find(|ip| ip.id == pitch) {
let location = event_location(score, event.id());
return EditContext {
region: location.map(|(r, _)| r),
staff_instance: location.map(|(_, si)| si),
analysis_layer: None,
pitch_space: Some(ip.pitch.scale_position.space.clone()),
};
}
}
EditContext::default()
}
/// A context for a pitch value the operation itself carries (an insert's new
/// pitch), whose space is read from the value rather than the graph.
fn carried_pitch_context(
location: Option<(RegionId, StaffInstanceId)>,
space: PitchSpaceId,
) -> EditContext {
EditContext {
region: location.map(|(r, _)| r),
staff_instance: location.map(|(_, si)| si),
analysis_layer: None,
pitch_space: Some(space),
}
}
/// The context of a cross-cutting structure: the containment of its first
/// locatable endpoint event (a slur/tie/beam/spanner lives where its anchors
/// live), or the default context when none is locatable.
fn structure_context(score: &Score, endpoints: &[TypedObjectId]) -> EditContext {
let location = endpoints.iter().find_map(|endpoint| match endpoint {
TypedObjectId::Event(id) => event_location(score, *id),
_ => None,
});
ctx(location.map(|(r, _)| r), location.map(|(_, si)| si))
}
/// The endpoints of a cross-cutting structure already in the graph, by id.
fn graph_structure_endpoints(score: &Score, structure: &TypedObjectId) -> Vec<TypedObjectId> {
let events = |ids: Vec<EventId>| ids.into_iter().map(TypedObjectId::Event).collect();
match structure {
TypedObjectId::Slur(id) => score
.cross_cutting
.slurs
.iter()
.find(|s| s.id == *id)
.map(|s| events(vec![s.start_event, s.end_event]))
.unwrap_or_default(),
TypedObjectId::Tie(id) => score
.cross_cutting
.ties
.iter()
.find(|t| t.id == *id)
.map(|t| events(vec![t.start_event, t.end_event]))
.unwrap_or_default(),
TypedObjectId::Beam(id) => score
.cross_cutting
.beams
.iter()
.find(|b| b.id == *id)
.map(|b| events(b.events.clone()))
.unwrap_or_default(),
TypedObjectId::Spanner(id) => score
.cross_cutting
.spanners
.iter()
.find(|s| s.id == *id)
.map(|s| {
[&s.start, &s.end]
.into_iter()
.filter_map(|anchor| match anchor {
epiphany_core::TimeAnchor::Event { id, .. } => {
Some(TypedObjectId::Event(*id))
}
_ => None,
})
.collect()
})
.unwrap_or_default(),
_ => Vec::new(),
}
}
/// Derives the barrier subjects of `kind`: the objects its payload names, each
/// with its containment in `score` (see the module docs for the matching
/// policy). Containment that cannot be resolved (the target is not in the
/// graph — reduction's invariant preconditions own that case) yields a context
/// with the unresolved fields `None`.
pub(crate) fn subjects_of(kind: &OperationKind, score: &Score) -> BarrierSubjects {
let one = |object: TypedObjectId, context: EditContext| {
BarrierSubjects::Objects(vec![(object, context)])
};
match kind {
OperationKind::InsertEvent(op) => {
let region = region_of_staff_instance(score, op.staff_instance);
one(
TypedObjectId::Event(op.event_id()),
ctx(region, Some(op.staff_instance)),
)
}
OperationKind::DeleteEvent(op) => {
let location = event_location(score, op.event);
one(
TypedObjectId::Event(op.event),
ctx(location.map(|(r, _)| r), location.map(|(_, si)| si)),
)
}
OperationKind::ModifyEvent(op) => {
let location = event_location(score, op.event_id());
one(
TypedObjectId::Event(op.event_id()),
ctx(location.map(|(r, _)| r), location.map(|(_, si)| si)),
)
}
OperationKind::RespellPitch(op) => one(
TypedObjectId::Pitch(op.pitch),
pitch_context(score, op.pitch),
),
OperationKind::Transpose(op) => BarrierSubjects::Objects(
op.targets
.iter()
.map(|pitch| (TypedObjectId::Pitch(*pitch), pitch_context(score, *pitch)))
.collect(),
),
OperationKind::InsertIdentifiedPitch(op) => {
// The op mutates the host event's pitch list *and* mints the pitch:
// both are named targets.
let location = event_location(score, op.event);
let event_ctx = ctx(location.map(|(r, _)| r), location.map(|(_, si)| si));
let pitch_ctx =
carried_pitch_context(location, op.pitch.pitch.scale_position.space.clone());
BarrierSubjects::Objects(vec![
(TypedObjectId::Event(op.event), event_ctx),
(TypedObjectId::Pitch(op.pitch_id()), pitch_ctx),
])
}
OperationKind::DeleteIdentifiedPitch(op) => one(
TypedObjectId::Pitch(op.pitch),
pitch_context(score, op.pitch),
),
OperationKind::ModifyIdentifiedPitch(op) => one(
TypedObjectId::Pitch(op.pitch),
pitch_context(score, op.pitch),
),
OperationKind::CreateCrossCutting(op) => one(
op.structure.id(),
structure_context(score, &op.structure.endpoints()),
),
OperationKind::ModifyCrossCutting(op) => one(
op.structure.id(),
structure_context(score, &op.structure.endpoints()),
),
OperationKind::DeleteCrossCutting(op) => one(
op.structure,
structure_context(score, &graph_structure_endpoints(score, &op.structure)),
),
OperationKind::ChangeRegionTimeModel(op) => {
one(TypedObjectId::Region(op.region), ctx(Some(op.region), None))
}
OperationKind::SetUserSystemBreak(op) => {
one(TypedObjectId::Region(op.region), ctx(Some(op.region), None))
}
OperationKind::SetUserPageBreak(op) => {
one(TypedObjectId::Region(op.region), ctx(Some(op.region), None))
}
OperationKind::SetMetricGrid(op) => {
one(TypedObjectId::Region(op.region), ctx(Some(op.region), None))
}
OperationKind::CreateRegion(op) => one(
TypedObjectId::Region(op.region_id()),
ctx(Some(op.region_id()), None),
),
OperationKind::DeleteRegion(op) => {
one(TypedObjectId::Region(op.region), ctx(Some(op.region), None))
}
OperationKind::CreateStaffInstance(op) => one(
TypedObjectId::StaffInstance(op.instance_id()),
ctx(Some(op.region), Some(op.instance_id())),
),
OperationKind::DeleteStaffInstance(op) => one(
TypedObjectId::StaffInstance(op.staff_instance),
ctx(
region_of_staff_instance(score, op.staff_instance),
Some(op.staff_instance),
),
),
OperationKind::CreateVoice(op) => one(
TypedObjectId::Voice(op.voice_id()),
ctx(
region_of_staff_instance(score, op.staff_instance),
Some(op.staff_instance),
),
),
OperationKind::DeleteVoice(op) => {
let location = voice_location(score, op.voice);
one(
TypedObjectId::Voice(op.voice),
ctx(location.map(|(r, _)| r), location.map(|(_, si)| si)),
)
}
OperationKind::SetMetadata(_) | OperationKind::DeclareTransaction(_) => {
BarrierSubjects::ScoreWide
}
OperationKind::Registered(..) => BarrierSubjects::Unknown,
}
}
/// Every active extension with a barrier prohibiting the candidate edit, in
/// declaration order, deduplicated. Empty means the edit is permitted.
pub(crate) fn prohibiting_extensions(
extensions: &[ActiveExtension],
tag: OperationKindTag,
subjects: &BarrierSubjects,
oracle: &dyn EditOracle,
) -> Vec<ExtensionRef> {
let mut crossed = Vec::new();
for ext in extensions {
let hit = ext.barriers.iter().any(|barrier| match subjects {
BarrierSubjects::Objects(objects) => objects
.iter()
.any(|(object, context)| barrier.prohibits_edit(tag, object, context, oracle)),
BarrierSubjects::ScoreWide => {
barrier.prohibited_operation_kinds.contains(&tag)
&& barrier.affected_object_kinds.is_empty()
&& matches!(
barrier.scope,
BarrierScope::WholeScore
| BarrierScope::TuningContext
| BarrierScope::Registered(_)
)
&& barrier.condition.is_active(oracle)
}
BarrierSubjects::Unknown => {
barrier.prohibited_operation_kinds.contains(&tag)
&& barrier.condition.is_active(oracle)
}
});
if hit && !crossed.contains(&ext.extension) {
crossed.push(ext.extension);
}
}
crossed
}

File diff suppressed because it is too large Load Diff

View File

@ -127,6 +127,7 @@ fn payload_label(payload: &OperationPayload) -> &'static str {
},
OperationPayload::ResolveConflict(_) => "ResolveConflict",
OperationPayload::UndoTransaction(_) => "UndoTransaction",
OperationPayload::ResolveEquivocation(_) => "ResolveEquivocation",
}
}

View File

@ -14,11 +14,16 @@
//! [`Engraver`] runs a genuine deterministic **horizontal spacing pass** (see
//! [`spacing`]) — placing each glyph-bearing slot left-to-right by a
//! collision-aware advance (its preferred width floored by the real glyph
//! bearings) — and **evaluates the IR's declared hard constraints** against the
//! resolved geometry (no-collision, alignment, position-within; a hard break or
//! unverifiable extension constraint it cannot honour is reported unsatisfied).
//! A solve is [`SolveStatus::Solved`] only when every hard constraint is
//! satisfied; otherwise it is a diagnostic layout naming the ones it could not.
//! bearings) — and **evaluates the IR's declared constraints** against the
//! resolved geometry, routed by [`LayoutConstraint::strength`] (Chapter 9
//! §"Strength Levels"): a violated `Required` constraint (no-collision,
//! alignment, position-within, a hard break, an unverifiable extension
//! constraint) is reported unsatisfied and the solve is
//! [`SolveStatus::Unsatisfiable`]; a violated `Preferred` constraint (a soft
//! break this single-system solve does not honour) surfaces as a
//! [`SolverWarningKind::LargeSoftConstraintViolation`] warning under
//! [`SolveStatus::SolvedWithWarnings`], never a failure. A solve is
//! [`SolveStatus::Solved`] only when every declared constraint holds.
//!
//! Having earned it, [`Engraver::tier`] reports [`SolverTier::Minimal`] — which
//! (Chapter 9 §"Conformance Tiers" / QUICKSTART) means *hard constraints
@ -44,11 +49,11 @@ mod spacing;
use std::collections::BTreeMap;
use epiphany_layout_ir::{
all_available, Axis, BravuraCatalog, BreakKind, ConstrainedLayoutIR, ConstraintId,
ConstraintSolver, GlyphCatalog, GlyphObject, GlyphObjectId, InvalidationSet, LayoutConstraint,
Margins, Point, QualityMetricVector, Rect, ResolvedGlyph, ResolvedLayoutIR, ResolvedPage,
ResolvedSystem, Size2D, SolveReport, SolveStatus, SolverBudgetUsed, SolverConfig, SolverState,
SolverTier, SolverVersion, SolverWarning, SolverWarningKind, Stroke,
all_available, Axis, BravuraCatalog, ConstrainedLayoutIR, ConstraintId, ConstraintSolver,
ConstraintStrength, GlyphCatalog, GlyphObject, GlyphObjectId, InvalidationSet,
LayoutConstraint, Margins, Point, QualityMetricVector, Rect, ResolvedGlyph, ResolvedLayoutIR,
ResolvedPage, ResolvedSystem, Size2D, SolveReport, SolveStatus, SolverBudgetUsed, SolverConfig,
SolverState, SolverTier, SolverVersion, SolverWarning, SolverWarningKind, Stroke,
};
/// The glyph a fixed-width stroke (a ledger line) belongs to: the same-source glyph
@ -82,11 +87,13 @@ pub const ENGRAVER_VERSION: SolverVersion = SolverVersion(1);
impl Engraver {
/// Resolves geometry: a deterministic horizontal spacing pass over the spring
/// slots (each glyph to its slot's `x`, baseline `y` preserved), then
/// evaluation of the declared hard constraints. A malformed input — an unknown
/// glyph, a forged catalog identity, or invalid structure — yields
/// [`SolveStatus::InternalError`]; a valid problem whose hard constraints
/// cannot all be satisfied yields [`SolveStatus::Unsatisfiable`] (naming the
/// unsatisfied constraints). Both are diagnostic-only; neither panics.
/// evaluation of the declared constraints by strength. A malformed input — an
/// unknown glyph, a forged catalog identity, or invalid structure — yields
/// [`SolveStatus::InternalError`]; a valid problem whose `Required`
/// constraints cannot all be satisfied yields [`SolveStatus::Unsatisfiable`]
/// (naming the unsatisfied constraints). Both are diagnostic-only; neither
/// panics. Violated `Preferred` constraints yield soft-violation warnings
/// under [`SolveStatus::SolvedWithWarnings`] — a valid, renderable layout.
fn resolve(&self, input: &ConstrainedLayoutIR) -> SolveReport {
let structural_valid = input.validate().is_ok();
@ -110,31 +117,43 @@ impl Engraver {
};
let resolved_glyphs = glyphs.len();
// Evaluate every declared hard constraint against the *resolved* geometry.
// A Minimal solve is `Solved` only when all are satisfied. A structurally
// invalid or bad-catalog input is not evaluated — there is no trustworthy
// geometry — so it reports no evaluation work.
let (constraints_satisfied, unsatisfied_constraints, constraints_evaluated) =
if structural_valid && catalog_valid {
let (satisfied, unsatisfied) = evaluate_constraints(&input.constraints, &glyphs);
(satisfied, unsatisfied, input.constraints.len() as u64)
} else {
(false, Vec::new(), 0)
};
let well_formed = structural_valid && catalog_valid && constraints_satisfied;
// Evaluate every declared constraint against the *resolved* geometry,
// routed by its strength (Chapter 9 §"Strength Levels"): a violated
// `Required` constraint is unsatisfied (the solve is `Unsatisfiable`);
// a violated `Preferred` one is a soft-violation *warning*, never a
// failure. A structurally invalid or bad-catalog input is not evaluated
// — there is no trustworthy geometry — so it reports no evaluation work.
let (evaluation, constraints_evaluated) = if structural_valid && catalog_valid {
(
evaluate_constraints(&input.constraints, &glyphs),
input.constraints.len() as u64,
)
} else {
(ConstraintEvaluation::not_evaluated(), 0)
};
let ConstraintEvaluation {
required_satisfied,
unsatisfied: unsatisfied_constraints,
soft_violations,
} = evaluation;
let well_formed = structural_valid && catalog_valid && required_satisfied;
// Distinguish a *malformed/unusable* input (InternalError — a structural or
// catalog defect the solver cannot proceed past) from a *valid problem
// whose declared hard constraints cannot all be satisfied* (Unsatisfiable),
// per the solver-report contract (Chapter 9 §"The Solver Report").
// per the solver-report contract (Chapter 9 §"The Solver Report"). Hard
// constraints all satisfied but soft ones violated is a valid layout
// worth flagging: SolvedWithWarnings.
let status = if !structural_valid || !catalog_valid {
SolveStatus::InternalError
} else if constraints_satisfied {
SolveStatus::Solved
} else {
} else if !required_satisfied {
SolveStatus::Unsatisfiable
} else if !soft_violations.is_empty() {
SolveStatus::SolvedWithWarnings
} else {
SolveStatus::Solved
};
let mut warnings = Vec::new();
let mut warnings = soft_violations;
if structural_valid && catalog_valid && !unsatisfied_constraints.is_empty() {
warnings.push(SolverWarning {
kind: SolverWarningKind::UnusualLayoutDecision(
@ -313,27 +332,53 @@ fn interp((s0, t0): (f32, f32), (s1, t1): (f32, f32), x: f32) -> f32 {
}
}
/// Evaluates the IR's declared hard constraints against the *resolved* geometry,
/// returning whether all are satisfied and the ids of those that are not — a
/// constraint's id is its index in the IR's constraint list.
/// What evaluating the declared constraints found, routed by strength: whether
/// every `Required` constraint held, the ids of those that did not, and a
/// soft-violation warning per unhonoured `Preferred` constraint.
struct ConstraintEvaluation {
required_satisfied: bool,
unsatisfied: Vec<ConstraintId>,
soft_violations: Vec<SolverWarning>,
}
impl ConstraintEvaluation {
/// The result for an input that was never evaluated (malformed structure or
/// catalog): nothing is claimed satisfied, nothing is named unsatisfied.
fn not_evaluated() -> Self {
ConstraintEvaluation {
required_satisfied: false,
unsatisfied: Vec::new(),
soft_violations: Vec::new(),
}
}
}
/// Evaluates the IR's declared constraints against the *resolved* geometry — a
/// constraint's id is its index in the IR's constraint list — routing each
/// violation by [`LayoutConstraint::strength`] (Chapter 9 §"Strength Levels"):
/// a violated `Required` constraint is reported unsatisfied, a violated
/// `Preferred` one becomes a [`SolverWarningKind::LargeSoftConstraintViolation`]
/// warning and never fails the solve.
///
/// Geometric constraints (no-collision, alignment, position-within) are checked
/// against the resolved glyph boxes. A *hard* break is reported unsatisfied — a
/// single-system, single-page Minimal solve casts off nothing, so it cannot force
/// a break (a *soft* break imposes no obligation). An extension `Registered`
/// constraint this solver cannot interpret is likewise not claimed satisfied
/// (Chapter 7 §"Behavior Under Unknown Extensions": conservative).
/// against the resolved glyph boxes. A break is never *honoured* — a
/// single-system, single-page Minimal solve casts off nothing — so a hard break
/// (`Required`) is unsatisfied and a soft break (`Preferred`) is a warning. An
/// extension `Registered` constraint this solver cannot interpret is likewise
/// not claimed satisfied (Chapter 7 §"Behavior Under Unknown Extensions":
/// conservative).
fn evaluate_constraints(
constraints: &[LayoutConstraint],
glyphs: &[ResolvedGlyph],
) -> (bool, Vec<ConstraintId>) {
) -> ConstraintEvaluation {
let by_id: BTreeMap<GlyphObjectId, &ResolvedGlyph> = glyphs
.iter()
.map(|g| (GlyphObjectId(g.provenance.stable_id.0), g))
.collect();
let mut unsatisfied = Vec::new();
let mut soft_violations = Vec::new();
for (index, constraint) in constraints.iter().enumerate() {
let satisfied = match constraint {
let holds = match constraint {
LayoutConstraint::NoCollision { a, b } => match (by_id.get(a), by_id.get(b)) {
(Some(a), Some(b)) => !overlaps(a, b),
// A referenced glyph was dropped (a diagnostic layout): not claimed.
@ -347,15 +392,34 @@ fn evaluate_constraints(
Some(g) => within(g, region),
None => false,
},
LayoutConstraint::SystemBreakAt { kind, .. }
| LayoutConstraint::PageBreakAt { kind, .. } => matches!(kind, BreakKind::Soft),
LayoutConstraint::SystemBreakAt { .. } | LayoutConstraint::PageBreakAt { .. } => false,
LayoutConstraint::Registered(_, _) => false,
};
if !satisfied {
unsatisfied.push(ConstraintId(index as u128));
if holds {
continue;
}
let id = ConstraintId(index as u128);
match constraint.strength() {
ConstraintStrength::Required => unsatisfied.push(id),
ConstraintStrength::Preferred { .. } => soft_violations.push(SolverWarning {
kind: SolverWarningKind::LargeSoftConstraintViolation {
constraint: id,
// A break preference is binary — honoured or not — so an
// unhonoured one is a full (1.0) violation.
magnitude: 1.0,
},
affected_objects: Vec::new(),
message: "a preferred (soft) constraint is not honoured by this \
single-system Minimal solve"
.to_owned(),
}),
}
}
(unsatisfied.is_empty(), unsatisfied)
ConstraintEvaluation {
required_satisfied: unsatisfied.is_empty(),
unsatisfied,
soft_violations,
}
}
/// A resolved glyph's absolute bounding box `[left, bottom, right, top]`.
@ -519,14 +583,23 @@ mod tests {
}
#[test]
fn an_empty_constraint_set_is_vacuously_satisfied() {
let report = Engraver.solve(&fixture(), &SolverConfig::default());
fn the_pipelines_emitted_constraints_are_satisfied() {
// The spacing stage now emits real constraints (no-collision chains,
// per-glyph containment) — this is *not* a vacuous empty-set solve. The
// collision-aware spacing satisfies every one of them, and the solve
// honestly reports the evaluation work it did.
let input = fixture();
assert!(
!input.constraints.is_empty(),
"the pipeline declares real constraints"
);
let report = Engraver.solve(&input, &SolverConfig::default());
assert_eq!(report.status, SolveStatus::Solved);
assert!(report.satisfied_hard_constraints);
assert!(report.unsatisfied_constraints.is_empty());
assert_eq!(
report.budget_used.constraint_evaluations,
fixture().constraints.len() as u64
input.constraints.len() as u64
);
}
@ -583,6 +656,8 @@ mod tests {
#[test]
fn a_hard_break_cannot_be_honoured_by_single_system_minimal() {
use epiphany_layout_ir::{BreakKind, LayoutConstraint};
// A hard break maps to ConstraintStrength::Required: a single-system
// solve cannot honour it, so the solve is Unsatisfiable.
let input = with_constraints(|c| {
let slot = c.horizontal_slots[0].id;
vec![LayoutConstraint::SystemBreakAt {
@ -594,7 +669,9 @@ mod tests {
assert_eq!(report.status, SolveStatus::Unsatisfiable);
assert_eq!(report.unsatisfied_constraints.len(), 1);
// …but a *soft* break imposes no obligation, so it solves.
// …but a *soft* break is ConstraintStrength::Preferred: not honouring it
// is a soft-violation warning on a valid, renderable layout — a
// Preferred violation is a warning, never a failure.
let soft = with_constraints(|c| {
let slot = c.horizontal_slots[0].id;
vec![LayoutConstraint::SystemBreakAt {
@ -602,10 +679,68 @@ mod tests {
kind: BreakKind::Soft,
}]
});
assert_eq!(
Engraver.solve(&soft, &SolverConfig::default()).status,
SolveStatus::Solved
let report = Engraver.solve(&soft, &SolverConfig::default());
assert_eq!(report.status, SolveStatus::SolvedWithWarnings);
assert!(report.status.is_renderable());
assert!(
report.satisfied_hard_constraints,
"a soft-break violation must not flip hard-constraint satisfaction"
);
assert!(report.unsatisfied_constraints.is_empty());
assert!(report.warnings.iter().any(|w| matches!(
w.kind,
SolverWarningKind::LargeSoftConstraintViolation {
constraint: ConstraintId(0),
magnitude,
} if magnitude == 1.0
)));
}
#[test]
fn a_users_break_flows_to_a_soft_violation_not_a_failure() {
// End to end: a user system break on the score graph projects through
// the logical stage's break override into a Soft break constraint,
// which this single-system solve does not honour — surfacing as a
// Preferred-violation warning on a valid, renderable layout.
use epiphany_core::generators::valid_score;
use epiphany_core::{AnchorOffset, Event, TimeAnchor};
let mut score = valid_score(3);
let event = score.canvas.regions[0]
.staff_instances()
.iter()
.flat_map(|si| si.voices.iter())
.flat_map(|voice| voice.events.iter().copied())
.find(|eid| {
matches!(score.events.get(*eid), Some(Event::Pitched(p)) if !p.pitches.is_empty())
})
.expect("valid_score has a pitched event");
score.canvas.regions[0]
.content
.staff_based_mut()
.expect("valid_score is staff based")
.user_system_breaks
.push(TimeAnchor::Event {
id: event,
offset: AnchorOffset::Zero,
});
let constrained = to_constrained(&to_logical(&score));
assert!(
constrained
.constraints
.iter()
.any(|c| matches!(c, LayoutConstraint::SystemBreakAt { .. })),
"the user break projects into a break constraint"
);
let report = Engraver.solve(&constrained, &SolverConfig::default());
assert_eq!(report.status, SolveStatus::SolvedWithWarnings);
assert!(report.status.is_renderable());
assert!(report.satisfied_hard_constraints);
assert!(report.unsatisfied_constraints.is_empty());
assert!(report.warnings.iter().any(|w| matches!(
w.kind,
SolverWarningKind::LargeSoftConstraintViolation { .. }
)));
}
#[test]

View File

@ -244,6 +244,127 @@ object is covered); the provenance-preservation contract itself is unchanged.
is the next layer, but the axis machinery now genuinely consumes whatever times
the spacing assigns.)
- **`ConstraintStrength` is attached by rule, not by widening the IR.** Chapter 9
§"Strength Levels" defines `ConstraintStrength { Required, Preferred { weight } }`,
but the spec's `LayoutConstraint` enum carries no strength field and the
"normalized form" the solver consumes never says how strength attaches to a
constraint instance (a genuine gap — see Pass 12 candidates below). Rather than
invent an IR shape the spec doesn't have, `LayoutConstraint::strength()` derives
strength from the constraint's own shape: a break's `BreakKind` *is* its
strength (`Hard` → `Required`, `Soft``Preferred { weight: 1.0 }`), the
geometric constraints (no-collision / alignment / containment) are `Required`,
and a `Registered` extension constraint is conservatively `Required` — an
obligation a solver cannot verify must never be silently demoted (Chapter 9:
a solver MUST NOT treat `Required` as `Preferred`).
- **The spacing pass emits real constraints (Chapter 7 pipeline: "Build collision
constraints").** `try_to_constrained` now populates `constraints`, per region and
in a deterministic order: (1) **NoCollision** chains over *successive notehead
columns* within each staff — adjacent pairs in (column x, glyph id) order, linear
in the noteheads, never the O(n²) closure; chord members share a slot (a second
or unison may genuinely overlap by design), so only cross-column neighbours carry
the obligation. These hold under both v0 solvers: the source layout separates
columns collision-free and the engraver's collision-aware advance keeps
successive columns separated after its remap. (2) **PositionWithin** per glyph
against its region's envelope: the vertical extent is the exact envelope of the
region's glyph boxes (both v0 solvers preserve glyph `y` verbatim, so this is a
genuine obligation a future vertical pass must renegotiate); the horizontal span
is the open v0 canvas (`POSITION_WITHIN_X_REACH`) because v0 does no casting-off
— a region imposes no honest horizontal bound. (3) **Soft break constraints**
projected from the logical stage's break overrides, on the spring slot carrying
the break anchor's onset (the barline column at that time when one exists, else
the note column); an anchor no realized column represents — an event/measure
outside the region, a measure *end*, a region edge — is skipped silently, since
there is no slot for a solver to break at. The four SVG golden snapshots'
`hard_constraint_count` moved off 0 accordingly; the golden SVG *bytes* are
unchanged (emission does not touch geometry).
- **The stub stays honest — and renderable — under declared constraints.** The
old `StubSolver` flipped to `InternalError` whenever a constraint was present,
which conflated "constraints I did not evaluate" with "a malformed input". Now:
geometry still passes through verbatim, `satisfied_hard_constraints` is `false`
(nothing was checked), a warning names the gap, and the status is
`SolvedWithWarnings` — the closest *non-claiming* renderable status, since
Chapter 9 defines no status for "renderable, constraints unevaluated" (a Pass 12
candidate below). Editors gate on `SolveStatus::is_renderable()`, so stub-driven
pipelines (editor-core sessions, the edit-loop harness, the acceptance goldens)
keep working; the round-trip harness asserts the stub claims satisfaction
exactly when the problem is constraint-free.
- **Break overrides carry their anchor; projected from the graph's break lists.**
Per the updated Chapter 7 §"Engraving Overrides", `OverrideKind::SystemBreak` /
`PageBreak` now carry `anchor: TimeAnchor` — a break addresses a *position*,
while the override's `ScoreGraph` target names the owning region. `to_logical`
projects each region's authoritative `user_system_breaks` / `user_page_breaks`
(Chapter 5) into `Soft`, `Internal`-origin overrides (authorship lives in the
op log until P11-C8), ordered by (region id, kind discriminant, anchor canonical
bytes), each with a paired `EngravingDecision` under
`DecisionSource::UserOverride(id)` (Chapter 7 §"Override Resolution" MUST). The
override id reuses the `MUSCLOID` derivation with a literal `engraving-override`
prefix (mirroring the decision id), keyed on (region, kind discriminant, anchor
canonical bytes). The variant-shape change is byte-visible only in memory:
overrides appear in **no** codec today (the layout IR chunks cache no override
records), so no stored or interchanged artifact changes — the stale
"graph exposes no override registry" comment this replaces predated the graph's
break lists.
- **Edit-barrier decode mirrors + the manifest blob codec (PROVISIONAL byte
form, Push 3).** The barrier tree was encode-only; `barrier.rs` now carries
the exact inverse and the codec for the two opaque manifest fields the bundle
preserves verbatim (`ExtensionDeclaration.edit_barriers` /
`.affected_object_kinds` — the bundle stays semantics-free; no bundle change).
The spec defines **no normative byte form** for `EditBarrier`/`BarrierScope`/
`BarrierCondition`, so this is a provisional canonical encoding on the
established pattern (define concretely, golden-lock, submit to the Binary
Format companion): both blobs are canonical **sets** in the crate's existing
`push_set` framing — `u64` LE count, then per element a `u64` LE length
prefix and the element's canonical bytes, elements strictly ascending
byte-lexicographic, duplicates removed — an `edit_barriers` element being an
`EditBarrier`'s canonical bytes (scope, affected-kind set, prohibited-tag
set, condition, in that order), an `affected_object_kinds` element being the
kind's 2 LE bytes. Golden literal-byte tests
(`edit_barriers_blob_bytes_are_golden`,
`affected_object_kinds_blob_bytes_are_golden`) lock the layout; the testkit
adds a generator-driven round-trip gate. Decode discipline is
reject-never-normalize (`BarrierDecodeError`): unknown scope/condition/
operation-kind discriminants, unsorted or duplicated set elements, non-NFC
pitch-space text (`PitchSpaceId::new` would re-spell it, so the bytes are
non-canonical), truncation, and trailing bytes are all typed errors, and a
decoded barrier must re-encode byte-identically. Two deliberate choices:
(1) **`ObjectKind` decodes any `u16`** — the payload is an open discriminant
space (a future core kind or an extension-registered kind is a *value*, not
a decode branch), so there is nothing to reject without breaking append-only
forward compatibility; (2) **`MAX_CONDITION_DEPTH = 64`** bounds the
recursive `BarrierCondition` decode — the spec places no bound on the tree,
a decoder needs one against adversarial bytes, and 64 is far past any real
barrier (spec examples are depth 12). Both are named Binary Format
companion candidates. Evaluation wiring (the §"Behavior Under Unknown
Extensions" MUST) lives in epiphany-editor-core, which decodes injected
declarations and gates `apply`/`apply_transaction` through
`EditBarrier::prohibits_edit`.
## Pass 12 candidates (ambiguities for the spec, not resolved in code)
1. **Strength attachment to constraint instances.** Chapter 9 §"Strength Levels"
defines `ConstraintStrength`, and §"Constraint Families" says the solver
consumes constraints "in normalized form" — but the normalized form is never
specified, and Chapter 7's `LayoutConstraint` enum has no strength field, so
there is no normative channel by which a constraint instance carries its
strength. v0 attaches strength by rule (`LayoutConstraint::strength()`, above);
the spec should either bless that rule (breaks strength = `BreakKind`, all
other core families `Required`, extensions conservative) or add an explicit
strength/weight field to the normalized constraint record.
2. **No renderable status for "constraints not evaluated".** A `Stub`-tier
(below-conformance) solver that preserves geometry but evaluates nothing has
no honest `SolveStatus`: every renderable status is documented as "all hard
constraints satisfied", and the failure statuses mark the layout
diagnostic-only, which a verbatim passthrough is not. v0 uses
`SolvedWithWarnings` with `satisfied_hard_constraints == false` and a warning;
the spec should either define the report shape for a non-evaluating tier or
state that `SolvedWithWarnings` + `satisfied_hard_constraints == false` is the
sanctioned encoding.
## Pass 11 candidates (ambiguities for the spec, not resolved in code)
1. **Agent E's stated dependency set vs. the edit-barrier types.** The QUICKSTART

View File

@ -19,7 +19,7 @@
//! identically.
use epiphany_core::{AnalysisLayerId, PitchSpaceId, RegionId, StaffInstanceId, TypedObjectId};
use epiphany_determinism::CanonicalEncode;
use epiphany_determinism::{CanonicalDecode, CanonicalEncode, DecodeError};
use epiphany_ops::OperationKindTag;
/// The kind of a score-graph object a barrier protects (Chapter 8:
@ -394,6 +394,405 @@ impl CanonicalEncode for EditBarrier {
}
}
// --- Canonical decoding (the inverse) and the manifest blob codec -----------
//
// PROVISIONAL byte form, pending Binary Format companion ratification (the
// established pattern: define concretely, golden-lock, document for the
// companion). The manifest's `ExtensionDeclaration.edit_barriers` /
// `.affected_object_kinds` fields are opaque bytes to the bundle (Agent D
// preserves them verbatim); this crate — the owner of the barrier types — owns
// what those bytes mean:
//
// * `encode_affected_object_kinds` / `decode_affected_object_kinds`: a
// canonical SET of [`ObjectKind`]s — `u64` LE count, then per element a
// `u64` LE length prefix and the element's canonical bytes (2 LE bytes),
// elements strictly ascending byte-lexicographic, no duplicates (the same
// `push_set` framing the barrier encoding itself uses).
// * `encode_edit_barriers` / `decode_edit_barriers`: a canonical SET of
// [`EditBarrier`]s under the identical framing, each element an
// `EditBarrier`'s canonical bytes.
//
// Decoding is reject-never-normalize (Appendix D discipline): unknown
// discriminants, unsorted or duplicated set elements, non-NFC pitch-space
// strings, over-deep condition trees, and trailing bytes are all typed errors.
/// The maximum [`BarrierCondition`] nesting depth the decoder accepts. The
/// spec places no bound on the recursive condition tree; a decoder needs one
/// so adversarial bytes cannot drive unbounded recursion. 64 levels of
/// `All`/`Any`/`Not` nesting is far beyond any plausible real barrier (the
/// spec's own examples are depth 12); this constant is part of the
/// provisional byte contract and is a Binary Format companion candidate.
pub const MAX_CONDITION_DEPTH: usize = 64;
/// Why decoding barrier bytes failed. Construction-side canonical bytes never
/// produce these; any of them means foreign, corrupt, or non-canonical data,
/// which is rejected rather than repaired.
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum BarrierDecodeError {
/// A field ended before its declared or fixed width.
UnexpectedEof,
/// A length prefix cannot be represented safely by this process.
LengthOverflow,
/// A tagged union carried an unknown discriminant.
InvalidTag { kind: &'static str, tag: u8 },
/// An embedded primitive failed its own canonical decoder.
InvalidValue(&'static str),
/// A canonical text field was not UTF-8.
InvalidUtf8,
/// A canonical text field was not in Unicode NFC (Appendix D §"Text and
/// Unicode" requires NFC bytes; a non-NFC spelling is rejected, never
/// silently normalized).
NotNfc,
/// A set-valued field was not in strictly ascending canonical byte order
/// (unsorted, or a duplicate element), or the bytes re-encoded differently.
NonCanonical(&'static str),
/// A condition tree nested deeper than [`MAX_CONDITION_DEPTH`].
ConditionTooDeep,
/// Bytes remained after the complete value was decoded.
TrailingBytes,
}
impl core::fmt::Display for BarrierDecodeError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::UnexpectedEof => f.write_str("unexpected end of edit-barrier bytes"),
Self::LengthOverflow => f.write_str("edit-barrier length does not fit usize"),
Self::InvalidTag { kind, tag } => write!(f, "invalid {kind} tag {tag}"),
Self::InvalidValue(kind) => write!(f, "invalid canonical {kind}"),
Self::InvalidUtf8 => f.write_str("invalid UTF-8 in canonical text"),
Self::NotNfc => f.write_str("canonical text is not NFC-normalized"),
Self::NonCanonical(what) => write!(f, "edit-barrier {what} is not in canonical form"),
Self::ConditionTooDeep => write!(
f,
"barrier condition nests deeper than {MAX_CONDITION_DEPTH}"
),
Self::TrailingBytes => f.write_str("trailing bytes after edit-barrier value"),
}
}
}
impl std::error::Error for BarrierDecodeError {}
impl CanonicalDecode for ObjectKind {
/// Exactly the 2 little-endian bytes of the wrapped discriminant. Every
/// `u16` round-trips: the payload is an open discriminant space (a future
/// core kind or an extension-registered kind is a value, not a decode
/// branch), so there is no tag to reject here.
fn decode_canonical(bytes: &[u8]) -> Result<Self, DecodeError> {
let arr: [u8; 2] = bytes
.try_into()
.map_err(|_| DecodeError::UnexpectedLength {
expected: 2,
actual: bytes.len(),
})?;
Ok(ObjectKind(u16::from_le_bytes(arr)))
}
}
/// Fixed-width 16-byte little-endian decode shared by the barrier registry-id
/// newtypes (mirroring their `to_le_bytes` encode above).
macro_rules! barrier_u128_le_decode {
($name:ident) => {
impl CanonicalDecode for $name {
fn decode_canonical(bytes: &[u8]) -> Result<Self, DecodeError> {
let arr: [u8; 16] =
bytes
.try_into()
.map_err(|_| DecodeError::UnexpectedLength {
expected: 16,
actual: bytes.len(),
})?;
Ok($name(u128::from_le_bytes(arr)))
}
}
};
}
barrier_u128_le_decode!(BarrierScopeRegistryId);
barrier_u128_le_decode!(BarrierConditionRegistryId);
barrier_u128_le_decode!(ExtensionRef);
type DecodeResult<T> = Result<T, BarrierDecodeError>;
struct Reader<'a> {
bytes: &'a [u8],
pos: usize,
}
impl<'a> Reader<'a> {
fn new(bytes: &'a [u8]) -> Self {
Reader { bytes, pos: 0 }
}
fn take(&mut self, n: usize) -> DecodeResult<&'a [u8]> {
let end = self
.pos
.checked_add(n)
.ok_or(BarrierDecodeError::LengthOverflow)?;
if end > self.bytes.len() {
return Err(BarrierDecodeError::UnexpectedEof);
}
let out = &self.bytes[self.pos..end];
self.pos = end;
Ok(out)
}
fn byte(&mut self) -> DecodeResult<u8> {
Ok(self.take(1)?[0])
}
/// A `u64` little-endian length/count field (the width `push_u64` writes),
/// converted to `usize`.
fn len(&mut self) -> DecodeResult<usize> {
let raw = u64::from_le_bytes(self.take(8)?.try_into().expect("fixed width"));
usize::try_from(raw).map_err(|_| BarrierDecodeError::LengthOverflow)
}
/// A length-prefixed byte field (the form `push_elem`/`push_set` write).
fn lp_bytes(&mut self) -> DecodeResult<&'a [u8]> {
let len = self.len()?;
self.take(len)
}
fn finish(self) -> DecodeResult<()> {
if self.pos == self.bytes.len() {
Ok(())
} else {
Err(BarrierDecodeError::TrailingBytes)
}
}
}
/// Runs `decode` over exactly `bytes` — trailing bytes are an error.
fn exact<T>(
bytes: &[u8],
decode: impl FnOnce(&mut Reader<'_>) -> DecodeResult<T>,
) -> DecodeResult<T> {
let mut reader = Reader::new(bytes);
let value = decode(&mut reader)?;
reader.finish()?;
Ok(value)
}
/// Reads a `push_set`-framed set: a count, then length-prefixed elements that
/// MUST be strictly ascending in raw byte order (the sorted, deduplicated form
/// the encoder emits). Unsorted or duplicated elements are rejected as
/// non-canonical, never re-sorted.
fn read_set<'a, T>(
reader: &mut Reader<'a>,
what: &'static str,
mut decode_elem: impl FnMut(&'a [u8]) -> DecodeResult<T>,
) -> DecodeResult<Vec<T>> {
let count = reader.len()?;
let mut values = Vec::with_capacity(count.min(1024));
let mut prev: Option<&[u8]> = None;
for _ in 0..count {
let elem = reader.lp_bytes()?;
if let Some(prev) = prev {
if prev >= elem {
return Err(BarrierDecodeError::NonCanonical(what));
}
}
prev = Some(elem);
values.push(decode_elem(elem)?);
}
Ok(values)
}
/// Streams one `TypedObjectId` (variable width: 2-byte discriminant, then 16
/// payload bytes — or 32 for the `Registered` variant, discriminant 27).
fn read_typed_object_id(reader: &mut Reader<'_>) -> DecodeResult<TypedObjectId> {
let tag_bytes = reader
.bytes
.get(reader.pos..reader.pos.saturating_add(2))
.ok_or(BarrierDecodeError::UnexpectedEof)?;
let tag = u16::from_be_bytes(tag_bytes.try_into().expect("two bytes"));
let width = if tag == 27 { 34 } else { 18 };
TypedObjectId::decode_canonical(reader.take(width)?)
.map_err(|_| BarrierDecodeError::InvalidValue("TypedObjectId"))
}
fn read_u128_le(reader: &mut Reader<'_>) -> DecodeResult<u128> {
Ok(u128::from_le_bytes(
reader.take(16)?.try_into().expect("fixed width"),
))
}
/// Streams one 16-byte big-endian graph identifier (the `graph_id!` canonical
/// form `RegionId`/`StaffInstanceId`/`AnalysisLayerId` share).
fn read_graph_id<T: CanonicalDecode>(
reader: &mut Reader<'_>,
name: &'static str,
) -> DecodeResult<T> {
T::decode_canonical(reader.take(16)?).map_err(|_| BarrierDecodeError::InvalidValue(name))
}
fn read_scope(reader: &mut Reader<'_>) -> DecodeResult<BarrierScope> {
match reader.byte()? {
0 => Ok(BarrierScope::WholeScore),
1 => Ok(BarrierScope::Region(read_graph_id(reader, "RegionId")?)),
2 => Ok(BarrierScope::StaffInstance(read_graph_id(
reader,
"StaffInstanceId",
)?)),
3 => Ok(BarrierScope::AnalysisLayer(read_graph_id(
reader,
"AnalysisLayerId",
)?)),
4 => Ok(BarrierScope::ObjectSet(read_set(
reader,
"object set",
|elem| {
TypedObjectId::decode_canonical(elem)
.map_err(|_| BarrierDecodeError::InvalidValue("TypedObjectId"))
},
)?)),
5 => {
let raw = reader.lp_bytes()?;
let s = core::str::from_utf8(raw).map_err(|_| BarrierDecodeError::InvalidUtf8)?;
// `PitchSpaceId::new` NFC-normalizes; canonical bytes MUST already
// be NFC, so a spelling the constructor would change is rejected
// rather than silently normalized (re-encoding it would differ).
let id = PitchSpaceId::new(s);
if id.as_str() != s {
return Err(BarrierDecodeError::NotNfc);
}
Ok(BarrierScope::PitchSpace(id))
}
6 => Ok(BarrierScope::TuningContext),
7 => Ok(BarrierScope::Registered(BarrierScopeRegistryId(
read_u128_le(reader)?,
))),
tag => Err(BarrierDecodeError::InvalidTag {
kind: "BarrierScope",
tag,
}),
}
}
fn read_condition(reader: &mut Reader<'_>, depth: usize) -> DecodeResult<BarrierCondition> {
if depth > MAX_CONDITION_DEPTH {
return Err(BarrierDecodeError::ConditionTooDeep);
}
match reader.byte()? {
0 => Ok(BarrierCondition::Always),
1 => Ok(BarrierCondition::ObjectExists(read_typed_object_id(
reader,
)?)),
2 => Ok(BarrierCondition::ObjectHasExtensionData {
object: read_typed_object_id(reader)?,
extension: ExtensionRef(read_u128_le(reader)?),
}),
3 => Ok(BarrierCondition::All(read_condition_list(reader, depth)?)),
4 => Ok(BarrierCondition::Any(read_condition_list(reader, depth)?)),
5 => {
let inner = exact(reader.lp_bytes()?, |r| read_condition(r, depth + 1))?;
Ok(BarrierCondition::Not(Box::new(inner)))
}
6 => Ok(BarrierCondition::Registered(BarrierConditionRegistryId(
read_u128_le(reader)?,
))),
tag => Err(BarrierDecodeError::InvalidTag {
kind: "BarrierCondition",
tag,
}),
}
}
/// Reads a `push_list`-framed condition list (order-significant — the
/// *structure* of an `All`/`Any` tree, not a set).
fn read_condition_list(
reader: &mut Reader<'_>,
depth: usize,
) -> DecodeResult<Vec<BarrierCondition>> {
let count = reader.len()?;
let mut conditions = Vec::with_capacity(count.min(1024));
for _ in 0..count {
conditions.push(exact(reader.lp_bytes()?, |r| read_condition(r, depth + 1))?);
}
Ok(conditions)
}
fn read_barrier(reader: &mut Reader<'_>) -> DecodeResult<EditBarrier> {
Ok(EditBarrier {
scope: read_scope(reader)?,
affected_object_kinds: read_set(reader, "affected object kinds", |elem| {
ObjectKind::decode_canonical(elem)
.map_err(|_| BarrierDecodeError::InvalidValue("ObjectKind"))
})?,
prohibited_operation_kinds: read_set(reader, "prohibited operation kinds", |elem| {
OperationKindTag::decode_canonical(elem).map_err(|err| match (err, elem.first()) {
(DecodeError::MalformedDomainTag, Some(&tag)) => BarrierDecodeError::InvalidTag {
kind: "OperationKindTag",
tag,
},
_ => BarrierDecodeError::InvalidValue("OperationKindTag"),
})
})?,
condition: read_condition(reader, 1)?,
})
}
impl EditBarrier {
/// Decodes exactly one barrier from its canonical bytes (the inverse of
/// [`CanonicalEncode`]); trailing bytes and every non-canonical form are
/// rejected. The manifest blob form is [`decode_edit_barriers`].
pub fn decode_canonical_bytes(bytes: &[u8]) -> DecodeResult<EditBarrier> {
let barrier = exact(bytes, read_barrier)?;
// Belt and braces: canonical decode admits exactly the encoder's image,
// so the round-trip must be byte-identical.
if barrier.to_canonical_bytes() != bytes {
return Err(BarrierDecodeError::NonCanonical("barrier bytes"));
}
Ok(barrier)
}
}
/// Encodes a set of edit barriers to the canonical blob stored in
/// `ExtensionDeclaration.edit_barriers`: `push_set` framing (`u64` LE count,
/// then per barrier a `u64` LE length prefix and the barrier's canonical
/// bytes), sorted ascending by encoded bytes with duplicates removed, so the
/// blob is order- and repetition-independent.
pub fn encode_edit_barriers(barriers: &[EditBarrier]) -> Vec<u8> {
let mut out = Vec::new();
push_set(&mut out, barriers);
out
}
/// Decodes an `ExtensionDeclaration.edit_barriers` blob (the inverse of
/// [`encode_edit_barriers`]). Rejects unsorted/duplicated barriers, unknown
/// discriminants anywhere in the tree, non-NFC pitch-space text, over-deep
/// condition trees, and trailing bytes.
pub fn decode_edit_barriers(bytes: &[u8]) -> DecodeResult<Vec<EditBarrier>> {
let barriers = exact(bytes, |reader| {
read_set(
reader,
"edit-barrier set",
EditBarrier::decode_canonical_bytes,
)
})?;
Ok(barriers)
}
/// Encodes a set of object kinds to the canonical blob stored in
/// `ExtensionDeclaration.affected_object_kinds` (the same `push_set` framing
/// as [`encode_edit_barriers`]; each element is the kind's 2 LE bytes).
pub fn encode_affected_object_kinds(kinds: &[ObjectKind]) -> Vec<u8> {
let mut out = Vec::new();
push_set(&mut out, kinds);
out
}
/// Decodes an `ExtensionDeclaration.affected_object_kinds` blob (the inverse
/// of [`encode_affected_object_kinds`]).
pub fn decode_affected_object_kinds(bytes: &[u8]) -> DecodeResult<Vec<ObjectKind>> {
exact(bytes, |reader| {
read_set(reader, "affected-object-kind set", |elem| {
ObjectKind::decode_canonical(elem)
.map_err(|_| BarrierDecodeError::InvalidValue("ObjectKind"))
})
})
}
#[cfg(test)]
mod tests {
use super::*;
@ -546,6 +945,282 @@ mod tests {
assert_eq!(one.to_canonical_bytes(), repeated.to_canonical_bytes());
}
// --- Decode mirrors + the manifest blob codec ---------------------------
fn u64le(n: u64) -> Vec<u8> {
n.to_le_bytes().to_vec()
}
/// `push_set` framing assembled by hand, so the reject tests can produce
/// deliberately non-canonical framings the encoder never emits.
fn set_blob(elems: &[Vec<u8>]) -> Vec<u8> {
let mut out = u64le(elems.len() as u64);
for elem in elems {
out.extend(u64le(elem.len() as u64));
out.extend(elem);
}
out
}
#[test]
fn affected_object_kinds_blob_bytes_are_golden() {
// GOLDEN LOCK (provisional canonical form, pending Binary Format
// companion ratification): u64 LE count, then per element a u64 LE
// length prefix and the ObjectKind's 2 LE bytes, ascending, deduped.
let blob = encode_affected_object_kinds(&[ObjectKind(1), ObjectKind(0), ObjectKind(1)]);
#[rustfmt::skip]
const GOLDEN: [u8; 28] = [
2, 0, 0, 0, 0, 0, 0, 0, // count = 2 (the duplicate collapses)
2, 0, 0, 0, 0, 0, 0, 0, 0, 0, // len 2, ObjectKind(0)
2, 0, 0, 0, 0, 0, 0, 0, 1, 0, // len 2, ObjectKind(1)
];
assert_eq!(blob, GOLDEN);
assert_eq!(
decode_affected_object_kinds(&GOLDEN).unwrap(),
vec![ObjectKind(0), ObjectKind(1)]
);
}
#[test]
fn edit_barriers_blob_bytes_are_golden() {
// GOLDEN LOCK (provisional canonical form, pending Binary Format
// companion ratification): the set framing wraps each barrier's
// canonical bytes — scope, affected-kind set, prohibited-tag set,
// condition, in that order.
let barrier = EditBarrier {
scope: BarrierScope::WholeScore,
affected_object_kinds: vec![ObjectKind(1)],
prohibited_operation_kinds: vec![OperationKindTag::DeleteEvent],
condition: BarrierCondition::Always,
};
let blob = encode_edit_barriers(std::slice::from_ref(&barrier));
#[rustfmt::skip]
const GOLDEN: [u8; 53] = [
1, 0, 0, 0, 0, 0, 0, 0, // set count = 1
37, 0, 0, 0, 0, 0, 0, 0, // barrier byte length
0, // scope: WholeScore
1, 0, 0, 0, 0, 0, 0, 0, // affected kinds: count = 1
2, 0, 0, 0, 0, 0, 0, 0, 1, 0, // len 2, ObjectKind(1)
1, 0, 0, 0, 0, 0, 0, 0, // prohibited tags: count = 1
1, 0, 0, 0, 0, 0, 0, 0, 1, // len 1, DeleteEvent (tag 1)
0, // condition: Always
];
assert_eq!(blob, GOLDEN);
assert_eq!(decode_edit_barriers(&GOLDEN).unwrap(), vec![barrier]);
}
#[test]
fn every_scope_and_condition_variant_round_trips_byte_identically() {
use epiphany_core::{AnalysisLayerId, StaffInstanceId};
let scopes = vec![
BarrierScope::WholeScore,
BarrierScope::Region(RegionId::from_raw(7)),
BarrierScope::StaffInstance(StaffInstanceId::from_raw(8)),
BarrierScope::AnalysisLayer(AnalysisLayerId::from_raw(9)),
// In canonical (ascending) order: decode admits only the canonical
// image, so the round-trip compares structurally equal.
BarrierScope::ObjectSet(vec![ev(1), ev(2)]),
BarrierScope::PitchSpace(PitchSpaceId::new("cmn-12")),
BarrierScope::TuningContext,
BarrierScope::Registered(BarrierScopeRegistryId(10)),
];
let conditions = vec![
BarrierCondition::Always,
BarrierCondition::ObjectExists(ev(3)),
BarrierCondition::ObjectHasExtensionData {
object: ev(4),
extension: ExtensionRef(11),
},
BarrierCondition::All(vec![
BarrierCondition::Always,
BarrierCondition::ObjectExists(ev(5)),
]),
BarrierCondition::Any(vec![BarrierCondition::Not(Box::new(
BarrierCondition::Always,
))]),
BarrierCondition::Not(Box::new(BarrierCondition::Registered(
BarrierConditionRegistryId(12),
))),
BarrierCondition::Registered(BarrierConditionRegistryId(13)),
];
let barriers: Vec<EditBarrier> = scopes
.into_iter()
.zip(conditions.into_iter().cycle())
.map(|(scope, condition)| EditBarrier {
scope,
affected_object_kinds: vec![ObjectKind(0), ObjectKind(4)],
prohibited_operation_kinds: vec![
OperationKindTag::DeleteEvent,
OperationKindTag::Registered(epiphany_ops::OperationKindRegistryId(99)),
],
condition,
})
.collect();
// Single-barrier decode mirrors encode exactly.
for barrier in &barriers {
let bytes = barrier.to_canonical_bytes();
let decoded = EditBarrier::decode_canonical_bytes(&bytes).unwrap();
assert_eq!(&decoded, barrier);
assert_eq!(decoded.to_canonical_bytes(), bytes);
}
// The blob is a canonical set: order- and repetition-independent, and
// decode → re-encode is byte-identical.
let blob = encode_edit_barriers(&barriers);
let mut reversed: Vec<EditBarrier> = barriers.iter().rev().cloned().collect();
reversed.push(barriers[0].clone());
assert_eq!(encode_edit_barriers(&reversed), blob);
let decoded = decode_edit_barriers(&blob).unwrap();
assert_eq!(decoded.len(), barriers.len());
assert_eq!(encode_edit_barriers(&decoded), blob);
}
#[test]
fn decode_rejects_unknown_discriminants() {
// Scope tag 8 is one past the vocabulary.
let mut bytes = vec![8u8];
bytes.extend(set_blob(&[]));
bytes.extend(set_blob(&[]));
bytes.push(0);
assert_eq!(
EditBarrier::decode_canonical_bytes(&bytes),
Err(BarrierDecodeError::InvalidTag {
kind: "BarrierScope",
tag: 8
})
);
// Condition tag 7 is one past the vocabulary.
let mut bytes = vec![0u8];
bytes.extend(set_blob(&[]));
bytes.extend(set_blob(&[]));
bytes.push(7);
assert_eq!(
EditBarrier::decode_canonical_bytes(&bytes),
Err(BarrierDecodeError::InvalidTag {
kind: "BarrierCondition",
tag: 7
})
);
// Operation-kind tag 24 is one past the v1 vocabulary.
let mut bytes = vec![0u8];
bytes.extend(set_blob(&[]));
bytes.extend(set_blob(&[vec![24u8]]));
bytes.push(0);
assert_eq!(
EditBarrier::decode_canonical_bytes(&bytes),
Err(BarrierDecodeError::InvalidTag {
kind: "OperationKindTag",
tag: 24
})
);
}
#[test]
fn decode_rejects_non_nfc_pitch_space_text() {
// scope: PitchSpace(tag 5) carrying a decomposed "café" — canonically
// equivalent to the NFC form but not byte-canonical. Rejected, never
// silently normalized.
let decomposed = "cafe\u{0301}";
let mut bytes = vec![5u8];
bytes.extend(u64le(decomposed.len() as u64));
bytes.extend(decomposed.as_bytes());
bytes.extend(set_blob(&[]));
bytes.extend(set_blob(&[]));
bytes.push(0);
assert_eq!(
EditBarrier::decode_canonical_bytes(&bytes),
Err(BarrierDecodeError::NotNfc)
);
// The NFC spelling of the same name decodes (and re-encodes) fine.
let nfc = EditBarrier {
scope: BarrierScope::PitchSpace(PitchSpaceId::new("caf\u{00e9}")),
affected_object_kinds: vec![],
prohibited_operation_kinds: vec![],
condition: BarrierCondition::Always,
};
let round = EditBarrier::decode_canonical_bytes(&nfc.to_canonical_bytes()).unwrap();
assert_eq!(round, nfc);
}
#[test]
fn decode_rejects_unsorted_and_duplicated_sets_and_trailing_bytes() {
// Kinds out of ascending byte order.
let unsorted = {
let mut out = vec![0u8];
out.extend(set_blob(&[vec![1, 0], vec![0, 0]]));
out.extend(set_blob(&[]));
out.push(0);
out
};
assert_eq!(
EditBarrier::decode_canonical_bytes(&unsorted),
Err(BarrierDecodeError::NonCanonical("affected object kinds"))
);
// A duplicated element (sorted but not strictly ascending).
let duplicated = {
let mut out = vec![0u8];
out.extend(set_blob(&[vec![0, 0], vec![0, 0]]));
out.extend(set_blob(&[]));
out.push(0);
out
};
assert_eq!(
EditBarrier::decode_canonical_bytes(&duplicated),
Err(BarrierDecodeError::NonCanonical("affected object kinds"))
);
// Trailing bytes after a complete blob / a complete barrier.
let blob = encode_edit_barriers(&[EditBarrier {
scope: BarrierScope::WholeScore,
affected_object_kinds: vec![],
prohibited_operation_kinds: vec![],
condition: BarrierCondition::Always,
}]);
let mut trailing = blob.clone();
trailing.push(0);
assert_eq!(
decode_edit_barriers(&trailing),
Err(BarrierDecodeError::TrailingBytes)
);
// Truncation is an error, at every prefix length.
for cut in 0..blob.len() {
assert!(decode_edit_barriers(&blob[..cut]).is_err());
}
let mut kinds_trailing = encode_affected_object_kinds(&[ObjectKind(3)]);
kinds_trailing.push(0);
assert_eq!(
decode_affected_object_kinds(&kinds_trailing),
Err(BarrierDecodeError::TrailingBytes)
);
}
#[test]
fn decode_bounds_condition_recursion_depth() {
let nested_nots = |n: usize| {
let mut condition = BarrierCondition::Always;
for _ in 0..n {
condition = BarrierCondition::Not(Box::new(condition));
}
EditBarrier {
scope: BarrierScope::WholeScore,
affected_object_kinds: vec![],
prohibited_operation_kinds: vec![],
condition,
}
};
// MAX_CONDITION_DEPTH - 1 wrappers put the innermost leaf exactly at
// the bound: accepted.
let at_bound = nested_nots(MAX_CONDITION_DEPTH - 1);
assert_eq!(
EditBarrier::decode_canonical_bytes(&at_bound.to_canonical_bytes()).unwrap(),
at_bound
);
// One wrapper more nests past the bound: rejected.
let past_bound = nested_nots(MAX_CONDITION_DEPTH);
assert_eq!(
EditBarrier::decode_canonical_bytes(&past_bound.to_canonical_bytes()),
Err(BarrierDecodeError::ConditionTooDeep)
);
}
#[test]
fn canonical_encoding_is_set_order_independent() {
let mk = |kinds: Vec<OperationKindTag>| EditBarrier {

View File

@ -13,8 +13,8 @@ use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet};
use epiphany_core::{
Clef, EventId, KeySignature, MusicalDuration, NoteValue, PitchId, PitchSpelling,
SpellingNominal, StaffId, TypedObjectId, WallClockTime,
Clef, EventId, KeySignature, MeasureId, MeasurePosition, MusicalDuration, NoteValue, PitchId,
PitchSpelling, SpellingNominal, StaffId, TimeAnchor, TypedObjectId, WallClockTime,
};
use epiphany_determinism::{DomainTag, Preimage};
@ -22,18 +22,18 @@ use crate::engrave_theory::{
accidental_glyph, clef_glyph, has_stem, key_signature, notehead_glyph, rest_glyph,
staff_position, KeyAccidental, StaffStep,
};
use crate::engraving::EngravingDecision;
use crate::engraving::{EngravingDecision, OverrideKind, OverridePriority, OverrideTarget};
use crate::glyph::{metrics, BravuraCatalog, GlyphCatalog, GlyphCatalogIdentity, GlyphReference};
use crate::logical::{
BarlineKind, LayoutContent, LogicalLayoutIR, PlacedClef, PlacedKeySignature, ScoreVersion,
StaffContent,
apply_offset, BarlineKind, LayoutContent, LogicalLayoutIR, PlacedClef, PlacedKeySignature,
ScoreVersion, StaffContent,
};
use crate::provenance::{
manifestation_layout_id, LayoutObjectId, Provenance, SynthesisInstanceKey, SynthesisKind,
SynthesisRegistryId,
};
use crate::solver::SpringSlotId;
use crate::spatial::{BoundingBox, Point, Rect, StaffSpace};
use crate::solver::{ConstraintStrength, SpringSlotId};
use crate::spatial::{BoundingBox, Point, Rect, Size2D, StaffSpace};
use crate::time_axis::{time_cmp, SlotPlacement, TimeAxisModel, TimePoint};
use crate::vertical_band::{inter_staff_gap_id, VerticalBand, VerticalBandId};
@ -208,6 +208,47 @@ pub enum LayoutConstraint {
Registered(ConstraintRegistryId, ConstraintParameters),
}
impl LayoutConstraint {
/// The strength this constraint binds the solver with (Chapter 9 §"Strength
/// Levels": [`ConstraintStrength`]).
///
/// The spec's `LayoutConstraint` enum carries no strength field, and the
/// "normalized form" Chapter 9 says the solver consumes does not specify how
/// strength attaches to a constraint instance (a genuine spec gap — see
/// DECISIONS.md), so v0 attaches strength **by rule** rather than widening
/// the IR shape: a break constraint's own [`BreakKind`] is its strength
/// (`Hard` → `Required`, `Soft` → `Preferred` at the default weight), the
/// geometric constraints (no-collision, alignment, containment) are hard
/// engraving obligations (`Required`), and a `Registered` extension
/// constraint is conservatively `Required` — an obligation a solver cannot
/// verify must not be silently demoted (Chapter 9: a solver MUST NOT treat
/// `Required` as `Preferred`).
pub fn strength(&self) -> ConstraintStrength {
match self {
LayoutConstraint::SystemBreakAt {
kind: BreakKind::Soft,
..
}
| LayoutConstraint::PageBreakAt {
kind: BreakKind::Soft,
..
} => ConstraintStrength::Preferred { weight: 1.0 },
LayoutConstraint::NoCollision { .. }
| LayoutConstraint::Align { .. }
| LayoutConstraint::PositionWithin { .. }
| LayoutConstraint::SystemBreakAt {
kind: BreakKind::Hard,
..
}
| LayoutConstraint::PageBreakAt {
kind: BreakKind::Hard,
..
}
| LayoutConstraint::Registered(_, _) => ConstraintStrength::Required,
}
}
}
/// A structural defect in [`ConstrainedLayoutIR`] that prevents a solver from
/// treating the input as a valid constraint problem.
#[derive(Clone, PartialEq, Eq, Debug)]
@ -455,6 +496,17 @@ const KEY_ACC_X: f32 = 0.9; // x advance per key-signature accidental
const TIME_SIG_X: f32 = 0.5; // a time signature sits this far right of its barline
const TIME_DIGIT_X: f32 = 0.8; // x advance per time-signature digit
/// The horizontal half-reach of an emitted `PositionWithin` region, in staff
/// spaces. v0 performs no casting-off, so a region imposes no *horizontal*
/// bound on its glyphs — a conformant solver may re-space columns freely along
/// the open canvas. The containment obligation v0 can honestly state is the
/// **vertical** envelope (which the spacing pass computes from the very glyph
/// geometry the solvers preserve), so the emitted rect pins that envelope and
/// leaves the horizontal span at canvas scale: wide enough for any plausible
/// re-spacing, finite because the validator rejects non-finite constraint
/// regions.
const POSITION_WITHIN_X_REACH: f32 = 1.0e6;
/// The registry id for the engraver's **structural-line synthesis** (staff
/// lines). The normative [`SynthesisKind`] set names *musical* synthesized
/// objects (cancellation accidentals, generated rests, …) but no purely visual
@ -530,6 +582,7 @@ pub fn try_to_constrained(
let mut diagnostics = Vec::new();
let mut vertical_bands = Vec::new();
let mut horizontal_slots = Vec::new();
let mut constraints = Vec::new();
let mut constrained_regions = Vec::new();
// Regions tile left-to-right; this advances by each region's width so all
// coordinates stay globally monotonic (the solver's coordinate remap relies
@ -807,6 +860,9 @@ pub fn try_to_constrained(
)
.collect();
// Where this region's glyphs begin in the global vector, so constraint
// emission below can see exactly the glyphs pass 2 produced for it.
let region_glyph_start = glyphs.len();
let mut emit = Emit {
glyphs: &mut glyphs,
strokes: &mut strokes,
@ -1156,6 +1212,144 @@ pub fn try_to_constrained(
}
}
// --- Constraint emission (Chapter 7 §"Pipeline Overview": the spacing
// pass "build[s] collision constraints"). Everything emitted here is
// satisfiable on well-formed input by construction — the source layout
// separates columns collision-free and a conformant re-spacing keeps
// them so — and the order is deterministic: per region, no-collision
// pairs (staff emission order, then column x / glyph id), containment
// (glyph stable-id order), then projected breaks (override order).
let region_glyph_objects = &glyphs[region_glyph_start..];
let glyph_by_id: BTreeMap<GlyphObjectId, &GlyphObject> = region_glyph_objects
.iter()
.map(|glyph| (glyph.id(), glyph))
.collect();
// NoCollision between *successive notehead columns* within each staff:
// adjacent pairs in (column x, id) order, one linear chain per staff,
// not O(n²). Chord members share a column slot — a second or unison may
// genuinely overlap by design — so only cross-column neighbours carry
// the obligation.
for staff in &staves_in_order {
let mut heads: Vec<&GlyphObject> = staff_members
.get(staff)
.into_iter()
.flatten()
.filter_map(|id| glyph_by_id.get(id).copied())
.filter(|glyph| glyph.glyph.as_str().starts_with("notehead"))
.collect();
heads.sort_by(|a, b| {
a.baseline
.x
.0
.total_cmp(&b.baseline.x.0)
.then_with(|| a.id().cmp(&b.id()))
});
for pair in heads.windows(2) {
if pair[0].horizontal_slot != pair[1].horizontal_slot {
constraints.push(LayoutConstraint::NoCollision {
a: pair[0].id(),
b: pair[1].id(),
});
}
}
}
// PositionWithin: every glyph must stay inside its owning region's
// envelope. The vertical extent is the exact envelope of the region's
// own glyph boxes (both v0 solvers preserve glyph `y` verbatim, so this
// is a real obligation a vertical pass must renegotiate); the
// horizontal span is the open v0 canvas (see
// [`POSITION_WITHIN_X_REACH`]).
if !region_glyph_objects.is_empty() {
let mut bottom = f32::INFINITY;
let mut top = f32::NEG_INFINITY;
for glyph in region_glyph_objects {
bottom = bottom.min(glyph.baseline.y.0 + glyph.bounding_box.bottom.0);
top = top.max(glyph.baseline.y.0 + glyph.bounding_box.top.0);
}
let envelope = Rect {
origin: Point::new(-POSITION_WITHIN_X_REACH, bottom),
size: Size2D {
width: StaffSpace(2.0 * POSITION_WITHIN_X_REACH),
height: StaffSpace(top - bottom),
},
};
let mut ids: Vec<GlyphObjectId> = glyph_by_id.keys().copied().collect();
ids.sort();
for glyph in ids {
constraints.push(LayoutConstraint::PositionWithin {
glyph,
region: envelope,
});
}
}
// Projected break overrides (the logical stage's `SystemBreak` /
// `PageBreak` engraving overrides, Chapter 7 §"Engraving Overrides")
// become break constraints on the spring slot that carries the break
// anchor's onset — the barline column at that time when one exists
// (a break belongs at the boundary), else the note column. An anchor
// no realized column represents — an event or measure outside this
// region, a measure *end* (Minimal resolves measure starts only), a
// region edge, or a column no glyph landed in — is skipped silently:
// there is no slot for a solver to break at.
let mut event_onsets: BTreeMap<EventId, TimePoint> = BTreeMap::new();
let mut measure_starts: BTreeMap<MeasureId, TimePoint> = BTreeMap::new();
for object in &region.objects {
match (object.provenance().source, object.content()) {
(TypedObjectId::Event(eid), LayoutContent::Note(note)) => {
event_onsets.insert(eid, note.position.clone());
}
(TypedObjectId::Event(eid), LayoutContent::Rest(rest)) => {
event_onsets.insert(eid, rest.position.clone());
}
(TypedObjectId::Measure(mid), LayoutContent::Measure(measure)) => {
measure_starts.insert(mid, measure.start.clone());
}
_ => {}
}
}
for override_record in &logical.overrides {
if override_record.target
!= OverrideTarget::ScoreGraph(TypedObjectId::Region(region_id))
{
continue;
}
let (anchor, system) = match &override_record.kind {
OverrideKind::SystemBreak { anchor } => (anchor, true),
OverrideKind::PageBreak { anchor } => (anchor, false),
_ => continue,
};
let Some(time) = break_anchor_time(anchor, &event_onsets, &measure_starts) else {
continue;
};
let slot = [ColumnRole::Barline, ColumnRole::Note]
.iter()
.find_map(|role| {
let info = columns.get(&ColumnKey::Timed(time.clone(), *role))?;
column_members
.get(&info.slot)
.filter(|members| !members.is_empty())
.map(|_| info.slot)
});
let Some(slot) = slot else {
continue;
};
// The override's binding strength is the break's kind: a `Hard`
// override MUST be honored or error, a `Soft` one is a preference
// (Chapter 7 §"Override Resolution"; the projection emits `Soft`).
let kind = match override_record.priority {
OverridePriority::Hard => BreakKind::Hard,
OverridePriority::Soft => BreakKind::Soft,
};
constraints.push(if system {
LayoutConstraint::SystemBreakAt { slot, kind }
} else {
LayoutConstraint::PageBreakAt { slot, kind }
});
}
// A staff band per manifested staff that carries glyphs, in first-glyph
// order; an (empty) inter-staff gap band between adjacent staves; and a
// margin band for any region-level glyphs.
@ -1203,7 +1397,7 @@ pub fn try_to_constrained(
glyphs,
strokes,
vertical_bands,
constraints: Vec::new(),
constraints,
engraving_decisions: logical.engraving_decisions.clone(),
diagnostics,
catalog,
@ -1422,6 +1616,30 @@ fn component_provenance(base: &Provenance, comp: usize) -> Provenance {
}
}
/// Resolves a projected break override's [`TimeAnchor`] to the region-local
/// [`TimePoint`] whose spacing column carries it, using the onsets this
/// region's own objects resolved to. Returns `None` — the break is skipped
/// silently — when the anchor addresses something no spacing column
/// represents: an event or measure outside this region, a measure *end* (the
/// Minimal slice resolves measure starts only), a region edge, or an offset
/// whose clock does not match its base.
fn break_anchor_time(
anchor: &TimeAnchor,
event_onsets: &BTreeMap<EventId, TimePoint>,
measure_starts: &BTreeMap<MeasureId, TimePoint>,
) -> Option<TimePoint> {
match anchor {
TimeAnchor::WallClock { time } => Some(TimePoint::WallClock(*time)),
TimeAnchor::Event { id, offset } => apply_offset(event_onsets.get(id)?.clone(), offset),
TimeAnchor::Measure {
id,
position: MeasurePosition::Start,
offset,
} => apply_offset(measure_starts.get(id)?.clone(), offset),
TimeAnchor::Measure { .. } | TimeAnchor::Region { .. } => None,
}
}
/// The column a measure's barline occupies: the final barline closes the region
/// at the right; an interior/region-end barline sits before its measure's notes.
fn measure_column(measure: &crate::logical::MeasureContent) -> ColumnKey {
@ -1700,6 +1918,191 @@ mod tests {
use epiphany_core::generators::valid_score_rich;
use std::collections::BTreeSet;
#[test]
fn constraint_strength_attaches_by_rule() {
// The spec's constraint enum carries no strength field, so strength is
// a rule over the constraint's own shape (Chapter 9 §"Strength Levels").
let glyph = GlyphObjectId(1);
let slot = SpringSlotId(2);
let required = [
LayoutConstraint::NoCollision { a: glyph, b: glyph },
LayoutConstraint::Align {
a: glyph,
b: glyph,
axis: Axis::Vertical,
},
LayoutConstraint::PositionWithin {
glyph,
region: Rect {
origin: Point::ORIGIN,
size: Size2D::default(),
},
},
LayoutConstraint::SystemBreakAt {
slot,
kind: BreakKind::Hard,
},
LayoutConstraint::PageBreakAt {
slot,
kind: BreakKind::Hard,
},
// Conservative: an unverifiable extension obligation is never demoted.
LayoutConstraint::Registered(ConstraintRegistryId(3), ConstraintParameters::default()),
];
for constraint in required {
assert_eq!(constraint.strength(), ConstraintStrength::Required);
}
// A soft break is a preference at the default weight.
for soft in [
LayoutConstraint::SystemBreakAt {
slot,
kind: BreakKind::Soft,
},
LayoutConstraint::PageBreakAt {
slot,
kind: BreakKind::Soft,
},
] {
assert_eq!(
soft.strength(),
ConstraintStrength::Preferred { weight: 1.0 }
);
}
}
#[test]
fn constraint_emission_is_deterministic_and_principled() {
let logical = to_logical(&valid_score_rich(11));
let a = try_to_constrained(&logical).expect("well-formed logical IR");
let b = try_to_constrained(&logical).expect("well-formed logical IR");
assert_eq!(
a.constraints, b.constraints,
"two runs emit identical constraint vectors"
);
assert!(!a.constraints.is_empty(), "the pipeline emits constraints");
// Every constraint references real glyphs/slots and finite geometry.
assert!(a.validate().is_ok());
// Containment: one PositionWithin per glyph, against its region envelope.
let contained = a
.constraints
.iter()
.filter(|c| matches!(c, LayoutConstraint::PositionWithin { .. }))
.count();
assert_eq!(contained, a.glyphs.len());
// No-collision: a linear chain over successive notehead columns, never
// the O(n²) all-pairs closure.
let pairs = a
.constraints
.iter()
.filter(|c| matches!(c, LayoutConstraint::NoCollision { .. }))
.count();
let noteheads = a
.glyphs
.iter()
.filter(|g| g.glyph.as_str().starts_with("notehead"))
.count();
assert!(pairs > 0, "successive noteheads earn no-collision pairs");
assert!(pairs < noteheads, "the chain is linear in the noteheads");
// Every no-collision endpoint is a notehead in a distinct column slot.
let by_id: BTreeMap<GlyphObjectId, &GlyphObject> =
a.glyphs.iter().map(|g| (g.id(), g)).collect();
for constraint in &a.constraints {
if let LayoutConstraint::NoCollision {
a: first,
b: second,
} = constraint
{
let (first, second) = (by_id[first], by_id[second]);
assert!(first.glyph.as_str().starts_with("notehead"));
assert!(second.glyph.as_str().starts_with("notehead"));
assert_ne!(first.horizontal_slot, second.horizontal_slot);
}
}
// No break constraints without projected break overrides.
assert!(!a.constraints.iter().any(|c| matches!(
c,
LayoutConstraint::SystemBreakAt { .. } | LayoutConstraint::PageBreakAt { .. }
)));
}
#[test]
fn user_break_overrides_become_soft_break_constraints() {
use epiphany_core::generators::valid_score;
use epiphany_core::{AnchorOffset, Event, RegionEdge, TimeAnchor};
let mut score = valid_score(3);
let region_id = score.canvas.regions[0].id;
// A pitched event in region 0 whose onset column is realized (it draws
// noteheads), so the break has a spring slot to land on.
let event = score.canvas.regions[0]
.staff_instances()
.iter()
.flat_map(|si| si.voices.iter())
.flat_map(|voice| voice.events.iter().copied())
.find(|eid| {
matches!(score.events.get(*eid), Some(Event::Pitched(p)) if !p.pitches.is_empty())
})
.expect("valid_score has a pitched event");
let anchor = TimeAnchor::Event {
id: event,
offset: AnchorOffset::Zero,
};
let content = score.canvas.regions[0]
.content
.staff_based_mut()
.expect("valid_score is staff based");
content.user_system_breaks.push(anchor.clone());
content.user_page_breaks.push(anchor);
// An anchor no spacing column represents — a region edge — is skipped
// silently rather than mis-assigned to some column.
content.user_system_breaks.push(TimeAnchor::Region {
id: region_id,
edge: RegionEdge::Start,
offset: AnchorOffset::Zero,
});
let constrained = to_constrained(&to_logical(&score));
assert!(constrained.validate().is_ok());
let system_breaks: Vec<&LayoutConstraint> = constrained
.constraints
.iter()
.filter(|c| matches!(c, LayoutConstraint::SystemBreakAt { .. }))
.collect();
let page_breaks: Vec<&LayoutConstraint> = constrained
.constraints
.iter()
.filter(|c| matches!(c, LayoutConstraint::PageBreakAt { .. }))
.collect();
assert_eq!(
system_breaks.len(),
1,
"the event-anchored break lands; the region-edge one is skipped"
);
assert_eq!(page_breaks.len(), 1);
for projected in system_breaks.iter().chain(&page_breaks) {
let (LayoutConstraint::SystemBreakAt { slot, kind }
| LayoutConstraint::PageBreakAt { slot, kind }) = projected
else {
unreachable!("filtered to break constraints");
};
// A Soft override projects a Soft break — a Preferred obligation.
assert_eq!(*kind, BreakKind::Soft);
assert_eq!(
projected.strength(),
ConstraintStrength::Preferred { weight: 1.0 }
);
// The slot is the event's own (realized) onset column.
let slot = constrained
.horizontal_slots
.iter()
.find(|s| s.id == *slot)
.expect("break constraints name realized slots");
assert!(!slot.members.is_empty());
}
}
#[test]
fn ledger_steps_cover_only_lines_outside_the_staff() {
// Within the five-line staff (steps 0..=8) and one space just outside: none.

View File

@ -8,7 +8,7 @@
//! provenance and override interfaces (the QUICKSTART scope item); production
//! engraving algorithms remain layered specifications beyond the v0 stub.
use epiphany_core::{StemDirection, TypedObjectId};
use epiphany_core::{CanonicalValue, RegionId, StemDirection, TimeAnchor, TypedObjectId};
use epiphany_determinism::{DomainTag, Preimage};
use crate::provenance::LayoutObjectId;
@ -72,19 +72,41 @@ pub enum OverrideOrigin {
/// Core override vocabulary. More detailed engraving payloads are represented
/// by stable registered ids until their companion algorithm specifications land.
///
/// A break override addresses a *position*, not an object (Chapter 7
/// §"Engraving Overrides"): the kind carries the break's [`TimeAnchor`], while
/// the override's `ScoreGraph` target names the owning region.
#[derive(Clone, PartialEq, Debug)]
pub enum OverrideKind {
StemDirection(StemDirection),
AccidentalParenthesized(bool),
AccidentalVisible(bool),
SystemBreak,
PageBreak,
SystemBreak { anchor: TimeAnchor },
PageBreak { anchor: TimeAnchor },
HiddenObject,
CustomPosition(Point),
LedgerLineSuppression,
Registered(u128),
}
impl OverrideKind {
/// A stable discriminant byte, part of the override-id preimage and the
/// projection's deterministic ordering key.
pub(crate) fn discriminant(&self) -> u8 {
match self {
OverrideKind::StemDirection(_) => 0,
OverrideKind::AccidentalParenthesized(_) => 1,
OverrideKind::AccidentalVisible(_) => 2,
OverrideKind::SystemBreak { .. } => 3,
OverrideKind::PageBreak { .. } => 4,
OverrideKind::HiddenObject => 5,
OverrideKind::CustomPosition(_) => 6,
OverrideKind::LedgerLineSuppression => 7,
OverrideKind::Registered(_) => 8,
}
}
}
/// A projected engraving override (Chapter 7 §"Engraving Overrides").
#[derive(Clone, PartialEq, Debug)]
pub struct EngravingOverride {
@ -95,6 +117,59 @@ pub struct EngravingOverride {
pub origin: OverrideOrigin,
}
impl EngravingOverride {
/// A system-break override projected from a region's authoritative
/// `user_system_breaks` list (Chapter 5 §"Staff-Based Content").
pub fn projected_system_break(region: RegionId, anchor: TimeAnchor) -> Self {
Self::projected_break(region, OverrideKind::SystemBreak { anchor })
}
/// A page-break override projected from a region's authoritative
/// `user_page_breaks` list (Chapter 5 §"Staff-Based Content").
pub fn projected_page_break(region: RegionId, anchor: TimeAnchor) -> Self {
Self::projected_break(region, OverrideKind::PageBreak { anchor })
}
/// The shared shape of a projected break override (Chapter 7 §"Engraving
/// Overrides"): the kind carries the break's anchor, the `ScoreGraph`
/// target names the owning region, the binding is `Soft` (the layout
/// SHOULD honor it), and the origin is `Internal` — break authorship
/// (author, timestamp) lives in the operation log, not the materialized
/// break lists, until the snapshot-undo refinement (P11-C8) surfaces it.
fn projected_break(region: RegionId, kind: OverrideKind) -> Self {
EngravingOverride {
id: derive_break_override_id(region, &kind),
target: OverrideTarget::ScoreGraph(TypedObjectId::Region(region)),
kind,
priority: OverridePriority::Soft,
origin: OverrideOrigin::Internal,
}
}
}
/// Derives an [`EngravingOverrideId`] for a projected break override from its
/// owning region, its kind discriminant, and the break anchor's canonical
/// bytes — so equal breaks share an id across re-projection and distinct ones
/// never collide.
///
/// Like the engraving-decision id, the override is a non-canonical
/// layout-namespace object, so the preimage is domain-separated under
/// [`DomainTag::LAYOUT_OBJECT_ID`] (`MUSCLOID`) with a literal
/// `engraving-override` discriminator prefix, so an override id can alias
/// neither a layout-object id nor a decision id within that namespace.
fn derive_break_override_id(region: RegionId, kind: &OverrideKind) -> EngravingOverrideId {
let anchor = match kind {
OverrideKind::SystemBreak { anchor } | OverrideKind::PageBreak { anchor } => anchor,
_ => unreachable!("projected break overrides carry a break kind"),
};
let mut p = Preimage::new(DomainTag::LAYOUT_OBJECT_ID);
p.push_bytes(b"engraving-override");
p.push_bytes(&region.canonical_bytes());
p.push_u64_le(kind.discriminant() as u64);
p.push_bytes(&anchor.canonical_bytes());
EngravingOverrideId(p.finish_trunc128())
}
/// Where an engraving decision came from (Chapter 7 §"Note Layout":
/// `DecisionSource`).
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
@ -258,6 +333,43 @@ mod tests {
);
}
#[test]
fn projected_break_override_ids_are_content_derived() {
use epiphany_core::{RegionId, WallClockTime};
let region = RegionId::from_raw(9);
let anchor = TimeAnchor::WallClock {
time: WallClockTime(7),
};
let a = EngravingOverride::projected_system_break(region, anchor.clone());
let b = EngravingOverride::projected_system_break(region, anchor.clone());
assert_eq!(a, b, "equal breaks share an id across re-projection");
// A page break at the same anchor is a distinct override…
let page = EngravingOverride::projected_page_break(region, anchor.clone());
assert_ne!(a.id, page.id);
// …as is the same break at a different anchor…
let other = EngravingOverride::projected_system_break(
region,
TimeAnchor::WallClock {
time: WallClockTime(8),
},
);
assert_ne!(a.id, other.id);
// …or in a different owning region.
let elsewhere =
EngravingOverride::projected_system_break(RegionId::from_raw(10), anchor.clone());
assert_ne!(a.id, elsewhere.id);
// The projected shape the spec pins: the kind carries the break anchor,
// the ScoreGraph target names the owning region, the binding is Soft,
// and the origin is Internal (authorship lives in the op log, P11-C8).
assert_eq!(a.kind, OverrideKind::SystemBreak { anchor });
assert_eq!(
a.target,
OverrideTarget::ScoreGraph(TypedObjectId::Region(region))
);
assert_eq!(a.priority, OverridePriority::Soft);
assert_eq!(a.origin, OverrideOrigin::Internal);
}
#[test]
fn stem_direction_payload_changes_the_id() {
let target = LayoutObjectId(7);

View File

@ -82,8 +82,10 @@ pub mod time_axis;
pub mod vertical_band;
pub use barrier::{
AlwaysLiveOracle, BarrierCondition, BarrierConditionRegistryId, BarrierScope,
BarrierScopeRegistryId, EditBarrier, EditContext, EditOracle, ExtensionRef, ObjectKind,
decode_affected_object_kinds, decode_edit_barriers, encode_affected_object_kinds,
encode_edit_barriers, AlwaysLiveOracle, BarrierCondition, BarrierConditionRegistryId,
BarrierDecodeError, BarrierScope, BarrierScopeRegistryId, EditBarrier, EditContext, EditOracle,
ExtensionRef, ObjectKind, MAX_CONDITION_DEPTH,
};
pub use cache::{
ConstrainedRegionCache, DependencyIndex, FineLayoutCache, LayoutCache, LogicalRegionCache,
@ -134,11 +136,11 @@ pub use resolved::{
};
pub use roundtrip::{laid_out_object_ids, round_trip, round_trip_with, RoundTripReport};
pub use solver::{
ConstraintId, ConstraintSolver, ExtensionMetric, ExtensionMetricId, ExtensionWarningId,
InvalidationScope, InvalidationSet, NormalizedMetric, QualityMetricKind, QualityMetricVector,
SolveReport, SolveStatus, SolverBudget, SolverBudgetUsed, SolverConfig, SolverProfile,
SolverState, SolverTier, SolverVersion, SolverWarning, SolverWarningKind, SpringSlotId,
StubSolver, TieBreakingWeights,
ConstraintId, ConstraintSolver, ConstraintStrength, ExtensionMetric, ExtensionMetricId,
ExtensionWarningId, InvalidationScope, InvalidationSet, NormalizedMetric, QualityMetricKind,
QualityMetricVector, SolveReport, SolveStatus, SolverBudget, SolverBudgetUsed, SolverConfig,
SolverProfile, SolverState, SolverTier, SolverVersion, SolverWarning, SolverWarningKind,
SpringSlotId, StubSolver, TieBreakingWeights,
};
pub use spatial::{
BoundingBox, Margins, Point, Rect, ScaleContext, Size2D, StaffSpace, Transform2D,

View File

@ -17,15 +17,17 @@ use std::collections::{BTreeMap, BTreeSet};
use epiphany_core::prepass::{derive_annotations, DerivedAnnotations, PrePassProfile};
use epiphany_core::{
AleatoricAnchoringDiscipline, AnchorOffset, AnnotationAnchor, Clef, CoordinateDiscipline,
Event, EventId, EventPosition, KeySignature, MeasurePosition, MusicalDuration, MusicalPosition,
NotatedComponent, PitchId, PitchSpelling, Region, RegionEdge, RegionId, RegionTimeModel, Score,
StaffId, StaffPosition, TimeAnchor, TimeSignatureDisplay, TupletId, TupletRatio, TypedObjectId,
WallClockTime,
AleatoricAnchoringDiscipline, AnchorOffset, AnnotationAnchor, CanonicalValue, Clef,
CoordinateDiscipline, Event, EventId, EventPosition, KeySignature, MeasurePosition,
MusicalDuration, MusicalPosition, NotatedComponent, PitchId, PitchSpelling, Region, RegionEdge,
RegionId, RegionTimeModel, Score, StaffId, StaffPosition, TimeAnchor, TimeSignatureDisplay,
TupletId, TupletRatio, TypedObjectId, WallClockTime,
};
use epiphany_determinism::{DomainTag, Preimage};
use crate::engraving::{EngravingDecision, EngravingDecisionKind, EngravingOverride};
use crate::engraving::{
DecisionSource, EngravingDecision, EngravingDecisionKind, EngravingOverride, OverrideKind,
};
use crate::provenance::{LayoutObjectId, Provenance};
use crate::spatial::Transform2D;
use crate::time_axis::{time_axis_of, TimeAxisModel, TimePoint};
@ -367,8 +369,12 @@ pub struct LogicalLayoutIR {
/// Engraving decisions made during the engraving pass (Chapter 7
/// §"Engraving Decisions"), carried forward through the pipeline.
pub engraving_decisions: Vec<EngravingDecision>,
/// User engraving overrides projected from the score graph. Agent B's
/// current graph exposes no override registry, so the projection is empty.
/// User engraving overrides projected from the score graph: each region's
/// authoritative `user_system_breaks` / `user_page_breaks` lists (Chapter 5
/// §"Staff-Based Content") become Soft, `Internal`-origin break overrides
/// targeting the owning region, ordered by (region id, kind, anchor
/// canonical bytes). Each carries a paired [`EngravingDecision`] with
/// [`DecisionSource::UserOverride`] in `engraving_decisions`.
pub overrides: Vec<EngravingOverride>,
/// Objects spanning two or more layout regions.
pub cross_region: Vec<CrossRegionObject>,
@ -394,6 +400,9 @@ pub struct LogicalLayoutIR {
pub fn to_logical(score: &Score) -> LogicalLayoutIR {
let mut regions = Vec::new();
let mut engraving_decisions = Vec::new();
// The projected break overrides, keyed by owning region for the final
// deterministic ordering (canvas order need not be region-id order).
let mut projected_breaks: Vec<(RegionId, EngravingOverride)> = Vec::new();
let mut cross_region = Vec::new();
let mut seen: BTreeSet<LayoutObjectId> = BTreeSet::new();
// The resolved spellings and decompositions the notation engraving consumes
@ -538,6 +547,34 @@ pub fn to_logical(score: &Score) -> LogicalLayoutIR {
region_provenance.stable_id,
EngravingDecisionKind::SystemBreak,
));
// The region's authoritative user break lists project as engraving
// overrides (Chapter 7 §"Engraving Overrides": a break override
// addresses a *position* — its kind carries the break's `TimeAnchor`,
// its `ScoreGraph` target names the owning region). Each applied
// override records a paired decision with
// `DecisionSource::UserOverride(id)` (Chapter 7 §"Override
// Resolution") against the region's stable layout id.
if let Some(content) = region.content.staff_based() {
for anchor in &content.user_system_breaks {
let projected =
EngravingOverride::projected_system_break(region_id, anchor.clone());
engraving_decisions.push(EngravingDecision::with_source(
region_provenance.stable_id,
EngravingDecisionKind::SystemBreak,
DecisionSource::UserOverride(projected.id),
));
projected_breaks.push((region_id, projected));
}
for anchor in &content.user_page_breaks {
let projected = EngravingOverride::projected_page_break(region_id, anchor.clone());
engraving_decisions.push(EngravingDecision::with_source(
region_provenance.stable_id,
EngravingDecisionKind::PageBreak,
DecisionSource::UserOverride(projected.id),
));
projected_breaks.push((region_id, projected));
}
}
regions.push(LayoutRegion {
provenance: region_provenance,
coordinate_system: LocalCoordinateSystem::default(),
@ -609,16 +646,41 @@ pub fn to_logical(score: &Score) -> LogicalLayoutIR {
}
}
// Deterministic override order: by (region id, kind discriminant, anchor
// canonical bytes) — independent of canvas order and of the break lists'
// internal order.
projected_breaks.sort_by(|(region_a, a), (region_b, b)| {
(region_a, a.kind.discriminant(), break_anchor_bytes(a)).cmp(&(
region_b,
b.kind.discriminant(),
break_anchor_bytes(b),
))
});
let source = derive_score_version(score);
LogicalLayoutIR {
source,
regions,
engraving_decisions,
overrides: Vec::new(),
overrides: projected_breaks
.into_iter()
.map(|(_, projected)| projected)
.collect(),
cross_region,
}
}
/// The canonical bytes of a projected break override's anchor (its ordering
/// key alongside the owning region and kind).
fn break_anchor_bytes(projected: &EngravingOverride) -> Vec<u8> {
match &projected.kind {
OverrideKind::SystemBreak { anchor } | OverrideKind::PageBreak { anchor } => {
anchor.canonical_bytes()
}
_ => Vec::new(),
}
}
/// Derives the [`ScoreVersion`] from the **whole score's canonical content**
/// (Agent B's whole-score codec), not merely the layout projection's object
/// identities. Any score edit — including one that changes an event's content
@ -790,7 +852,10 @@ fn resolve_time_anchor_inner(score: &Score, anchor: &TimeAnchor, depth: u8) -> O
}
}
fn apply_offset(base: TimePoint, offset: &AnchorOffset) -> Option<TimePoint> {
/// Applies an [`AnchorOffset`] to a resolved base time; `None` when the
/// offset's clock does not match the base. Shared with the constrained stage's
/// break-anchor resolution.
pub(crate) fn apply_offset(base: TimePoint, offset: &AnchorOffset) -> Option<TimePoint> {
match (base, offset) {
(base, AnchorOffset::Zero) => Some(base),
(TimePoint::Musical(position), AnchorOffset::Musical(duration)) => {
@ -1292,6 +1357,73 @@ mod tests {
);
}
#[test]
fn user_breaks_project_as_overrides_with_paired_decisions() {
use crate::engraving::{
DecisionSource, EngravingDecisionKind, OverrideKind, OverrideOrigin, OverridePriority,
OverrideTarget,
};
let mut score = valid_score(5);
let region_id = score.canvas.regions[0].id;
let anchor = TimeAnchor::WallClock {
time: WallClockTime(42),
};
let content = score.canvas.regions[0]
.content
.staff_based_mut()
.expect("valid_score is staff based");
content.user_system_breaks.push(anchor.clone());
content.user_page_breaks.push(anchor.clone());
// Deterministic across two runs.
let ir = to_logical(&score);
assert_eq!(ir.overrides, to_logical(&score).overrides);
// One override per break, in the pinned projected shape: the kind
// carries the anchor, the ScoreGraph target names the owning region,
// Soft binding, Internal origin.
assert_eq!(ir.overrides.len(), 2);
for projected in &ir.overrides {
assert_eq!(
projected.target,
OverrideTarget::ScoreGraph(TypedObjectId::Region(region_id))
);
assert_eq!(projected.priority, OverridePriority::Soft);
assert_eq!(projected.origin, OverrideOrigin::Internal);
}
assert!(ir.overrides.iter().any(|o| matches!(
&o.kind,
OverrideKind::SystemBreak { anchor: got } if *got == anchor
)));
assert!(ir.overrides.iter().any(|o| matches!(
&o.kind,
OverrideKind::PageBreak { anchor: got } if *got == anchor
)));
assert_ne!(ir.overrides[0].id, ir.overrides[1].id);
// Each applied override records a paired decision sourced to it
// (Chapter 7 §"Override Resolution").
for projected in &ir.overrides {
let kind = match &projected.kind {
OverrideKind::SystemBreak { .. } => EngravingDecisionKind::SystemBreak,
OverrideKind::PageBreak { .. } => EngravingDecisionKind::PageBreak,
other => panic!("unexpected projected override kind {other:?}"),
};
assert!(
ir.engraving_decisions.iter().any(|decision| decision.source
== DecisionSource::UserOverride(projected.id)
&& decision.kind == kind),
"no paired UserOverride decision for {projected:?}"
);
}
// The automatic per-region system-break decision is still present.
assert!(ir
.engraving_decisions
.iter()
.any(|decision| decision.source == DecisionSource::Automatic
&& decision.kind == EngravingDecisionKind::SystemBreak));
}
#[test]
fn spanning_object_uses_cross_region_collection() {
let mut score = valid_score_rich(5);

View File

@ -5,14 +5,16 @@
//! > all provenance preserved.
//!
//! [`round_trip`] runs the whole pipeline and asserts the contract every IR
//! stage must satisfy: the stub solver reports [`SolveStatus::Solved`] with all
//! hard constraints satisfied; the **complete** [`Provenance`] of every object
//! survives every stage unchanged; no two objects share a stable id; the stub
//! solver returns the input geometry verbatim; and the *set* of score-graph
//! sources recovered from the RenderIR is exactly the set laid out — a surjection
//! onto graph identity (one source may back several manifestations, each a
//! distinct layout object). This is the contract the testkit's layout harness
//! drives, now against the real crate.
//! stage must satisfy: the solver reports a renderable status (the stub, which
//! evaluates no constraints, honestly claims hard-constraint satisfaction only
//! for a constraint-free problem; a conformant tier must claim it outright);
//! the **complete** [`Provenance`] of every object survives every stage
//! unchanged; no two objects share a stable id; the stub solver returns the
//! input geometry verbatim; and the *set* of score-graph sources recovered from
//! the RenderIR is exactly the set laid out — a surjection onto graph identity
//! (one source may back several manifestations, each a distinct layout object).
//! This is the contract the testkit's layout harness drives, now against the
//! real crate.
use std::collections::{BTreeMap, BTreeSet};
@ -97,8 +99,8 @@ fn provenance_map<'a>(
/// completes without panic and **without losing provenance back-references**.
/// Specifically:
///
/// * the stub solver returns [`SolveStatus::Solved`] with all hard constraints
/// satisfied;
/// * the stub solver returns a renderable status, claiming hard-constraint
/// satisfaction exactly when no constraint is declared (it evaluates none);
/// * the complete [`Provenance`] of every object — `source`, `synthesis`,
/// `dependencies`, and `stable_id` — survives every stage unchanged (compared
/// as `stable_id -> Provenance` maps, so a dropped dependency or synthesis
@ -175,10 +177,21 @@ pub fn round_trip_with<S: ConstraintSolver>(score: &Score, solver: &S) -> RoundT
"the solver must return a renderable layout, got {:?}",
report.status
);
assert!(
report.satisfied_hard_constraints,
"the solver must satisfy all hard constraints"
);
if solver.tier() == SolverTier::Stub {
// The interface-only stub evaluates no constraints, so it may claim
// hard-constraint satisfaction only for a constraint-free problem —
// with any declared, an honest report claims none.
assert_eq!(
report.satisfied_hard_constraints,
constrained.constraints.is_empty(),
"the stub must claim satisfaction exactly when no constraint is declared"
);
} else {
assert!(
report.satisfied_hard_constraints,
"a conformant solver must satisfy all hard constraints"
);
}
// The Stub tier's geometry contract: it returns the input geometry *verbatim*
// — each resolved glyph's position is exactly its constrained baseline (Chapter
@ -404,8 +417,10 @@ mod tests {
.collect();
assert_eq!(manifestations.len(), 2);
assert_ne!(manifestations[0], manifestations[1]);
// The pipeline declares real constraints for this score, which the stub
// does not evaluate — renderable, but not exactly `Solved`.
let report = round_trip(&score);
assert_eq!(report.status, SolveStatus::Solved);
assert!(report.status.is_renderable());
}
/// Across a multi-region score, `to_logical` never emits two layout objects

View File

@ -9,7 +9,9 @@
//! a [`SolveReport`] with its full diagnostic surface (unsatisfied constraints,
//! warnings, a [`QualityMetricVector`], budget used, state) — and a
//! [`StubSolver`] that, per the QUICKSTART, "returns `SolveStatus::Solved` with
//! the input geometry verbatim."
//! the input geometry verbatim" — for a constraint-free problem; with
//! constraints declared it stays a renderable passthrough but claims no
//! satisfaction (see [`StubSolver`]).
//!
//! **Quality-metric *computation* is deliberately not implemented** (QUICKSTART:
//! "only the interface — don't implement quality metrics"): the
@ -273,11 +275,29 @@ pub enum InvalidationScope {
pub struct SpringSlotId(pub u128);
/// A constraint identifier referenced by [`SolveReport::unsatisfied_constraints`]
/// (Chapter 9: `ConstraintId`). The stub never reports any because it rejects
/// explicit constraints it cannot evaluate.
/// (Chapter 9: `ConstraintId`). The stub never reports any: it evaluates no
/// constraints, so it neither claims one satisfied nor names one unsatisfied.
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
pub struct ConstraintId(pub u128);
/// The strength a constraint binds the solver with (Chapter 9 §"Strength
/// Levels": `ConstraintStrength`). Constraints do not carry this in the IR —
/// the spec's [`crate::LayoutConstraint`] enum has no strength field — so it is
/// attached by rule via [`crate::LayoutConstraint::strength`].
#[derive(Copy, Clone, PartialEq, Debug)]
pub enum ConstraintStrength {
/// Hard constraint. The solver MUST satisfy it or return
/// [`SolveStatus::Unsatisfiable`], and MUST NOT treat it as if it were
/// `Preferred` for any reason, including quality optimization (Chapter 9
/// §"Strength Levels").
Required,
/// Soft constraint with an associated weight. The solver minimizes the
/// weighted violation when optimizing; an unhonoured preference is a
/// warning ([`SolverWarningKind::LargeSoftConstraintViolation`]), never an
/// `Unsatisfiable`.
Preferred { weight: f64 },
}
/// A declared invalidation (Chapter 9: `InvalidationSet`) over the invalidated
/// slots, bands, constraints, and glyphs.
#[derive(Clone, PartialEq, Eq, Debug)]
@ -384,6 +404,15 @@ pub trait ConstraintSolver: Send + Sync {
/// (Chapter 7 §7.3.2). A glyph whose metrics are not bundled, or a catalog hash
/// that does not match its glyphs, is a well-formedness failure reported as
/// [`SolveStatus::InternalError`] (never a panic).
///
/// **Declared constraints are not evaluated** ([`SolverTier::Stub`]), and the
/// report is honest about it in both directions: the solve stays renderable
/// (geometry passes through; unevaluated constraints are not a defect in the
/// *input*), but `satisfied_hard_constraints` is `false` and a warning names
/// the gap — the stub never claims satisfaction it did not check. Chapter 9
/// has no status for "renderable, constraints unevaluated", so the closest
/// non-claiming renderable status, [`SolveStatus::SolvedWithWarnings`], is
/// used (see DECISIONS.md).
pub struct StubSolver;
impl StubSolver {
@ -398,10 +427,31 @@ impl StubSolver {
.collect();
let metrics_available = all_available(names.iter().copied());
let catalog_valid = metrics_available && input.catalog == BravuraCatalog.identity(&names);
let well_formed = structural_valid && catalog_valid;
// This interface-only solver can preserve already-resolved geometry but
// does not evaluate explicit constraints. It must not claim those are
// satisfied merely because the input is structurally well formed.
let well_formed = structural_valid && catalog_valid && input.constraints.is_empty();
let unevaluated = input.constraints.len();
let status = if !well_formed {
SolveStatus::InternalError
} else if unevaluated > 0 {
SolveStatus::SolvedWithWarnings
} else {
SolveStatus::Solved
};
let warnings = if well_formed && unevaluated > 0 {
vec![SolverWarning {
kind: SolverWarningKind::UnusualLayoutDecision(format!(
"the interface-only stub solver evaluated none of the {unevaluated} \
declared constraint(s); satisfaction is not claimed"
)),
affected_objects: Vec::new(),
message: "declared constraints were not evaluated".to_owned(),
}]
} else {
Vec::new()
};
let glyphs: Vec<ResolvedGlyph> = if structural_valid {
input
@ -452,12 +502,10 @@ impl StubSolver {
.collect();
SolveReport {
status: if well_formed {
SolveStatus::Solved
} else {
SolveStatus::InternalError
},
satisfied_hard_constraints: well_formed,
status,
// Honest in both directions: false when the input is malformed *and*
// when constraints were declared but not evaluated.
satisfied_hard_constraints: well_formed && unevaluated == 0,
layout: ResolvedLayoutIR {
source: input.source,
pages,
@ -467,7 +515,7 @@ impl StubSolver {
catalog: input.catalog.clone(),
},
unsatisfied_constraints: Vec::new(),
warnings: Vec::new(),
warnings,
metric_vector: QualityMetricVector::unmeasured(),
// The stub does no iterative work; its deterministic budget use is zero.
budget_used: SolverBudgetUsed::default(),
@ -613,14 +661,14 @@ mod tests {
))
);
// A well-formed constraint reference validates — even though the stub
// solver still refuses to *evaluate* it (it cannot claim it satisfied).
// A well-formed constraint reference validates — and the stub solver
// still does not *evaluate* it: the solve stays renderable, but it does
// not claim the constraint satisfied.
input.constraints = vec![LayoutConstraint::NoCollision { a: real, b: real }];
assert!(input.validate().is_ok());
assert_eq!(
StubSolver.solve(&input, &SolverConfig::default()).status,
SolveStatus::InternalError
);
let report = StubSolver.solve(&input, &SolverConfig::default());
assert_eq!(report.status, SolveStatus::SolvedWithWarnings);
assert!(!report.satisfied_hard_constraints);
}
#[test]
@ -741,8 +789,15 @@ mod tests {
.constraints
.push(crate::LayoutConstraint::NoCollision { a: glyph, b: glyph });
let report = StubSolver.solve(&input, &SolverConfig::default());
assert_eq!(report.status, SolveStatus::InternalError);
// Unevaluated constraints are not a defect in the input, so the solve is
// renderable — but satisfaction is not claimed, and a warning names the gap.
assert_eq!(report.status, SolveStatus::SolvedWithWarnings);
assert!(report.status.is_renderable());
assert!(!report.satisfied_hard_constraints);
assert_eq!(report.warnings.len(), 1);
assert!(report.unsatisfied_constraints.is_empty());
// The geometry still passes through verbatim.
assert_eq!(report.layout.glyphs[0].position, input.glyphs[0].baseline);
}
#[test]

View File

@ -147,6 +147,16 @@ a faithfulness gap the graph convergence gate (criterion 1) would surface.
records each seeded spanner's event-anchored endpoints in `structures` (as it
already did for slurs/ties/beams), so a base spanner re-anchors through the same
rule as a created one rather than being left dangling.
- **A `CascadeDeleteTuplets` also drops decompositions that named the tuplet.** A
notated decomposition component records its tuplet by id (`NotatedComponent.tuplet`),
so once the cascade removes the tuplet structure, any attachment naming it would
dangle — `check_invariants` flags it as a cross-cutting reference that no longer
resolves (invariant 6). `materialize_graph_delete` therefore prunes, in the same
step, every decomposition attachment whose components reference a removed tuplet.
Those members are tombstoned in the same cascade, so the decomposition has nothing
left to describe. This is what lets the editor's atomic tuplet overwrite (a pencil
insert over a triplet member removes the whole triplet) leave an invariant-valid
graph even when a member carried an in-tuplet decomposition.
This consistency is what lets `graph_edit_session` create cross-cutting structures
and delete their endpoints, giving the Group-2 CRUD ops (and slur re-anchoring)
@ -327,6 +337,17 @@ cascade) is implemented faithfully; only the metric "nearest" is approximated.
**For the spec:** no change needed — this resolves once the graph mutation phase
tracks positions; recorded so the approximation is explicit.
*Push-3 update (2026-07): the four-key "nearest" metric is now implemented
(`nearest_live_event` + `containment_rank`, over the canonical ledger indices)
and drives the marker and graphic-gesture rows plus the slur/spanner
`Reanchored` reason; see "Re-anchoring rule-table completion" below. For
slurs/spanners the table itself prescribes surviving-endpoint collapse, so
`nearest_survivor` legitimately remains the endpoint-set minimum (proximity-
aware re-targeting beyond the endpoints stays the table's own "deferred
refinement"). Wall-clock distance in proportional regions remains
unimplemented — the occupancy index is metric-only — so a wall-clock referent
falls to the kind's declared failure action.*
### P11-C6 — time-model compatibility is computed when a graph is available
`ChangeRegionTimeModel` retains a `declared_incompatible` list for base-free
@ -409,3 +430,281 @@ conflict-id derivation, and the materialized-state bytes are testable now. This
is deterministic and unambiguous but **provisional**: when the Binary Format
companion lands, reconcile `encode.rs` and the per-type `CanonicalEncode` impls
with it. A failing cross-crate round-trip test is the trigger.
## Spec-compliance audit follow-up (2026-07, Push 1)
Four reduction-semantics fixes closing MUST-level gaps the six-agent spec audit
confirmed, plus one parity check. Each changes canonical effect bytes only in
the scenario it fixes; all determinism gates (permutation invariance, 10k fuzz,
migration equivalence) stay green.
- **Transpose skips tombstoned targets.** The catalog (§Transpose,
re-anchoring) says "tombstoned targets are skipped (the transpose applies
only to live pitches)"; the code refused the whole operation on any dead
target. Now: missing target → whole-op `TargetMissing` refusal (a dangling
reference is malformed authorship, unchanged); tombstoned targets are
skipped and the live remainder shifts (`Applied`); all-tombstoned
degenerates to `NoOp { TargetTombstoned }` (byte-identical to the old
behavior for that sub-case). The skip is not recorded as a repair: no
compensating change is performed on the skipped target.
- **Marker re-anchoring is a recorded repair.** Chapter 6 §Re-Anchoring:
"Re-anchoring actions MUST be recorded as RepairRecord entries in the
triggering operation's effect." The graph materializer re-pointed
event-anchored markers to their region start silently;
`materialize_graph_delete` now returns those re-anchors as
`RepairKind::Reanchored { …, reason: ExplicitFallback }` records
(`ExplicitFallback` because region-start is the documented P11-C5 stand-in
for the table's "nearest event in same staff instance" metric, which stays
deferred). Delete and undo effects carry them. Bookkeeping-only `reduce()`
cannot see markers (they are graph state), so the records appear only under
graph-aware reduction — same documented asymmetry class as base-only
regions.
- **System-derived counter collision check** (Chapter 5 §"System-Derived
Counter Collisions" — the audit's top reduction MUST gap). The reducer now
keeps a mint registry `(ObjectKind, counter) → (canonical inputs, minting
op)` seeded from the base graph (`SystemPromoted` voices via the `MUSCSVCE`
preimage, `SYSTEM_DERIVED` pitches via `canonical_pitch_bytes`, now public
in epiphany-core for exactly this) and checked by a pre-walk over the
canonical order before any effect is applied. Prospective mints are the
promotion pre-pass assignments plus `SYSTEM_DERIVED` pitches carried by
minting payloads (InsertEvent, InsertIdentifiedPitch). On the first
differing-inputs collision: a `SystemIdentifierCollision` anomaly is
recorded (both input sets retained), reduction does not continue past the
collision point, and the earlier occupant op is held too, so *neither*
input set occupies the collided counter. Held ops surface in `pending` under
the new `PendingReason::HaltedBySystemCollision { at }` (discriminant 4,
additive); transactions with a held member are wholly held, and causal
dependents hold behind them (`DependsOnPending`). Scope decisions, made
deliberately: (a) the pre-walk is conservative — a claimed mint participates
even if the op would fail an unrelated apply-time precondition, since two
input sets contending for one counter is a structural identity failure
regardless of which contender materializes; (b) a base occupant (owner =
None) cannot be evicted by this reduction and is left to diagnostic
recovery; (c) in-place content rewrites of a live system pitch (ModifyEvent /
ModifyIdentifiedPitch) are *not* treated as mints — that is Invariant-11
territory. **For Pass 12:** should reduction refuse content modification of
a `SYSTEM_DERIVED` pitch outright (it silently invalidates the id's
content-derivation)? Reader-side diagnostic-recovery gating on a bundle
whose state carries this anomaly is bundle/editor work, tracked with the
edit-barrier seam.
- **ResolveConflict meta-conflict names both resolvers.** The spec requires a
true conflict's `caused_by` to name "at least two" operations; the
meta-conflict for a differing later resolve had `winner == loser == self`
and one cause. Now: winner = the earlier resolver (its action stands),
loser = the later differing resolver, `caused_by` = both. `affected_objects`
stays empty — a conflict record has no `TypedObjectId` kind, so the
contested conflict cannot be named there (spec-side gap, Pass-12-adjacent).
The related asymmetries (a causally-later resolve cannot supersede; a
differing resolve against `Dismissed` reads `AlreadyApplied`) are spec-gray
and deliberately unchanged.
- **Base-free pitch-id freshness.** `insert_event` now refuses a carried pitch
id that already exists in canonical state under bookkeeping-only `reduce()`
too (the graph-aware precondition already did, with the same
`TargetTombstoned` reason), closing one base-free/graph-aware divergence
from the audit. Placed after the graph precondition so graph-aware effect
bytes are unchanged.
Reserved effect vocabulary (`OperationEffect::TombstonedTarget`,
`NoOpReason::SupersededByLaterOperation`, `PositionOutsideRegion`,
`PitchSpaceMismatch`, `ReanchorResult`) is now annotated as reserved at the
type definitions with the condition under which each becomes load-bearing.
## Re-anchoring rule-table completion (2026-07, Push 3)
The remaining referent rows of the re-anchoring rule table (core_spec §"The
Re-Anchoring Rule Table") are now implemented: **marker**, **cue event**,
**comment**, **analytical annotation**, and **graphic gesture** — together with
the four-key "nearest" total ordering (§"Total Ordering for Nearest") they
depend on. No discriminant was added or renumbered anywhere; every record uses
the ratified `RepairKind`/`ReanchorReason` vocabulary.
- **The four-key "nearest" is computed from the canonical ledger indices.**
`nearest_live_event` takes the strict lexicographic minimum of (containment
proximity, absolute rational time distance from the referent's resolved
position, forward-before-backward, ascending `EventId` — whose numeric order
*is* its canonical 16-byte order) over live events within the kind's declared
proximity bound. Proximity (`containment_rank`: same voice 0, staff instance
1, staff 2, region 3, canvas 4) reads `instance_voices`, the new
`instance_staff` map (seeded + maintained by `CreateStaffInstance`), and
`region_instances`; distance/direction read `voice_occupancy`. All are
base-free indices, so `reduce()` and `reduce_onto()` rank identically
wherever both can represent a scenario. Occupancy is metric-only, so
wall-clock distance (proportional regions) is a deferred refinement: a
wall-clock referent finds no candidate and falls to the kind's declared
failure action.
- **Where the rows run: the graph arm, one decision per row.** None of the five
kinds is creatable by an operation — they exist only in seeded base graphs —
so their rows run in `reanchor_event_referents`, called from
`materialize_graph_delete` (hence from both the `DeleteEvent` path and undo's
`materialize_graph_tombstones`). Each row's ledger record and graph mutation
are decided together, in canonical id order, keeping "the graph follows the
ledger" (above) intact; `reanchor_for_tombstone` explicitly skips these kinds
so no row is double-recorded. The referents are indexed in the same
`structures` map as slurs/ties/beams/spanners (markers by event anchor,
comments/annotations by event-anchored annotation anchors, gestures by
`Events`/`Range` event references, cues — keyed by their own `EventId` — by
their source lists); the four creatable kinds' create/modify/delete paths are
unchanged.
- **Marker: nearest event in the same staff instance replaces the Push-1
region-start fallback.** The re-anchored anchor keeps its offset (the
survivor shares the staff instance, hence the region and its offset
discipline), and the recorded reason names the *achieved* proximity rank
(`SameVoiceNearer` when the survivor shares the voice). On failure the marker
**orphans**: kept live in ledger and graph with `RepairKind::Orphaned`; the
graph anchor degrades to the containing region's start purely as reference
hygiene (invariant 10 rejects a dangling event anchor) — that form is no
longer presented as a re-anchor choice.
- **Cue event: plain-text cascade on any source deletion.** Deleting *any*
event in a live cue's `source` cascade-deletes the cue — ledger tombstone,
graph removal, `CascadeDeleted` repair — in the same reduction step. The
cascaded cue is itself a tombstoned event, so the full pass runs over its own
referents transitively (a cue-of-a-cue cascades along; ties/slurs on the cue
re-anchor through the ordinary ledger arm). The rationale-vs-action tension
for multi-source cues ("no source is meaningless" suggests truncate-while-
any-survives) is deliberately *not* resolved in code — proposed Pass-12 row.
- **Comment: orphan, with deterministic anchor hygiene.** The ledger records
`Orphaned` and the comment survives everywhere; because invariant 10 rejects
dangling anchors, the graph anchor degrades deterministically — an `Event`
anchor to `AnnotationAnchor::Region(containing region)`, a dead `Range`
endpoint to the region edge on its side (start → `Start`, end → `End`).
- **Analytical annotation: extent-preserving range reconstruction, else
orphan.** An event-anchored annotation whose event dies re-anchors to
`AnnotationAnchor::Range` with both endpoints as region-start `Musical`
offsets covering the event's exact span (positions are region-relative, so
the resolved extent is preserved); recorded as `Reanchored { to:
Region(region), reason: ExplicitFallback }` (the row is a declared fallback,
not a proximity choice). Range-anchored annotations get the same treatment
per dead endpoint (event position plus any musical anchor offset). A
wall-clock or indeterminate span is not expressible as a stored
region-relative range — the annotation orphans with the comment's anchor
hygiene; the expressibility gap is a proposed Pass-12 row.
- **Graphic gesture: nearest re-target / truncate / orphan.** `Events`
references to the dead event re-target to the nearest survivor in the same
staff instance (`Reanchored`, reason per achieved rank); with no candidate
the reference drops — `SpannerTruncated { removed_members: [event] }` while
references remain (the vocabulary's own definition, "lost members but enough
remained", fits), `Orphaned` when the list empties (gesture kept, user
content). `Range` anchoring "truncates": a dead endpoint moves to its region
edge (start → `Start`, end → `End`, offset zero) with `Reanchored { to:
Region, reason: ExplicitFallback }` — the least-surprising deterministic
reading of the table's one-word action; proposed Pass-12 row. `Free` is never
indexed (table: "no action").
- **Slur/spanner `Reanchored` reason is computed, not hardcoded.** The
surviving-endpoint collapse itself is unchanged, but the reason now names the
survivor's actual containment rank relative to the tombstoned endpoint
(`containment_rank` over the same ledger indices; `SameVoiceNearer` remains
the default when neither side has an indexed metric placement). Rank 4 (same
canvas) has no ratified `ReanchorReason` variant, so it is recorded as
`ExplicitFallback` rather than appending a discriminant — spec-vocabulary
question, proposed Pass-12 row.
- **Known asymmetries, kept deliberately.** (a) The five rows fire only under
graph-aware reduction — the same documented asymmetry class as the Push-1
marker fix (base-free `reduce()` cannot see these kinds at all). (b) The
tie/beam/slur/spanner *ledger* arm still does not run in the undo path
(pre-existing gap, unchanged by this slice; the graph-only rows *do* run
there via `materialize_graph_delete`).
## ResolveEquivocation + validation modes (2026-07, Push 3)
Two Push-3 items land together: the **ResolveEquivocation** meta-operation
(operation_catalog §"ResolveEquivocation", ratified this pass) and the
**validation-mode** seam (core_spec §"Validation Modes"). One payload
discriminant was appended (`OperationPayload::ResolveEquivocation` = 3); the
ratified 0..=2 and every other discriminant table are untouched. Canonical
reduction changes **only** for scenarios containing a valid resolve of an
equivocated slot — everything else reduces byte-identically (the extended
equivocation fuzz plus the unchanged `run_equivocation_fuzz` gate this).
- **Promotion is a set-level pre-pass, not a walk step.** The catalog's rule
("when the operation set holds an Equivocated slot for `target` and `chosen`
names one of its candidates, the slot reduces as if it had always been
Single") conditions on the *operation set*, so `Reducer::run` resolves it in
step 1b, before pending computation and ordering: among Single-slot,
non-quarantined resolves whose `(target, chosen)` is valid, the smallest
reduction tuple — the same total HLC order `canonical_reduction_order`
selects ready ops by — governs. The chosen candidate envelope then joins the
reducible set *at its own canonical position*: it flows through
`compute_pending` (its own causal gaps still hold it), transactions, voice
promotion, and the walk exactly like a native Single, and dependents that
were `DependsOnEquivocated` unblock. A resolved slot records no
`OperationSlotEquivocated` anomaly; losing candidates stay only in the
opset's diagnostic candidate store.
- **No `OperationSet` API extension was needed.** The promotion needs the
candidate *envelope*, and `OperationSet::candidate(hash)` already exposes
the retained diagnostic store (the CRDT property forbids dropping
candidates, so every hash in an `Equivocated` slot resolves). `accept`
transitions are untouched — the opset still holds the slot as `Equivocated`;
the promoted view lives only in the reducer (`promoted_singles`, consulted
by `env_of` so concurrency checks see the slot "as if always Single").
- **Resolve effects mirror `resolve_conflict`.** Governing resolve →
`Applied`; later resolve naming the same candidate → `NoOp(AlreadyApplied)`;
later *valid* resolve naming a differing candidate →
`StructuralFieldCollision` on `FieldPath("equivocation_resolution")` with
winner = governing, loser = later, both in `caused_by`, `affected_objects`
empty (a slot is not a `TypedObjectId`) → `Conflicted`. A resolve whose
target is absent, holds a Single slot, or whose `chosen` names no candidate
is a precondition no-op reusing `TargetMissing` — the named target/candidate
pair does not exist — rather than a new appended reason.
- **Deliberate single-pass simplifications (proposed Pass-12 rows).** (a) A
promoted candidate that is itself a `ResolveEquivocation` does not govern a
further promotion (no cascade/fixpoint; the catalog names none). (b) The
promoted candidate is not re-subjected to HLC-monotonicity segmentation
(quarantine detection runs over native singles first; "as if it had always
been Single" vs. the quarantine pass ordering is a spec question). (c) A
resolve held pending by its own causal gaps still governs promotion — the
set-level rule needs no walk position — while its recorded effect stays
subject to the ordinary pending rules.
- **v0 migration.** v0 predates the entry, so `V0OperationPayload` gains a
`ResolveEquivocation` variant carried **verbatim** — the same "v1-native,
round-trip by identity" treatment as the Group 14 kinds. `project_v1_to_v0`
stays total; `migrate_v0_envelope` maps it back by identity (deterministic,
trivially equivalence-preserving).
- **Golden locks added.** `operation_kind_wire_discriminants_are_golden` pins
all 24 `OperationKind` literals (0..=23),
`operation_payload_discriminants_are_golden` pins the payload union
(0..=3 incl. the appended variant), and
`resolve_equivocation_payload_encodes_target_then_hash` pins the 16+32-byte
layout — the append-only discipline is now enforced by test, not convention.
- **Validation modes: the reducer *is* replay mode.** `src/validate.rs` adds
`ValidationMode { Authoring, Replay }` and
`advisory_violations(kind, score)`. `reduce`/`reduce_onto` enforce exactly
the invariant preconditions in every context; authoring enforcement happens
in epiphany-editor-core, which runs `advisory_violations` against its
current materialized score *before minting* and refuses with a new
`EditorError::AdvisoryViolation` (no envelope enters the log). Canonical
reduction behavior and bytes are untouched by the mode machinery;
`AdvisoryViolation` is deliberately **non-canonical** (no encoding, no
discriminants — it never enters effects or state).
- **Advisory inventory (core_spec §6.10).** Implemented: InsertEvent /
ModifyEvent *duration-not-crossing-region-boundary* (the span straddles the
region's musical end bound — resolvable when the extent's end anchor is
region-start-anchored with a `Musical` offset, the same sound-but-incomplete
discipline as `Region::overlaps_in_time`), and CreateCrossCutting(Slur)
*not-spanning-a-region-boundary* (endpoint events resolve to different
regions). Blocked on the truncated data model, documented in the module
docs: InsertEvent *pitch-within-instrument-range* (`Instrument` carries no
range field; staged to the Binary Format companion) and the Slur rule's
"unless explicitly permitted by region configuration" (no such flag on
`Region`; spanning is treated as never permitted until it lands). A
wall-clock or symbolic region extent yields no musical bound, so the
boundary check passes vacuously there (deferred tempo/measure resolution,
P11-C5).
## Edit-barrier bridge: `OperationKindTag` decode (2026-07, Push 3)
- **`OperationKindTag` gains its decode mirror.** The tag had `CanonicalEncode`
only (the discriminant byte, plus the registry id's 16 big-endian bytes for
`Registered`); the edit-barrier blob codec (epiphany-layout-ir, the barrier
owner) needs the inverse to read `prohibited_operation_kinds` back out of a
manifest declaration. `CanonicalDecode` now decodes exactly the encoder's
image: variable width (1 byte, or 17 for `Registered`), length mismatches and
trailing bytes rejected, and an **unknown discriminant rejected** — via
`DecodeError::MalformedDomainTag`, the same unknown-discriminant rejection
`TypedObjectId::decode_canonical` already uses, so no new error variant and
no change to the determinism crate. No encoding changed and no discriminant
was appended; `operation_kind_tag_decode_mirrors_encode_exactly` /
`operation_kind_tag_decode_rejects_malformed_bytes` pin the contract.

View File

@ -194,6 +194,7 @@ fn pending_reason(reader: &mut Reader<'_>) -> Result<PendingReason> {
1 => Ok(PendingReason::DependsOnEquivocated { on: blocker }),
2 => Ok(PendingReason::DependsOnExcluded { on: blocker }),
3 => Ok(PendingReason::DependsOnPending { on: blocker }),
4 => Ok(PendingReason::HaltedBySystemCollision { at: blocker }),
tag => Err(MaterializedDecodeError::InvalidTag {
kind: "PendingReason",
tag,

View File

@ -36,6 +36,11 @@ pub enum OperationEffect {
Conflicted { conflict: ConflictId },
/// The target was tombstoned; preserved in the operation set, no graph
/// effect beyond the recorded effect.
///
/// Reserved: the current reducer expresses this outcome as
/// [`NoOpReason::TargetTombstoned`] instead; no reduction path produces
/// this variant yet. It stays in the vocabulary (and its discriminant
/// stays pinned) because the spec's effect table names it.
TombstonedTarget { target: TypedObjectId },
/// Reduces to no effect; the reason is recorded and is canonical.
NoOp { reason: NoOpReason },
@ -75,6 +80,11 @@ pub enum NoOpReason {
/// Duplicates a causally-prior operation's effect.
AlreadyApplied,
/// A later operation in canonical order subsumed this one's effect.
///
/// Reserved: no reduction path produces this yet — Pass 11 item 2.2
/// decided the winner carries `Conflicted` while the superseded loser
/// keeps `Applied`. Retagging losers with this reason is an open
/// disposition (see the item's rationale in the ratification log).
SupersededByLaterOperation { superseder: OperationId },
/// An invariant precondition satisfied at authoring time fails under
/// concurrent reduction; the intent is not preserved.
@ -127,8 +137,14 @@ pub enum PreconditionFailureReason {
EventDurationInvalid,
/// The target position falls outside the region declared by the envelope,
/// or the region does not exist.
///
/// Reserved: producing this requires resolving positions against region
/// extents, which is the deferred P11-C5 resolved-position machinery.
PositionOutsideRegion,
/// A pitch-space or tuning-context precondition failed.
///
/// Reserved: producing this requires the Chapter 4 tuning catalog
/// (pitch-space registry), which is deferred Track-C work.
PitchSpaceMismatch,
/// The operation targeted a voice that does not exist or is tombstoned.
VoiceMissing,
@ -322,6 +338,10 @@ impl CanonicalEncode for TupletCompensationKind {
/// The result of the re-anchor function for one referencing object
/// (Chapter 6 §6.5). The reduction maps each result to a [`RepairKind`] (or a
/// conflict) on the triggering operation's effect.
///
/// Reserved: the current reducer constructs [`RepairKind`] values directly
/// rather than routing through this intermediate; it becomes load-bearing when
/// the full "nearest surviving anchor" ordering (P11-C5) lands.
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum ReanchorResult {
/// The reference is replaced with a reference to a new target.

View File

@ -16,7 +16,7 @@
//! foremost `stamp.id == id`.
use epiphany_core::{OperationId, TransactionId};
use epiphany_determinism::{CanonicalEncode, DomainTag, Preimage};
use epiphany_determinism::{CanonicalDecode, CanonicalEncode, DomainTag, Preimage};
use crate::causal::CausalContext;
use crate::encode::{push_canon, push_tag};
@ -106,6 +106,24 @@ impl CanonicalEncode for OperationEnvelope {
}
}
/// Reads the [`OperationId`] a canonically encoded envelope declares from its
/// **leading 16 bytes** alone, without decoding the rest of the envelope.
///
/// The canonical envelope encoding leads with the id
/// ([`OperationEnvelope::encode_canonical`] pushes `id` first, and an
/// `OperationId`'s canonical form is exactly 16 big-endian bytes), so this is a
/// total, allocation-free peek. Returns `None` when fewer than 16 bytes are
/// present. It deliberately performs **no** validation of the remaining bytes —
/// full decoding stays the caller's job — which is exactly the contract the
/// bundle's operation index (Chapter 8 §"The Operation Index") needs: the
/// bundle layer keys index entries on the raw 16 id bytes without ever
/// interpreting an envelope, and this helper is the one place the ops layer
/// vouches that those bytes really are the id.
pub fn peek_operation_id(envelope_bytes: &[u8]) -> Option<OperationId> {
let head = envelope_bytes.get(..16)?;
OperationId::decode_canonical(head).ok()
}
/// Why an envelope failed the well-formedness check (Chapter 6 §6.4). A
/// rejected envelope is recorded in the local diagnostic log but does not enter
/// the canonical operation set.
@ -247,6 +265,21 @@ mod tests {
);
}
#[test]
fn peek_operation_id_reads_the_leading_canonical_bytes() {
let id = OperationId::new(ReplicaId(0x0102_0304_0506_0708), 0x1122_3344_5566_7788);
let e = env(id, id);
let bytes = e.to_canonical_bytes();
// The canonical envelope encoding truly *leads* with the id's 16
// canonical bytes — the invariant `peek_operation_id` (and the bundle's
// operation index built on it) depends on.
assert_eq!(&bytes[..16], &id.canonical_bytes());
assert_eq!(peek_operation_id(&bytes), Some(id));
// A short buffer has no id to peek.
assert_eq!(peek_operation_id(&bytes[..15]), None);
assert_eq!(peek_operation_id(&[]), None);
}
#[test]
fn envelope_hash_changes_with_payload_bytes() {
let mut a = env(

View File

@ -40,7 +40,7 @@ use crate::payload::{
use crate::stamp::{HybridLogicalClock, OperationStamp};
use crate::support::AuthorId;
use crate::valuegen;
use crate::IntegrityAnomalyKind;
use crate::{EnvelopeHash, IntegrityAnomalyKind, OperationEffect};
/// Number of replicas the generator draws authors from.
const REPLICAS: u64 = 3;
@ -410,6 +410,137 @@ pub fn run_equivocation_fuzz(iters: u64, seed: u64) {
}
}
/// Runs `iters` equivocation-*resolution* iterations from `seed` (the
/// `ResolveEquivocation` sibling of [`run_equivocation_fuzz`]). Each iteration
/// builds two distinct canonical envelopes under one `OperationId` plus a
/// `ResolveEquivocation`, embeds them in random noise, and asserts across four
/// random acceptance orders:
///
/// * with a **valid** resolve (`chosen` names a real candidate): the resolved
/// slot contributes an effect at its own id, no `OperationSlotEquivocated`
/// anomaly is recorded for it, the resolve itself applies, and every
/// permutation reduces to byte-identical [`crate::MaterializedState`];
/// * with an **invalid** resolve (`chosen` names no candidate): behavior is
/// exactly today's unresolved equivocation — the slot contributes nothing,
/// the anomaly is recorded, the resolve is a precondition no-op — and the
/// permutation invariance still holds.
pub fn run_equivocation_resolution_fuzz(iters: u64, seed: u64) {
let mut rng = SplitMix64::new(seed);
for _ in 0..iters {
let id = OperationId::new(ReplicaId(1 + rng.below(REPLICAS)), rng.below(5));
let mk = |rng: &mut SplitMix64, spelling: u8| OperationEnvelope {
id,
author: AuthorId(0),
stamp: OperationStamp::new(
HybridLogicalClock::new(epiphany_core::WallClockTime(rng.below(100) as i64), 0),
id,
),
causal_context: CausalContext::new(),
transaction: None,
payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
pitch: pitch(0),
spelling: valuegen::spelling(spelling),
})),
};
let a = mk(&mut rng, 1);
let b = mk(&mut rng, 2); // distinct canonical bytes (different spelling)
debug_assert_ne!(a.envelope_hash(), b.envelope_hash());
// Valid two-thirds of the time; otherwise a hash naming no candidate.
let valid = !rng.chance(3);
let chosen = if !valid {
EnvelopeHash([0xEE; 32])
} else if rng.chance(2) {
a.envelope_hash()
} else {
b.envelope_hash()
};
// The resolve lives on a replica the noise generator never draws
// (noise uses 1..=REPLICAS), so its own slot can neither equivocate
// nor land in a quarantined segment.
let resolve_id = OperationId::new(ReplicaId(REPLICAS + 2), 0);
let resolve = OperationEnvelope {
id: resolve_id,
author: AuthorId(0),
stamp: OperationStamp::new(
HybridLogicalClock::new(epiphany_core::WallClockTime(rng.below(100) as i64), 0),
resolve_id,
),
causal_context: CausalContext::new(),
transaction: None,
payload: OperationPayload::ResolveEquivocation(
crate::payload::ResolveEquivocationPayload { target: id, chosen },
),
};
let noise_count = rng.below(4) as usize;
let noise = gen_envelope_set(&mut rng, noise_count)
.into_iter()
.filter(|e| e.id != id)
.collect::<Vec<_>>();
let mut items = vec![a.clone(), b.clone(), resolve.clone()];
items.extend(noise);
let mut reference: Option<Vec<u8>> = None;
for _ in 0..4 {
shuffle(&mut items, &mut rng);
let mut set = OperationSet::new();
set.accept_all(items.iter().cloned());
let state = set.reduce();
let slot_contributes = state.effects.iter().any(|(e, _)| *e == id);
let anomalous = state.anomalies.iter().any(|an| matches!(
an.kind,
IntegrityAnomalyKind::OperationSlotEquivocated { operation_id } if operation_id == id
));
let resolve_effect = state
.effects
.iter()
.find(|(e, _)| *e == resolve_id)
.map(|(_, eff)| eff);
if valid {
assert!(
slot_contributes,
"a resolved slot must contribute its chosen candidate's effect"
);
assert!(
!anomalous,
"a resolved slot must record no OperationSlotEquivocated anomaly"
);
assert_eq!(
resolve_effect,
Some(&OperationEffect::Applied),
"the governing resolve must apply"
);
} else {
assert!(
!slot_contributes,
"an unresolved equivocated slot must contribute nothing"
);
assert!(
anomalous,
"an unresolved equivocated slot must record its anomaly"
);
assert!(
matches!(resolve_effect, Some(OperationEffect::NoOp { .. })),
"an invalid resolve must be a precondition no-op, got {resolve_effect:?}"
);
}
let bytes = state.canonical_bytes();
match &reference {
None => reference = Some(bytes),
Some(reference) => assert_eq!(
&bytes, reference,
"equivocation resolution is not permutation-invariant"
),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -424,6 +555,11 @@ mod tests {
run_equivocation_fuzz(500, 0x1234_5678);
}
#[test]
fn equivocation_resolution_smoke() {
run_equivocation_resolution_fuzz(500, 0x9E50_1AE5);
}
#[test]
fn generator_is_deterministic() {
let mut a = SplitMix64::new(99);

View File

@ -92,6 +92,7 @@ mod slot;
mod stamp;
mod support;
mod v0;
mod validate;
pub mod valuegen;
pub mod fuzz;
@ -109,7 +110,9 @@ pub use effect::{
NoOpReason, OperationEffect, PreconditionFailureReason, ReanchorReason, ReanchorResult,
RepairKind, RepairRecord, TupletCompensationKind,
};
pub use envelope::{well_formed, EnvelopeHash, OperationEnvelope, WellFormednessError};
pub use envelope::{
peek_operation_id, well_formed, EnvelopeHash, OperationEnvelope, WellFormednessError,
};
pub use migrate::{migrate_v0_envelope, project_v1_to_v0, MigrationError};
pub use opset::{AcceptOutcome, OperationSet};
pub use payload::{
@ -117,9 +120,9 @@ pub use payload::{
CreateVoiceOp, CrossCuttingValue, DeleteCrossCuttingOp, DeleteEventOp, DeleteIdentifiedPitchOp,
DeleteRegionOp, DeleteStaffInstanceOp, DeleteVoiceOp, InsertEventOp, InsertIdentifiedPitchOp,
ModifyCrossCuttingOp, ModifyEventOp, ModifyIdentifiedPitchOp, OperationKind, OperationKindTag,
OperationPayload, PositionRemapping, ResolveConflictPayload, RespellPitchOp, SetMetadataOp,
SetMetricGridOp, SetUserPageBreakOp, SetUserSystemBreakOp, TransactionCategory,
TransactionDescriptor, TransposeOp, TupletCompensation,
OperationPayload, PositionRemapping, ResolveConflictPayload, ResolveEquivocationPayload,
RespellPitchOp, SetMetadataOp, SetMetricGridOp, SetUserPageBreakOp, SetUserSystemBreakOp,
TransactionCategory, TransactionDescriptor, TransposeOp, TupletCompensation,
};
pub use reduce::{
canonical_reduction_order, GraphMaterialization, MaterializedState, ObjectState, PendingReason,
@ -135,5 +138,6 @@ pub use support::{
pub use undo::{UndoPolicy, UndoTransactionPayload};
pub use v0::V0OperationEnvelope;
pub use validate::{advisory_violations, AdvisoryViolation, ValidationMode};
mod undo;

View File

@ -95,6 +95,8 @@ fn project_payload(p: &OperationPayload) -> V0OperationPayload {
OperationPayload::Primitive(kind) => V0OperationPayload::Primitive(project_kind(kind)),
OperationPayload::ResolveConflict(rc) => V0OperationPayload::ResolveConflict(*rc),
OperationPayload::UndoTransaction(u) => V0OperationPayload::UndoTransaction(*u),
// v1-native (no v0 predecessor): projected verbatim, like Group 14.
OperationPayload::ResolveEquivocation(re) => V0OperationPayload::ResolveEquivocation(*re),
}
}
@ -231,6 +233,9 @@ fn migrate_payload(
}
V0OperationPayload::ResolveConflict(rc) => OperationPayload::ResolveConflict(*rc),
V0OperationPayload::UndoTransaction(u) => OperationPayload::UndoTransaction(*u),
// v1-native: round-trips by identity (deterministic and trivially
// equivalence-preserving; no context needed).
V0OperationPayload::ResolveEquivocation(re) => OperationPayload::ResolveEquivocation(*re),
})
}

View File

@ -33,18 +33,19 @@
use epiphany_core::{
Beam, CanonicalValue, Event, EventDuration, EventId, EventPosition, IdentifiedPitch,
MetricGrid, MusicalDuration, MusicalPosition, Pitch, PitchId, PitchSpelling, Region, RegionId,
RegionTimeModel, Rest, ScoreMetadata, Slur, Spanner, StaffInstance, StaffInstanceId, Tie,
TimeAnchor, TransactionId, TupletId, TypedObjectId, Voice, VoiceId,
MetricGrid, MusicalDuration, MusicalPosition, OperationId, Pitch, PitchId, PitchSpelling,
Region, RegionId, RegionTimeModel, Rest, ScoreMetadata, Slur, Spanner, StaffInstance,
StaffInstanceId, Tie, TimeAnchor, TransactionId, TupletId, TypedObjectId, Voice, VoiceId,
};
use epiphany_determinism::{sorted_canonical, CanonicalEncode};
use epiphany_determinism::{sorted_canonical, CanonicalDecode, CanonicalEncode, DecodeError};
use crate::conflict::{ConflictId, ResolutionAction};
use crate::encode::{push_canon, push_lp_bytes, push_seq, push_str, push_tag, push_u8_bool};
use crate::envelope::EnvelopeHash;
use crate::support::OperationKindRegistryId;
use crate::undo::UndoTransactionPayload;
/// The full payload of an operation envelope: a primitive, or one of the two
/// The full payload of an operation envelope: a primitive, or one of the
/// meta-operations (Chapter 6 §"Operation Envelopes").
// v1 payloads carry whole graph values, so the `Primitive` variant is
// intentionally larger than the meta-operations — the durability the catalog
@ -60,6 +61,10 @@ pub enum OperationPayload {
/// A meta-operation that compensates for a previously-committed
/// transaction; the realization of "undo".
UndoTransaction(UndoTransactionPayload),
/// A meta-operation that resolves an equivocated operation slot by naming
/// the chosen candidate envelope (operation_catalog
/// §"ResolveEquivocation").
ResolveEquivocation(ResolveEquivocationPayload),
}
impl OperationPayload {
@ -68,6 +73,8 @@ impl OperationPayload {
OperationPayload::Primitive(_) => 0,
OperationPayload::ResolveConflict(_) => 1,
OperationPayload::UndoTransaction(_) => 2,
// Appended (ResolveEquivocation); the ratified 0..=2 stay stable.
OperationPayload::ResolveEquivocation(_) => 3,
}
}
}
@ -79,6 +86,7 @@ impl CanonicalEncode for OperationPayload {
OperationPayload::Primitive(k) => k.encode_canonical(out),
OperationPayload::ResolveConflict(p) => p.encode_canonical(out),
OperationPayload::UndoTransaction(p) => p.encode_canonical(out),
OperationPayload::ResolveEquivocation(p) => p.encode_canonical(out),
}
}
}
@ -318,6 +326,63 @@ impl CanonicalEncode for OperationKindTag {
}
}
impl CanonicalDecode for OperationKindTag {
/// Decodes exactly the canonical form [`CanonicalEncode`] produces: the
/// discriminant byte, plus — for [`OperationKindTag::Registered`] only —
/// the registry id's 16 big-endian bytes. Variable-width, so the input
/// length must match the decoded variant exactly (trailing bytes are an
/// error). An unknown discriminant is rejected, never normalized —
/// [`DecodeError::MalformedDomainTag`], the same unknown-discriminant
/// rejection [`TypedObjectId::decode_canonical`] uses.
fn decode_canonical(bytes: &[u8]) -> Result<Self, DecodeError> {
let (&tag, rest) = bytes.split_first().ok_or(DecodeError::UnexpectedLength {
expected: 1,
actual: 0,
})?;
if tag == 16 {
let arr: [u8; 16] = rest.try_into().map_err(|_| DecodeError::UnexpectedLength {
expected: 17,
actual: bytes.len(),
})?;
return Ok(OperationKindTag::Registered(OperationKindRegistryId(
u128::from_be_bytes(arr),
)));
}
if !rest.is_empty() {
return Err(DecodeError::UnexpectedLength {
expected: 1,
actual: bytes.len(),
});
}
Ok(match tag {
0 => OperationKindTag::InsertEvent,
1 => OperationKindTag::DeleteEvent,
2 => OperationKindTag::ModifyEvent,
3 => OperationKindTag::RespellPitch,
4 => OperationKindTag::Transpose,
5 => OperationKindTag::CreateCrossCutting,
6 => OperationKindTag::DeleteCrossCutting,
7 => OperationKindTag::ModifyCrossCutting,
8 => OperationKindTag::ChangeRegionTimeModel,
9 => OperationKindTag::InsertRegion,
10 => OperationKindTag::DeleteRegion,
11 => OperationKindTag::InsertStaffInstance,
12 => OperationKindTag::DeleteStaffInstance,
13 => OperationKindTag::SetUserSystemBreak,
14 => OperationKindTag::SetUserPageBreak,
15 => OperationKindTag::DeclareTransaction,
17 => OperationKindTag::InsertIdentifiedPitch,
18 => OperationKindTag::DeleteIdentifiedPitch,
19 => OperationKindTag::ModifyIdentifiedPitch,
20 => OperationKindTag::CreateVoice,
21 => OperationKindTag::DeleteVoice,
22 => OperationKindTag::SetMetadata,
23 => OperationKindTag::SetMetricGrid,
_ => return Err(DecodeError::MalformedDomainTag),
})
}
}
// --- Representative operation payloads (Chapter 6 §6.10). --------------------
/// Insert an event into a voice (Chapter 6 §6.10 InsertEvent). Carries the full
@ -731,6 +796,25 @@ impl CanonicalEncode for ResolveConflictPayload {
}
}
/// The payload of an equivocation-resolution meta-operation (operation_catalog
/// §"ResolveEquivocation"): the equivocated slot and the candidate envelope (by
/// canonical-bytes hash) that shall stand. Value-complete.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub struct ResolveEquivocationPayload {
/// The `OperationId` of the equivocated slot to resolve.
pub target: OperationId,
/// The [`EnvelopeHash`] of the candidate envelope that shall stand.
pub chosen: EnvelopeHash,
}
impl CanonicalEncode for ResolveEquivocationPayload {
fn encode_canonical(&self, out: &mut Vec<u8>) {
// Catalog: `target` (16 canonical bytes), then `chosen` (32 bytes).
push_canon(out, &self.target);
push_canon(out, &self.chosen);
}
}
// --- Group 1 (M2): event & pitch leaf-field ops (Chapter 6 §6.10). -----------
/// Overwrite a live event's value (Chapter 6 §6.10 ModifyEvent). Carries the
@ -1053,6 +1137,247 @@ mod tests {
use super::*;
use epiphany_core::{ReplicaId, SlurId};
#[test]
fn operation_kind_wire_discriminants_are_golden() {
// GOLDEN LOCK: the discriminant byte leads every canonically-encoded
// primitive payload (operation_catalog §"Value-Typed Payloads"), so the
// literal values are normative wire facts. Encodings are append-only:
// new kinds append past 23; the values below never change.
use crate::valuegen;
use epiphany_core::{MusicalDuration, MusicalPosition, StaffId};
let r = ReplicaId(1);
let event_a = EventId::new(r, 1);
let event_b = EventId::new(r, 2);
let pitch = PitchId::new(r, 3);
let region = RegionId::new(r, 4);
let staff = StaffId::new(r, 5);
let instance = StaffInstanceId::new(r, 6);
let voice = VoiceId::new(r, 7);
let slur_id = SlurId::new(r, 8);
let event_value = || {
valuegen::insert_event_value(
event_a,
voice,
MusicalPosition::origin(),
MusicalDuration::whole(),
&[],
)
};
let slur_value = || CrossCuttingValue::Slur(valuegen::slur(slur_id, event_a, event_b));
let anchor = || valuegen::region_start_anchor(region, MusicalPosition::origin());
let table: [(OperationKind, u8); 24] = [
(
OperationKind::InsertEvent(InsertEventOp {
staff_instance: instance,
event: event_value(),
}),
0,
),
(
OperationKind::DeleteEvent(DeleteEventOp {
event: event_a,
tuplet_compensation: TupletCompensation::NotInTuplet,
}),
1,
),
(
OperationKind::RespellPitch(RespellPitchOp {
pitch,
spelling: valuegen::spelling(1),
}),
2,
),
(
OperationKind::CreateCrossCutting(CreateCrossCuttingOp {
structure: slur_value(),
}),
3,
),
(
OperationKind::ChangeRegionTimeModel(ChangeRegionTimeModelOp {
region,
new_time_model: valuegen::metric_model(),
declared_incompatible: vec![],
remapping: PositionRemapping::PreserveTime,
}),
4,
),
(
OperationKind::SetUserSystemBreak(SetUserSystemBreakOp {
region,
anchor: anchor(),
present: true,
}),
5,
),
(
OperationKind::DeclareTransaction(TransactionDescriptor {
id: TransactionId::new(r, 9),
label: String::new(),
category: None,
}),
6,
),
(
OperationKind::Registered(OperationKindRegistryId(0), vec![]),
7,
),
(
OperationKind::ModifyEvent(ModifyEventOp {
event: event_value(),
}),
8,
),
(
OperationKind::Transpose(TransposeOp {
targets: vec![pitch],
chromatic_steps: 0,
}),
9,
),
(
OperationKind::InsertIdentifiedPitch(InsertIdentifiedPitchOp {
event: event_a,
pitch: valuegen::identified_pitch(pitch),
}),
10,
),
(
OperationKind::DeleteIdentifiedPitch(DeleteIdentifiedPitchOp { pitch }),
11,
),
(
OperationKind::ModifyIdentifiedPitch(ModifyIdentifiedPitchOp {
pitch,
value: valuegen::pitch_value(),
}),
12,
),
(
OperationKind::DeleteCrossCutting(DeleteCrossCuttingOp {
structure: TypedObjectId::Slur(slur_id),
}),
13,
),
(
OperationKind::ModifyCrossCutting(ModifyCrossCuttingOp {
structure: slur_value(),
}),
14,
),
(
OperationKind::CreateRegion(CreateRegionOp {
region: valuegen::region(region),
}),
15,
),
(OperationKind::DeleteRegion(DeleteRegionOp { region }), 16),
(
OperationKind::CreateStaffInstance(CreateStaffInstanceOp {
region,
instance: valuegen::staff_instance(instance, staff),
}),
17,
),
(
OperationKind::DeleteStaffInstance(DeleteStaffInstanceOp {
staff_instance: instance,
}),
18,
),
(
OperationKind::CreateVoice(CreateVoiceOp {
staff_instance: instance,
voice: valuegen::voice(voice),
}),
19,
),
(OperationKind::DeleteVoice(DeleteVoiceOp { voice }), 20),
(
OperationKind::SetMetadata(SetMetadataOp {
metadata: valuegen::score_metadata(0),
}),
21,
),
(
OperationKind::SetMetricGrid(SetMetricGridOp { region, grid: None }),
22,
),
(
OperationKind::SetUserPageBreak(SetUserPageBreakOp {
region,
anchor: anchor(),
present: true,
}),
23,
),
];
for (kind, expected) in &table {
assert_eq!(
kind.discriminant(),
*expected,
"wire discriminant for {:?} moved — canonical encodings are append-only",
kind.tag(),
);
// The discriminant byte truly leads the canonical encoding.
assert_eq!(kind.to_canonical_bytes()[0], *expected);
}
}
#[test]
fn operation_payload_discriminants_are_golden() {
// GOLDEN LOCK: the payload-union discriminant byte leads every
// canonically-encoded envelope payload. 0..=2 are the ratified v1
// values; 3 (ResolveEquivocation) is appended by the catalog entry
// §"ResolveEquivocation". Append-only; never renumber.
use crate::undo::UndoPolicy;
use epiphany_core::OperationId;
let r = ReplicaId(1);
let primitive = OperationPayload::Primitive(OperationKind::DeleteEvent(DeleteEventOp {
event: EventId::new(r, 1),
tuplet_compensation: TupletCompensation::NotInTuplet,
}));
let resolve_conflict = OperationPayload::ResolveConflict(ResolveConflictPayload {
target: ConflictId(0),
action: ResolutionAction::Dismiss,
});
let undo = OperationPayload::UndoTransaction(UndoTransactionPayload {
target: TransactionId::new(r, 2),
policy: UndoPolicy::BestEffort,
});
let resolve_equivocation =
OperationPayload::ResolveEquivocation(ResolveEquivocationPayload {
target: OperationId::new(r, 3),
chosen: EnvelopeHash([0; 32]),
});
for (payload, expected) in [
(&primitive, 0u8),
(&resolve_conflict, 1),
(&undo, 2),
(&resolve_equivocation, 3),
] {
assert_eq!(payload.discriminant(), expected);
assert_eq!(payload.to_canonical_bytes()[0], expected);
}
}
#[test]
fn resolve_equivocation_payload_encodes_target_then_hash() {
// Catalog §"ResolveEquivocation": the canonical encoding is exactly the
// target's 16 canonical bytes followed by the 32 hash bytes.
use epiphany_core::OperationId;
let target = OperationId::new(ReplicaId(0x0102_0304_0506_0708), 0x1122_3344_5566_7788);
let chosen = EnvelopeHash([0xAB; 32]);
let p = ResolveEquivocationPayload { target, chosen };
let bytes = p.to_canonical_bytes();
assert_eq!(bytes.len(), 48);
assert_eq!(&bytes[..16], &target.canonical_bytes());
assert_eq!(&bytes[16..], &[0xAB; 32]);
}
#[test]
fn transaction_category_discriminants_are_golden() {
// RATIFIED by Pass 11 (item 2.4, req:semops:transaction-category): the
@ -1113,6 +1438,48 @@ mod tests {
assert_eq!(encoded.len(), tags.len());
}
#[test]
fn operation_kind_tag_decode_mirrors_encode_exactly() {
// Every non-registered variant round-trips through its 1-byte form, and
// the registered variant through its 17-byte (tag + registry id) form.
let mut tags: Vec<OperationKindTag> = (0u8..24)
.filter(|d| *d != 16)
.map(|d| OperationKindTag::decode_canonical(&[d]).expect("known discriminant"))
.collect();
tags.push(OperationKindTag::Registered(OperationKindRegistryId(
0x0102_0304_0506_0708_090A_0B0C_0D0E_0F10,
)));
assert_eq!(tags.len(), 24, "the full v1 tag vocabulary");
for tag in tags {
let bytes = tag.to_canonical_bytes();
let decoded = OperationKindTag::decode_canonical(&bytes).expect("round-trips");
assert_eq!(decoded, tag);
assert_eq!(
decoded.to_canonical_bytes(),
bytes,
"re-encode is byte-identical"
);
}
}
#[test]
fn operation_kind_tag_decode_rejects_malformed_bytes() {
use epiphany_determinism::DecodeError;
// Unknown discriminant (24 is one past the v1 vocabulary): rejected,
// never normalized.
assert_eq!(
OperationKindTag::decode_canonical(&[24]),
Err(DecodeError::MalformedDomainTag)
);
// Empty input.
assert!(OperationKindTag::decode_canonical(&[]).is_err());
// Trailing byte after a payload-less tag.
assert!(OperationKindTag::decode_canonical(&[0, 0]).is_err());
// A truncated (and an oversized) Registered payload.
assert!(OperationKindTag::decode_canonical(&[16; 16]).is_err());
assert!(OperationKindTag::decode_canonical(&[16; 19]).is_err());
}
#[test]
fn reassign_remapping_is_order_independent() {
let e1 = EventId::new(ReplicaId(1), 1);

File diff suppressed because it is too large Load Diff

View File

@ -22,7 +22,7 @@ use epiphany_core::{
};
use epiphany_determinism::ContentHash;
use crate::payload::{ResolveConflictPayload, TransactionDescriptor};
use crate::payload::{ResolveConflictPayload, ResolveEquivocationPayload, TransactionDescriptor};
use crate::support::OperationKindRegistryId;
use crate::undo::UndoTransactionPayload;
use crate::OperationEnvelope;
@ -49,6 +49,10 @@ pub enum V0OperationPayload {
ResolveConflict(ResolveConflictPayload),
/// Value-complete in v0 already; unchanged in v1.
UndoTransaction(UndoTransactionPayload),
/// v1-native (no identifier-only v0 predecessor existed — v0 predates the
/// catalog's equivocation-resolution entry); carried verbatim so the
/// migration round-trips it by identity, like the Group 14 kinds below.
ResolveEquivocation(ResolveEquivocationPayload),
}
/// Frozen v0 primitive kinds (the representative §6.10 set).

View File

@ -0,0 +1,451 @@
//! Validation modes and advisory-precondition checking (Chapter 6
//! §"Validation Modes", label `sec:semops:validation`).
//!
//! The spec distinguishes two precondition-checking modes:
//!
//! * **Authoring mode** — interactive edits. *All* preconditions are enforced:
//! invariant preconditions (which preserve graph invariants) and advisory
//! preconditions (user-intent constraints, range checks, style policy).
//! * **Replay mode** — replaying historical operations or applying remote
//! operations under reduction. Only invariant preconditions are enforced;
//! advisory preconditions MAY fail silently, since they represent the
//! authoring replica's local policy at the moment of authoring, not
//! invariants of the canonical state.
//!
//! ## Where each mode lives
//!
//! [`crate::OperationSet::reduce`] and [`crate::OperationSet::reduce_onto`]
//! **are** replay mode: the reducer enforces exactly the invariant
//! preconditions and nothing else, in every context. Authoring-mode
//! enforcement happens **before an envelope is minted** — the authoring layer
//! (epiphany-editor-core) runs [`advisory_violations`] against its current
//! materialized score and refuses to mint on any violation. Canonical
//! reduction behavior and canonical bytes are therefore **untouched** by the
//! mode machinery: an envelope that exists reduces identically whether its
//! author checked advisories or not (the spec's replay-parity requirement).
//!
//! ## The advisory inventory (core spec §6.10)
//!
//! The spec declares advisory preconditions for two of the implemented K0
//! operations (every other implemented kind's precondition bucket is entirely
//! invariant):
//!
//! * **InsertEvent** — (a) for pitched events, every pitch is within the
//! instrument's declared range, if any; (b) the event's duration does not
//! extend past a region boundary in a way that would require splitting.
//! * **CreateCrossCutting (Slur case)** — the slur does not span a region
//! boundary, unless explicitly permitted by region configuration.
//!
//! `ModifyEvent` carries the full replacement event value, so check (b)
//! applies to it identically (the replacement's span must not straddle the
//! region boundary any more than an inserted one may).
//!
//! ### Implemented here
//!
//! * InsertEvent / ModifyEvent duration-not-crossing-region-boundary
//! ([`AdvisoryViolation::DurationCrossesRegionBoundary`]), for regions whose
//! musical end bound is resolvable (see below).
//! * CreateCrossCutting(Slur) not-spanning-a-region-boundary
//! ([`AdvisoryViolation::SlurSpansRegionBoundary`]): the slur's endpoint
//! events resolve to different regions.
//!
//! ### Documented gaps (blocked on the truncated data model)
//!
//! * **InsertEvent pitch-within-instrument-range**: `epiphany_core::Instrument`
//! carries only `{ id, name }` — it has no declared range field. The
//! data-model completion is staged to the Binary Format companion; until the
//! field exists there is nothing to check against ("if any" in the spec text
//! makes the absent-range case a vacuous pass, which is exactly what this
//! module does by omission).
//! * **Slur spanning "explicitly permitted by region configuration"**:
//! `epiphany_core::Region` has no such configuration flag. The check treats
//! spanning as never permitted; when the flag lands, it suppresses the
//! violation.
//! * **Region musical end bound**: a region's `TimeExtent` is a pair of
//! `TimeAnchor`s. The bound is resolvable in musical time only when the end
//! anchor is region-start-anchored with a `Musical` offset (the same
//! sound-but-incomplete resolution discipline as
//! `epiphany_core::Region::overlaps_in_time`, which resolves only wall-clock
//! extents). A wall-clock or symbolic extent yields no musical bound and the
//! boundary check passes vacuously — the full tempo/measure resolution
//! machinery is deferred (P11-C5).
use epiphany_core::{
AnchorOffset, EventDuration, EventId, EventPosition, MusicalPosition, Region, RegionEdge,
RegionId, Score, SlurId, TimeAnchor,
};
use crate::payload::{CrossCuttingValue, OperationKind};
use crate::reduce::graph_voice_location;
/// The two precondition-checking modes of Chapter 6 §"Validation Modes"
/// (`sec:semops:validation`).
///
/// Invariant preconditions hold in **all** modes — the reducer
/// ([`crate::OperationSet::reduce`] / [`crate::OperationSet::reduce_onto`])
/// enforces them unconditionally, and is thereby exactly
/// [`ValidationMode::Replay`]. [`ValidationMode::Authoring`] additionally
/// requires [`advisory_violations`] to be empty *before an envelope is
/// minted*; it never alters reduction.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum ValidationMode {
/// Interactive edits: invariant **and** advisory preconditions are
/// enforced. The advisory half is enforced pre-mint by the authoring
/// layer via [`advisory_violations`].
Authoring,
/// Replaying historical operations or applying remote operations under
/// reduction: only invariant preconditions are enforced; advisory
/// preconditions MAY fail silently.
Replay,
}
impl ValidationMode {
/// Whether this mode enforces advisory preconditions (only
/// [`ValidationMode::Authoring`] does).
#[inline]
pub fn enforces_advisory(self) -> bool {
matches!(self, ValidationMode::Authoring)
}
}
/// A failed advisory precondition (Chapter 6 §6.10, the "Advisory
/// preconditions (authoring mode only)" buckets).
///
/// **Deliberately non-canonical**: this type has no canonical encoding and no
/// discriminant table because it never enters effects, conflicts, or any other
/// canonical state — it exists only on the authoring side, *before* an
/// envelope is minted. An operation refused for an advisory violation leaves
/// no trace in the operation set; one that slipped past (a remote author's
/// different policy) reduces normally in replay mode.
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum AdvisoryViolation {
/// An `InsertEvent`/`ModifyEvent` event's span starts inside its region's
/// musical extent but ends past the region's end bound — applying it would
/// require splitting the event across the boundary (core spec §6.10
/// InsertEvent, advisory bucket).
DurationCrossesRegionBoundary {
/// The offending event.
event: EventId,
/// The region whose end bound the event's span crosses.
region: RegionId,
},
/// A `CreateCrossCutting` slur's endpoint events lie in different regions
/// (core spec §6.10 CreateCrossCutting, Slur advisory bucket). Region
/// configuration cannot yet permit spanning (see the module docs' gap
/// list), so a cross-region slur always reports.
SlurSpansRegionBoundary {
/// The offending slur.
slur: SlurId,
/// The region containing the start event.
start_region: RegionId,
/// The (different) region containing the end event.
end_region: RegionId,
},
}
/// Checks `kind` against every implemented advisory precondition (the
/// authoring-mode-only bucket of Chapter 6 §6.10), evaluated against `score`
/// — the authoring replica's current materialized graph. Returns every
/// violation found (empty = the operation may be minted in authoring mode).
///
/// Replay mode never calls this: the reducer applies invariant preconditions
/// only, so an envelope violating an advisory check still reduces cleanly
/// (spec: advisory preconditions MAY fail silently in replay mode). The check
/// is deliberately *conservative*: anything it cannot resolve against the
/// graph (a missing voice, a non-musical placement, an unresolvable region
/// bound) passes vacuously and is left to the invariant preconditions under
/// reduction.
pub fn advisory_violations(kind: &OperationKind, score: &Score) -> Vec<AdvisoryViolation> {
let mut violations = Vec::new();
match kind {
OperationKind::InsertEvent(op) => {
check_event_span(&op.event, score, &mut violations);
}
OperationKind::ModifyEvent(op) => {
check_event_span(&op.event, score, &mut violations);
}
OperationKind::CreateCrossCutting(op) => {
if let CrossCuttingValue::Slur(slur) = &op.structure {
let start = event_region(score, slur.start_event);
let end = event_region(score, slur.end_event);
if let (Some(start_region), Some(end_region)) = (start, end) {
if start_region != end_region {
violations.push(AdvisoryViolation::SlurSpansRegionBoundary {
slur: slur.id,
start_region,
end_region,
});
}
}
}
}
// Every other implemented kind's spec precondition bucket is entirely
// invariant (see the module docs); nothing to check here.
_ => {}
}
violations
}
/// Reports a violation when `event`'s musical span straddles its region's
/// musical end bound (starts strictly before it, ends strictly past it — the
/// "would require splitting" shape). A non-musical placement, an unlocatable
/// voice, or an unresolvable bound passes vacuously.
fn check_event_span(
event: &epiphany_core::Event,
score: &Score,
violations: &mut Vec<AdvisoryViolation>,
) {
let (EventPosition::Musical(position), EventDuration::Musical(duration)) =
(event.position(), event.duration())
else {
return;
};
let Some((region_index, _, _)) = graph_voice_location(score, event.voice()) else {
return;
};
let region = &score.canvas.regions[region_index];
let Some(bound) = region_musical_end_bound(region) else {
return;
};
let end = position.clone() + duration.clone();
if position < &bound && end > bound {
violations.push(AdvisoryViolation::DurationCrossesRegionBoundary {
event: event.id(),
region: region.id,
});
}
}
/// The region's end bound as a region-local musical position, when its
/// `TimeExtent`'s end anchor expresses one: anchored to this region's own
/// start edge with a `Musical` offset. Any other shape (wall-clock, symbolic,
/// another region's edge) is not resolvable without the deferred tempo/measure
/// machinery and yields `None` (the advisory check then passes vacuously).
fn region_musical_end_bound(region: &Region) -> Option<MusicalPosition> {
match &region.time_extent.end {
TimeAnchor::Region {
id,
edge: RegionEdge::Start,
offset: AnchorOffset::Musical(length),
} if *id == region.id => Some(MusicalPosition(length.0.clone())),
_ => None,
}
}
/// The region containing `event`, resolved through the event's voice. `None`
/// when the event or its voice is not in the graph (the invariant
/// preconditions own that case).
fn event_region(score: &Score, event: EventId) -> Option<RegionId> {
let ev = score.events.get(event)?;
let (region_index, _, _) = graph_voice_location(score, ev.voice())?;
Some(score.canvas.regions[region_index].id)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::payload::{CreateCrossCuttingOp, InsertEventOp, ModifyEventOp};
use crate::valuegen;
use epiphany_core::generators::valid_score;
use epiphany_core::{
EventId, MusicalDuration, MusicalPosition, PitchId, RationalTime, ReplicaId, SlurId,
VoiceId,
};
/// A fixture score whose (single) region declares a musical end bound of
/// `bound` whole units, plus the ids needed to aim operations at it.
fn bounded_score(bound: i32) -> (Score, RegionId, epiphany_core::StaffInstanceId, VoiceId) {
let mut score = valid_score(7);
let region = &mut score.canvas.regions[0];
let region_id = region.id;
region.time_extent.end = TimeAnchor::Region {
id: region_id,
edge: RegionEdge::Start,
offset: AnchorOffset::Musical(MusicalDuration(RationalTime::from_int(bound))),
};
let instance = region.staff_instances()[0].id;
let voice = region.staff_instances()[0].voices[0].id;
(score, region_id, instance, voice)
}
fn insert_kind(
instance: epiphany_core::StaffInstanceId,
voice: VoiceId,
position: i32,
duration: i32,
) -> OperationKind {
OperationKind::InsertEvent(InsertEventOp {
staff_instance: instance,
event: valuegen::insert_event_value(
EventId::new(ReplicaId(50), 999),
voice,
MusicalPosition(RationalTime::from_int(position)),
MusicalDuration(RationalTime::from_int(duration)),
&[PitchId::new(ReplicaId(50), 998)],
),
})
}
#[test]
fn insert_event_crossing_the_region_end_bound_is_an_advisory_violation() {
let (score, region, instance, voice) = bounded_score(12);
// Starts inside the extent (10 < 12), ends past it (14 > 12).
let kind = insert_kind(instance, voice, 10, 4);
let violations = advisory_violations(&kind, &score);
assert_eq!(
violations,
vec![AdvisoryViolation::DurationCrossesRegionBoundary {
event: EventId::new(ReplicaId(50), 999),
region,
}]
);
}
#[test]
fn insert_event_within_the_region_end_bound_passes() {
let (score, _, instance, voice) = bounded_score(12);
// Ends exactly at the bound: nothing to split, no violation.
let kind = insert_kind(instance, voice, 10, 2);
assert!(advisory_violations(&kind, &score).is_empty());
}
#[test]
fn insert_event_against_an_unresolvable_extent_passes_vacuously() {
// The unmodified fixture's extent is wall-clock — no musical bound is
// resolvable, so the boundary check cannot fire (module docs, gap 3).
let score = valid_score(7);
let instance = score.canvas.regions[0].staff_instances()[0].id;
let voice = score.canvas.regions[0].staff_instances()[0].voices[0].id;
let kind = insert_kind(instance, voice, 10, 1_000);
assert!(advisory_violations(&kind, &score).is_empty());
}
#[test]
fn modify_event_crossing_the_region_end_bound_is_an_advisory_violation() {
let (score, region, _, voice) = bounded_score(12);
// A replacement value for an existing event, moved to straddle the
// bound. (The advisory check reads the replacement's span; liveness of
// the target is the reducer's invariant precondition.)
let target = score.canvas.regions[0].staff_instances()[0].voices[0].events[0];
let kind = OperationKind::ModifyEvent(ModifyEventOp {
event: valuegen::insert_event_value(
target,
voice,
MusicalPosition(RationalTime::from_int(11)),
MusicalDuration(RationalTime::from_int(3)),
&[PitchId::new(ReplicaId(50), 998)],
),
});
let violations = advisory_violations(&kind, &score);
assert_eq!(
violations,
vec![AdvisoryViolation::DurationCrossesRegionBoundary {
event: target,
region,
}]
);
// The same replacement kept inside the bound passes.
let kind = OperationKind::ModifyEvent(ModifyEventOp {
event: valuegen::insert_event_value(
target,
voice,
MusicalPosition(RationalTime::from_int(11)),
MusicalDuration(RationalTime::from_int(1)),
&[PitchId::new(ReplicaId(50), 998)],
),
});
assert!(advisory_violations(&kind, &score).is_empty());
}
#[test]
fn slur_spanning_two_regions_is_an_advisory_violation() {
// Two single-region fixture scores merged: distinct regions, each with
// its own events.
let mut score = valid_score(7);
let other = valid_score(8);
let start_region = score.canvas.regions[0].id;
let end_region = other.canvas.regions[0].id;
let start_event = score.canvas.regions[0].staff_instances()[0].voices[0].events[0];
let end_event = other.canvas.regions[0].staff_instances()[0].voices[0].events[0];
score.canvas.regions.push(other.canvas.regions[0].clone());
for event in other.events.iter_canonical() {
score
.events
.insert(event.clone())
.expect("distinct seeds mint distinct event ids");
}
let slur_id = SlurId::new(ReplicaId(50), 1);
let cross = OperationKind::CreateCrossCutting(CreateCrossCuttingOp {
structure: CrossCuttingValue::Slur(valuegen::slur(slur_id, start_event, end_event)),
});
assert_eq!(
advisory_violations(&cross, &score),
vec![AdvisoryViolation::SlurSpansRegionBoundary {
slur: slur_id,
start_region,
end_region,
}]
);
// A slur within one region passes.
let second = score.canvas.regions[0].staff_instances()[0].voices[0].events[1];
let within = OperationKind::CreateCrossCutting(CreateCrossCuttingOp {
structure: CrossCuttingValue::Slur(valuegen::slur(slur_id, start_event, second)),
});
assert!(advisory_violations(&within, &score).is_empty());
}
#[test]
fn replay_reduction_ignores_advisory_violations_and_is_unchanged_by_the_mode_machinery() {
// Spec §"Validation Modes": advisory preconditions MAY fail silently
// in replay mode. An envelope carrying an advisory-violating insert
// reduces cleanly (Applied) through the ordinary reduction path, and
// the reduction's canonical bytes are a pure function of the operation
// set — consulting `advisory_violations` beforehand (as an authoring
// layer would) changes nothing.
use crate::causal::CausalContext;
use crate::stamp::{HybridLogicalClock, OperationStamp};
use crate::support::AuthorId;
use crate::{OperationEnvelope, OperationPayload, OperationSet};
use epiphany_core::{OperationId, WallClockTime};
let (score, _, instance, voice) = bounded_score(12);
let kind = insert_kind(instance, voice, 10, 4);
assert!(
!advisory_violations(&kind, &score).is_empty(),
"the scenario must actually violate an advisory precondition"
);
let id = OperationId::new(ReplicaId(50), 0);
let env = OperationEnvelope {
id,
author: AuthorId(0),
stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(1), 0), id),
causal_context: CausalContext::new(),
transaction: None,
payload: OperationPayload::Primitive(kind),
};
let mut set = OperationSet::new();
set.accept(env);
let first = set.reduce_onto(&score);
assert!(first.state.is_clean(), "replay applies the insert cleanly");
assert!(matches!(
first.state.effects.as_slice(),
[(applied, crate::OperationEffect::Applied)] if *applied == id
));
// Byte-identity across repeated reductions of the same set — the mode
// machinery has no channel into reduction.
let second = set.reduce_onto(&score);
assert_eq!(
first.state.canonical_bytes(),
second.state.canonical_bytes()
);
}
#[test]
fn validation_mode_advisory_enforcement_is_authoring_only() {
assert!(ValidationMode::Authoring.enforces_advisory());
assert!(!ValidationMode::Replay.enforces_advisory());
}
}

View File

@ -2,18 +2,20 @@
//! graph rather than only the Chapter 6 bookkeeping projection.
use epiphany_core::{
check_invariants, derive_promoted_voice_id, AnchorOffset, EventId, MusicalDuration,
MusicalPosition, OperationId, PitchId, RationalTime, RegionEdge, RegionTimeModel, ReplicaId,
Score, SlurId, StaffInstanceId, TimeAnchor, TransactionId, TypedObjectId, VoiceId, VoiceOrigin,
WallClockTime,
check_invariants, derive_promoted_voice_id, AnalyticalAnnotation, AnalyticalAnnotationId,
AnchorOffset, AnnotationAnchor, Comment, CommentId, CueEvent, CueRendering, Event,
EventDuration, EventId, EventPosition, GestureAnchoring, GraphicGesture, GraphicGestureId,
Marker, MarkerId, MusicalDuration, MusicalPosition, OperationId, PitchId, RationalTime,
RegionEdge, RegionTimeModel, ReplicaId, Score, SlurId, StaffInstanceId, TimeAnchor,
TransactionId, TypedObjectId, VoiceId, VoiceOrigin, WallClockTime,
};
use epiphany_ops::{
valuegen, AuthorId, CausalContext, ChangeRegionTimeModelOp, ConflictKind, CreateCrossCuttingOp,
CrossCuttingValue, DeleteEventOp, HybridLogicalClock, InsertEventOp, NoOpReason,
OperationEffect, OperationEnvelope, OperationKind, OperationPayload, OperationSet,
OperationStamp, PositionRemapping, PreconditionFailureReason, SetUserSystemBreakOp,
TransactionCategory, TransactionDescriptor, TupletCompensation, UndoPolicy,
UndoTransactionPayload,
OperationStamp, PositionRemapping, PreconditionFailureReason, ReanchorReason, RepairKind,
RepairRecord, SetUserSystemBreakOp, TransactionCategory, TransactionDescriptor,
TupletCompensation, UndoPolicy, UndoTransactionPayload,
};
fn envelope(
@ -1031,6 +1033,76 @@ fn deleting_both_slur_endpoints_cascades_in_both_graph_and_ledger() {
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn cascade_delete_tuplet_prunes_dangling_decompositions() {
// `valid_score_rich`'s metric region is a 3:2 triplet whose first member carries an
// in-tuplet decomposition. Cascade-deleting the tuplet — member 0 carries the
// `CascadeDeleteTuplets`, the rest delete as ordinary (no-longer-tuplet) events —
// must also drop that decomposition; otherwise its tuplet reference would dangle
// (invariant 6, cross-cutting refs resolve).
let base = epiphany_core::generators::valid_score_rich(0x5EED);
let tuplet = base.cross_cutting.tuplets[0].id;
let members = base.cross_cutting.tuplets[0].members.clone();
assert_eq!(members.len(), 3, "the fixture triplet has three members");
assert!(
base.decomposition_attachments
.iter()
.any(|d| d.components.iter().any(|c| c.tuplet == Some(tuplet))),
"the fixture has an in-tuplet decomposition referencing the triplet"
);
// Three deletes in causal order: the structure-removing cascade first, so the
// remaining members then delete as ordinary events.
let mut ops = Vec::new();
for (i, &member) in members.iter().enumerate() {
let counter = (i + 1) as u64;
let compensation = if i == 0 {
TupletCompensation::CascadeDeleteTuplets {
tuplets: vec![tuplet],
}
} else {
TupletCompensation::NotInTuplet
};
ops.push(envelope(
70,
counter,
10 + i as i64,
CausalContext::new(),
None,
OperationPayload::Primitive(OperationKind::DeleteEvent(DeleteEventOp {
event: member,
tuplet_compensation: compensation,
})),
));
}
let mut set = OperationSet::new();
set.accept_all(ops);
let result = set.reduce_onto(&base);
assert!(
result.score.cross_cutting.tuplets.is_empty(),
"the tuplet structure is removed"
);
assert!(
!result
.score
.decomposition_attachments
.iter()
.any(|d| d.components.iter().any(|c| c.tuplet == Some(tuplet))),
"the now-orphaned decomposition is pruned"
);
for &member in &members {
assert!(
matches!(
result.state.objects.get(&TypedObjectId::Event(member)),
Some(epiphany_ops::ObjectState::Tombstoned { .. })
),
"each member is tombstoned"
);
}
assert!(check_invariants(&result.score).is_empty());
}
/// Helper: the effect recorded for `id` in a reduction.
fn effect_of(result: &epiphany_ops::GraphMaterialization, id: OperationId) -> OperationEffect {
result
@ -1828,3 +1900,854 @@ fn create_rejects_carried_non_hierarchy_children() {
);
assert!(check_invariants(&result.score).is_empty());
}
// === Re-anchoring rule-table coverage: markers, cue events, comments,
// analytical annotations, graphic gestures (core_spec §"The Re-Anchoring Rule
// Table", §"Total Ordering for Nearest"). All five kinds exist only via seeded
// base graphs (no operation creates them), so every scenario reduces onto a
// base. ========================================================================
/// The repairs of an `AppliedWithRepair` effect.
fn repairs_of(result: &epiphany_ops::GraphMaterialization, id: OperationId) -> Vec<RepairRecord> {
match effect_of(result, id) {
OperationEffect::AppliedWithRepair { repairs } => repairs,
other => panic!("expected AppliedWithRepair, got {other:?}"),
}
}
/// A plain (non-tuplet) DeleteEvent envelope.
fn delete_event(
replica: u64,
counter: u64,
physical: i64,
ctx: CausalContext,
event: EventId,
) -> OperationEnvelope {
envelope(
replica,
counter,
physical,
ctx,
None,
OperationPayload::Primitive(OperationKind::DeleteEvent(DeleteEventOp {
event,
tuplet_compensation: TupletCompensation::NotInTuplet,
})),
)
}
/// The first voice's event list (the fixture voice all these scenarios edit).
fn first_voice_events(base: &Score) -> Vec<EventId> {
base.canvas.regions[0].staff_instances()[0].voices[0]
.events
.clone()
}
/// Adds a cue event sourcing `sources` to `base`'s first voice at whole-note
/// `position` (clear of the fixture's quarter-note content in `[0, 1)`).
fn push_cue(base: &mut Score, id: EventId, sources: Vec<EventId>, position: i32) {
let (_, voice) = target(base);
base.events
.insert(Event::Cue(CueEvent {
id,
voice,
position: EventPosition::Musical(MusicalPosition(RationalTime::from_int(position))),
duration: EventDuration::Musical(MusicalDuration::whole()),
source: sources,
rendering: CueRendering,
}))
.expect("fresh cue id");
base.canvas.regions[0]
.content
.staff_instances_mut()
.expect("fixture is staff based")[0]
.voices[0]
.events
.push(id);
}
#[test]
fn deleting_a_cue_source_cascade_deletes_the_cue() {
// Rule table, "Cue event / Source event": cascade-delete ("a cue with no
// source is meaningless") — ledger tombstone, graph removal, and a
// CascadeDeleted repair in the triggering delete's effect, all in the same
// reduction step.
let mut base = epiphany_core::generators::valid_score(100);
let source = first_voice_events(&base)[0];
let cue = EventId::new(ReplicaId(90), 0);
push_cue(&mut base, cue, vec![source], 40);
let del = delete_event(91, 0, 10, CausalContext::new(), source);
let mut set = OperationSet::new();
set.accept(del.clone());
let result = set.reduce_onto(&base);
assert!(
repairs_of(&result, del.id)
.iter()
.any(|r| r.kind == RepairKind::CascadeDeleted && r.target == TypedObjectId::Event(cue)),
"the cue cascade is recorded in the triggering delete's effect"
);
assert!(
matches!(
result.state.objects.get(&TypedObjectId::Event(cue)),
Some(epiphany_ops::ObjectState::Tombstoned { .. })
),
"the cue's event id is tombstoned in the ledger"
);
assert!(
!result.score.events.contains(cue),
"the cue is removed from the event arena"
);
assert!(
result.score.tombstoned_events.contains(&cue),
"the cue is a graph tombstone"
);
assert!(
!first_voice_events(&result.score).contains(&cue),
"the cue is removed from its voice"
);
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn a_cue_with_multiple_sources_cascades_on_any_source_deletion() {
// The table's action is the plain "cascade-delete" on a source deletion —
// not truncate-while-any-source-survives (the rationale-vs-action tension
// for multi-source cues is a proposed Pass-12 row). Either source's
// deletion cascades the cue.
for victim_index in [0usize, 1] {
let mut base = epiphany_core::generators::valid_score(100);
let events = first_voice_events(&base);
let cue = EventId::new(ReplicaId(90), 1);
push_cue(&mut base, cue, vec![events[0], events[1]], 40);
let del = delete_event(91, 0, 10, CausalContext::new(), events[victim_index]);
let mut set = OperationSet::new();
set.accept(del.clone());
let result = set.reduce_onto(&base);
assert!(
repairs_of(&result, del.id)
.iter()
.any(|r| r.kind == RepairKind::CascadeDeleted
&& r.target == TypedObjectId::Event(cue)),
"deleting source #{victim_index} cascades the two-source cue"
);
assert!(!result.score.events.contains(cue));
assert!(check_invariants(&result.score).is_empty());
}
}
#[test]
fn a_cascaded_cue_reanchors_its_own_referents_transitively() {
// A cascaded cue is itself a tombstoned event, so the same re-anchoring
// pass runs over *its* referents in the same reduction step: a cue-of-a-cue
// cascades along.
let mut base = epiphany_core::generators::valid_score(100);
let source = first_voice_events(&base)[0];
let cue1 = EventId::new(ReplicaId(90), 2);
let cue2 = EventId::new(ReplicaId(90), 3);
push_cue(&mut base, cue1, vec![source], 40);
push_cue(&mut base, cue2, vec![cue1], 44);
let del = delete_event(91, 0, 10, CausalContext::new(), source);
let mut set = OperationSet::new();
set.accept(del.clone());
let result = set.reduce_onto(&base);
let repairs = repairs_of(&result, del.id);
for cue in [cue1, cue2] {
assert!(
repairs
.iter()
.any(|r| r.kind == RepairKind::CascadeDeleted
&& r.target == TypedObjectId::Event(cue)),
"cue {cue:?} cascades in the same reduction step"
);
assert!(
matches!(
result.state.objects.get(&TypedObjectId::Event(cue)),
Some(epiphany_ops::ObjectState::Tombstoned { .. })
),
"cue {cue:?} is tombstoned in the ledger"
);
assert!(!result.score.events.contains(cue));
}
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn deleting_a_comment_anchor_orphans_the_comment() {
// Rule table, "Comment / Anchor": orphan — user content never silently
// deleted. The comment survives (ledger Live, graph present); its anchor
// degrades to the containing region so invariant 10 keeps holding.
let mut base = epiphany_core::generators::valid_score(100);
let region = base.canvas.regions[0].id;
let anchor_event = first_voice_events(&base)[0];
let comment_id = CommentId::new(ReplicaId(90), 4);
base.cross_cutting.comments.push(Comment {
id: comment_id,
anchor: AnnotationAnchor::Event(anchor_event),
resolved: false,
});
let del = delete_event(91, 0, 10, CausalContext::new(), anchor_event);
let mut set = OperationSet::new();
set.accept(del.clone());
let result = set.reduce_onto(&base);
assert!(
repairs_of(&result, del.id)
.iter()
.any(|r| r.kind == RepairKind::Orphaned
&& r.target == TypedObjectId::Comment(comment_id)),
"the orphaning is a recorded repair"
);
assert_eq!(
result
.state
.objects
.get(&TypedObjectId::Comment(comment_id)),
Some(&epiphany_ops::ObjectState::Live),
"the orphaned comment stays live in the ledger"
);
let comment = result
.score
.cross_cutting
.comments
.iter()
.find(|c| c.id == comment_id)
.expect("the orphaned comment survives in the graph");
assert_eq!(
comment.anchor,
AnnotationAnchor::Region(region),
"the dangling event anchor degrades to the containing region"
);
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn annotation_reanchors_to_a_range_preserving_the_events_extent() {
// Rule table, "Analytical annotation / Anchor": re-anchor to a time range
// preserving the original extent. The fixture's second event spans
// [1/4, 1/2), so the reconstructed range is region-start + 1/4 .. + 1/2.
let mut base = epiphany_core::generators::valid_score(100);
let region = base.canvas.regions[0].id;
let anchor_event = first_voice_events(&base)[1];
let annotation_id = AnalyticalAnnotationId::new(ReplicaId(90), 5);
base.cross_cutting.analytical.push(AnalyticalAnnotation {
id: annotation_id,
anchor: AnnotationAnchor::Event(anchor_event),
layer: None,
});
let del = delete_event(91, 0, 10, CausalContext::new(), anchor_event);
let mut set = OperationSet::new();
set.accept(del.clone());
let result = set.reduce_onto(&base);
assert!(
repairs_of(&result, del.id).iter().any(|r| {
r.target == TypedObjectId::AnalyticalAnnotation(annotation_id)
&& r.kind
== RepairKind::Reanchored {
from: TypedObjectId::Event(anchor_event),
to: TypedObjectId::Region(region),
reason: ReanchorReason::ExplicitFallback,
}
}),
"the range reconstruction is a recorded repair"
);
let annotation = result
.score
.cross_cutting
.analytical
.iter()
.find(|a| a.id == annotation_id)
.expect("the annotation survives");
let offset_at = |num: i64, den: i64| {
AnchorOffset::Musical(MusicalDuration(RationalTime::new(num, den).unwrap()))
};
assert_eq!(
annotation.anchor,
AnnotationAnchor::Range {
start: TimeAnchor::Region {
id: region,
edge: RegionEdge::Start,
offset: offset_at(1, 4),
},
end: TimeAnchor::Region {
id: region,
edge: RegionEdge::Start,
offset: offset_at(1, 2),
},
},
"the reconstructed range covers the deleted event's exact span"
);
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn annotation_orphans_when_the_range_cannot_be_reconstructed() {
// The extent of a wall-clock event is not expressible as a stored
// region-relative range in this prototype (the expressibility gap is a
// proposed Pass-12 row), so the annotation orphans: kept, anchor degraded
// to the containing region.
let mut base = epiphany_core::generators::valid_score_rich(0x5EED);
let (region, wall_clock_event) = base
.voices()
.find_map(|(region, _, v)| {
v.events
.iter()
.copied()
.find(|e| {
matches!(
base.events.get(*e).map(Event::position),
Some(EventPosition::WallClock(_))
)
})
.map(|e| (region, e))
})
.expect("the rich fixture has a proportional region with wall-clock events");
let annotation_id = AnalyticalAnnotationId::new(ReplicaId(90), 6);
base.cross_cutting.analytical.push(AnalyticalAnnotation {
id: annotation_id,
anchor: AnnotationAnchor::Event(wall_clock_event),
layer: None,
});
let del = delete_event(91, 0, 10, CausalContext::new(), wall_clock_event);
let mut set = OperationSet::new();
set.accept(del.clone());
let result = set.reduce_onto(&base);
assert!(
repairs_of(&result, del.id)
.iter()
.any(|r| r.kind == RepairKind::Orphaned
&& r.target == TypedObjectId::AnalyticalAnnotation(annotation_id)),
"an unreconstructable range orphans the annotation"
);
let annotation = result
.score
.cross_cutting
.analytical
.iter()
.find(|a| a.id == annotation_id)
.expect("the orphaned annotation survives");
assert_eq!(annotation.anchor, AnnotationAnchor::Region(region));
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn gesture_event_references_retarget_to_the_nearest_survivor() {
// Rule table, "Graphic gesture / Anchor event": re-anchor to the nearest
// surviving event of the same staff instance.
let mut base = epiphany_core::generators::valid_score(100);
let events = first_voice_events(&base);
let (dead, survivor) = (events[0], events[1]);
let gesture_id = GraphicGestureId::new(ReplicaId(90), 7);
base.cross_cutting.graphic_gestures.push(GraphicGesture {
id: gesture_id,
objects: Vec::new(),
anchoring: GestureAnchoring::Events(vec![dead]),
});
let del = delete_event(91, 0, 10, CausalContext::new(), dead);
let mut set = OperationSet::new();
set.accept(del.clone());
let result = set.reduce_onto(&base);
assert!(
repairs_of(&result, del.id).iter().any(|r| {
r.target == TypedObjectId::GraphicGesture(gesture_id)
&& r.kind
== RepairKind::Reanchored {
from: TypedObjectId::Event(dead),
to: TypedObjectId::Event(survivor),
reason: ReanchorReason::SameVoiceNearer,
}
}),
"the gesture re-target is a recorded repair"
);
let gesture = result
.score
.cross_cutting
.graphic_gestures
.iter()
.find(|g| g.id == gesture_id)
.expect("the gesture survives");
assert_eq!(
gesture.anchoring,
GestureAnchoring::Events(vec![survivor]),
"the graph reference list agrees with the recorded repair"
);
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn free_anchored_gestures_ignore_event_deletion() {
// Rule table: "for Free anchoring, no action" — a free gesture follows no
// score content, so the delete reduces with no gesture repair.
let mut base = epiphany_core::generators::valid_score(100);
let dead = first_voice_events(&base)[0];
let gesture_id = GraphicGestureId::new(ReplicaId(90), 8);
base.cross_cutting.graphic_gestures.push(GraphicGesture {
id: gesture_id,
objects: Vec::new(),
anchoring: GestureAnchoring::Free,
});
let del = delete_event(91, 0, 10, CausalContext::new(), dead);
let mut set = OperationSet::new();
set.accept(del.clone());
let result = set.reduce_onto(&base);
assert_eq!(
effect_of(&result, del.id),
OperationEffect::Applied,
"no repair is recorded for a free-anchored gesture"
);
let gesture = result
.score
.cross_cutting
.graphic_gestures
.iter()
.find(|g| g.id == gesture_id)
.expect("the gesture survives");
assert_eq!(gesture.anchoring, GestureAnchoring::Free);
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn gesture_range_anchoring_truncates_to_the_region_edge() {
// Rule table: "for Range anchoring, truncate" — the deterministic reading:
// a dead start endpoint moves to its region's start edge (an end endpoint
// would move to the end edge); the underdetermined "truncate" semantics is
// a proposed Pass-12 row.
let mut base = epiphany_core::generators::valid_score(100);
let region = base.canvas.regions[0].id;
let dead = first_voice_events(&base)[0];
let gesture_id = GraphicGestureId::new(ReplicaId(90), 9);
let end_anchor = TimeAnchor::Region {
id: region,
edge: RegionEdge::End,
offset: AnchorOffset::Zero,
};
base.cross_cutting.graphic_gestures.push(GraphicGesture {
id: gesture_id,
objects: Vec::new(),
anchoring: GestureAnchoring::Range {
start: TimeAnchor::Event {
id: dead,
offset: AnchorOffset::Zero,
},
end: end_anchor.clone(),
staves: Vec::new(),
},
});
let del = delete_event(91, 0, 10, CausalContext::new(), dead);
let mut set = OperationSet::new();
set.accept(del.clone());
let result = set.reduce_onto(&base);
assert!(
repairs_of(&result, del.id).iter().any(|r| {
r.target == TypedObjectId::GraphicGesture(gesture_id)
&& r.kind
== RepairKind::Reanchored {
from: TypedObjectId::Event(dead),
to: TypedObjectId::Region(region),
reason: ReanchorReason::ExplicitFallback,
}
}),
"the range truncation is a recorded repair"
);
let gesture = result
.score
.cross_cutting
.graphic_gestures
.iter()
.find(|g| g.id == gesture_id)
.expect("the gesture survives");
assert_eq!(
gesture.anchoring,
GestureAnchoring::Range {
start: TimeAnchor::Region {
id: region,
edge: RegionEdge::Start,
offset: AnchorOffset::Zero,
},
end: end_anchor,
staves: Vec::new(),
},
"the dead start endpoint moved to the region's start edge"
);
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn marker_reanchor_breaks_full_ties_by_ascending_event_id() {
// Four-key ordering, key 4: with equal proximity rank, distance, and
// direction, the ascending typed-id byte order decides. The referent sits
// alone in its own voice; two candidates in two sibling voices share its
// exact position (rank 1, distance 0, forward) — the smaller EventId wins
// even though it was authored later and lives in the higher-id voice.
let mut base = epiphany_core::generators::valid_score(100);
let (staff_instance, _) = target(&base);
let referent_voice = VoiceId::new(ReplicaId(9), 77);
let voice_b = VoiceId::new(ReplicaId(9), 78);
let voice_c = VoiceId::new(ReplicaId(9), 79);
{
let instances = base.canvas.regions[0]
.content
.staff_instances_mut()
.expect("fixture is staff based");
instances[0].voices.push(valuegen::voice(referent_voice));
instances[0].voices.push(valuegen::voice(voice_b));
instances[0].voices.push(valuegen::voice(voice_c));
}
let referent = EventId::new(ReplicaId(95), 50);
let larger_id = EventId::new(ReplicaId(95), 9);
let smaller_id = EventId::new(ReplicaId(95), 3);
let marker_id = MarkerId::new(ReplicaId(90), 10);
base.cross_cutting.markers.push(Marker {
id: marker_id,
anchor: TimeAnchor::Event {
id: referent,
offset: AnchorOffset::Zero,
},
});
let ins = |counter: u64, event: EventId, voice: VoiceId, pitch: u64| {
let ctx = if counter == 0 {
CausalContext::new()
} else {
CausalContext::new().with_seen(ReplicaId(95), counter - 1)
};
envelope(
95,
counter,
10 + counter as i64,
ctx,
None,
insert(
staff_instance,
voice,
event,
PitchId::new(ReplicaId(95), 100 + pitch),
100,
),
)
};
let del = delete_event(
95,
3,
20,
CausalContext::new().with_seen(ReplicaId(95), 2),
referent,
);
let mut set = OperationSet::new();
set.accept_all(vec![
ins(0, referent, referent_voice, 0),
ins(1, larger_id, voice_b, 1),
ins(2, smaller_id, voice_c, 2),
del.clone(),
]);
let result = set.reduce_onto(&base);
assert!(
repairs_of(&result, del.id).iter().any(|r| {
r.target == TypedObjectId::Marker(marker_id)
&& r.kind
== RepairKind::Reanchored {
from: TypedObjectId::Event(referent),
to: TypedObjectId::Event(smaller_id),
reason: ReanchorReason::SameStaffInstanceNearer,
}
}),
"a full tie falls to the ascending typed-id byte order"
);
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn marker_orphans_when_the_staff_instance_has_no_other_live_event() {
// Rule table, "Marker / Anchor": proximity max is the same staff instance,
// orphan on failure. Every event of the marker's staff instance is deleted
// (the anchored one last), so no candidate survives within the bound; the
// marker is kept and its anchor degrades to the region start.
let mut base = epiphany_core::generators::valid_score(100);
let region = base.canvas.regions[0].id;
let instance_events: Vec<EventId> = base.canvas.regions[0].staff_instances()[0]
.voices
.iter()
.flat_map(|v| v.events.clone())
.collect();
let marked = instance_events[0];
let marker_id = MarkerId::new(ReplicaId(90), 11);
base.cross_cutting.markers.push(Marker {
id: marker_id,
anchor: TimeAnchor::Event {
id: marked,
offset: AnchorOffset::Zero,
},
});
let mut order: Vec<EventId> = instance_events
.iter()
.copied()
.filter(|e| *e != marked)
.collect();
order.push(marked);
let ops: Vec<OperationEnvelope> = order
.iter()
.enumerate()
.map(|(i, &event)| {
let ctx = if i == 0 {
CausalContext::new()
} else {
CausalContext::new().with_seen(ReplicaId(96), i as u64 - 1)
};
delete_event(96, i as u64, 10 + i as i64, ctx, event)
})
.collect();
let last = ops.last().expect("at least one delete").clone();
let mut set = OperationSet::new();
set.accept_all(ops);
let result = set.reduce_onto(&base);
assert!(
repairs_of(&result, last.id).iter().any(|r| r.kind == RepairKind::Orphaned
&& r.target == TypedObjectId::Marker(marker_id)),
"the marker orphans when its staff instance has no other live event"
);
assert_eq!(
result.state.objects.get(&TypedObjectId::Marker(marker_id)),
Some(&epiphany_ops::ObjectState::Live),
"the orphaned marker stays live in the ledger"
);
let marker = result
.score
.cross_cutting
.markers
.iter()
.find(|m| m.id == marker_id)
.expect("the orphaned marker survives in the graph");
assert_eq!(
marker.anchor,
TimeAnchor::Region {
id: region,
edge: RegionEdge::Start,
offset: AnchorOffset::Zero,
},
"the dangling anchor degrades to the region start"
);
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn slur_reanchor_reason_names_the_survivors_containment_rank() {
// The Reanchored reason on a slur's surviving-endpoint collapse names the
// survivor's actual containment proximity to the tombstoned endpoint —
// same voice → SameVoiceNearer, sibling voice in the same staff instance →
// SameStaffInstanceNearer — instead of a hardcoded same-voice claim.
let mut base = epiphany_core::generators::valid_score(100);
let (staff_instance, voice_a) = target(&base);
let voice_b = VoiceId::new(ReplicaId(9), 80);
base.canvas.regions[0]
.content
.staff_instances_mut()
.expect("fixture is staff based")[0]
.voices
.push(valuegen::voice(voice_b));
let r = 97;
let e1 = EventId::new(ReplicaId(r), 0);
let e2 = EventId::new(ReplicaId(r), 1);
let e3 = EventId::new(ReplicaId(r), 2);
let cross_slur = SlurId::new(ReplicaId(r), 10);
let same_slur = SlurId::new(ReplicaId(r), 11);
let step = |counter: u64, payload: OperationPayload| {
let ctx = if counter == 0 {
CausalContext::new()
} else {
CausalContext::new().with_seen(ReplicaId(r), counter - 1)
};
envelope(r, counter, 10 + counter as i64, ctx, None, payload)
};
let create = |slur: SlurId, a: EventId, b: EventId| {
OperationPayload::Primitive(OperationKind::CreateCrossCutting(CreateCrossCuttingOp {
structure: CrossCuttingValue::Slur(valuegen::slur(slur, a, b)),
}))
};
let del = step(
5,
OperationPayload::Primitive(OperationKind::DeleteEvent(DeleteEventOp {
event: e1,
tuplet_compensation: TupletCompensation::NotInTuplet,
})),
);
let mut set = OperationSet::new();
set.accept_all(vec![
step(
0,
insert(
staff_instance,
voice_a,
e1,
PitchId::new(ReplicaId(r), 100),
100,
),
),
step(
1,
insert(
staff_instance,
voice_b,
e2,
PitchId::new(ReplicaId(r), 101),
101,
),
),
step(
2,
insert(
staff_instance,
voice_a,
e3,
PitchId::new(ReplicaId(r), 102),
102,
),
),
step(3, create(cross_slur, e1, e2)),
step(4, create(same_slur, e3, e1)),
del.clone(),
]);
let result = set.reduce_onto(&base);
let repairs = repairs_of(&result, del.id);
assert!(
repairs.iter().any(|rec| {
rec.target == TypedObjectId::Slur(cross_slur)
&& rec.kind
== RepairKind::Reanchored {
from: TypedObjectId::Event(e1),
to: TypedObjectId::Event(e2),
reason: ReanchorReason::SameStaffInstanceNearer,
}
}),
"a sibling-voice survivor is SameStaffInstanceNearer: {repairs:?}"
);
assert!(
repairs.iter().any(|rec| {
rec.target == TypedObjectId::Slur(same_slur)
&& rec.kind
== RepairKind::Reanchored {
from: TypedObjectId::Event(e1),
to: TypedObjectId::Event(e3),
reason: ReanchorReason::SameVoiceNearer,
}
}),
"a same-voice survivor is SameVoiceNearer: {repairs:?}"
);
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn referent_reanchoring_is_permutation_invariant() {
// The new rule-table rows are functions of canonical order and canonical
// state: a marker re-anchor (with distance and direction tie-breaks), a
// cue cascade, and a comment orphan reduce to byte-identical materialized
// state under any delivery permutation.
let mut base = epiphany_core::generators::valid_score(100);
let (staff_instance, voice_a) = target(&base);
let source = first_voice_events(&base)[0];
let cue = EventId::new(ReplicaId(90), 20);
push_cue(&mut base, cue, vec![source], 40);
let comment_id = CommentId::new(ReplicaId(90), 21);
base.cross_cutting.comments.push(Comment {
id: comment_id,
anchor: AnnotationAnchor::Event(source),
resolved: false,
});
let referent = EventId::new(ReplicaId(98), 1);
let marker_id = MarkerId::new(ReplicaId(90), 22);
base.cross_cutting.markers.push(Marker {
id: marker_id,
anchor: TimeAnchor::Event {
id: referent,
offset: AnchorOffset::Zero,
},
});
let ins = |counter: u64, event: u64, position: i32| {
let ctx = if counter == 0 {
CausalContext::new()
} else {
CausalContext::new().with_seen(ReplicaId(98), counter - 1)
};
envelope(
98,
counter,
10 + counter as i64,
ctx,
None,
insert(
staff_instance,
voice_a,
EventId::new(ReplicaId(98), event),
PitchId::new(ReplicaId(98), 100 + event),
position,
),
)
};
let envelopes = vec![
ins(0, 0, 10),
ins(1, 1, 12),
ins(2, 2, 14),
delete_event(
98,
3,
20,
CausalContext::new().with_seen(ReplicaId(98), 2),
referent,
),
delete_event(
98,
4,
21,
CausalContext::new().with_seen(ReplicaId(98), 3),
source,
),
];
let mut reference_set = OperationSet::new();
reference_set.accept_all(envelopes.clone());
let reference = reference_set.reduce_onto(&base);
assert!(check_invariants(&reference.score).is_empty());
// Non-vacuity: all three rows actually fired.
assert!(!reference.score.events.contains(cue), "the cue cascaded");
let permutations: [[usize; 5]; 4] = [
[4, 3, 2, 1, 0],
[2, 4, 0, 3, 1],
[3, 0, 4, 1, 2],
[1, 2, 3, 4, 0],
];
for (k, permutation) in permutations.iter().enumerate() {
let mut set = OperationSet::new();
set.accept_all(permutation.iter().map(|&i| envelopes[i].clone()));
let got = set.reduce_onto(&base);
assert_eq!(
got, reference,
"delivery permutation #{k} changed the materialized graph"
);
assert_eq!(
got.state.canonical_bytes(),
reference.state.canonical_bytes(),
"delivery permutation #{k} changed the canonical bytes"
);
}
}

View File

@ -5,7 +5,7 @@ fallback_rect_count=0
stroke_count=91
provenance_count=142
layer_count=1
hard_constraint_count=0
hard_constraint_count=90
xml_well_formed=true
view_box=[-3.059857 -3.632 102.98304 11.024]
class_counts:

View File

@ -5,7 +5,7 @@ fallback_rect_count=0
stroke_count=91
provenance_count=142
layer_count=1
hard_constraint_count=0
hard_constraint_count=90
xml_well_formed=true
view_box=[-3.065 -3.632 88.87701 11.024]
class_counts:

View File

@ -5,7 +5,7 @@ fallback_rect_count=0
stroke_count=38
provenance_count=49
layer_count=1
hard_constraint_count=0
hard_constraint_count=15
xml_well_formed=true
view_box=[-3.1598568 -3.632 31.588375 11.024]
class_counts:

View File

@ -5,7 +5,7 @@ fallback_rect_count=0
stroke_count=38
provenance_count=49
layer_count=1
hard_constraint_count=0
hard_constraint_count=15
xml_well_formed=true
view_box=[-3.065 -3.632 39.329998 11.024]
class_counts:

View File

@ -14,10 +14,14 @@
//! > generation when the crash precedes the durable flush.
use epiphany_bundle::{
pack_operation_blocks, Bundle, CommitContext, CrashPoint, DocumentId, FaultStore, FileUuid,
Manifest, MemStore, Slot, StagedChunk, Tear,
encode_block, envelope_offsets, pack_operation_blocks, BlockStore, Bundle, BundleError,
CommitContext, CrashPoint, DocumentId, ExtensionDeclaration, ExtensionId, FaultStore, FileUuid,
IndexedBlock, Manifest, MemStore, OperationIndex, SemVer, Slot, StagedChunk, Tear,
};
use epiphany_determinism::CanonicalEncode;
use epiphany_ops::{peek_operation_id, OperationEnvelope};
use crate::generators;
use crate::rng::Rng;
// --- Re-exported authoritative gates from Agent D --------------------------
@ -233,10 +237,419 @@ pub fn run_manifest_selection(seed: u64) {
assert_selection_through_commits(seed);
}
// --- The operation-index harness (Chapter 8 §"The Operation Index") --------
//
// The C/D seam: `epiphany-ops` vouches that a canonical envelope *leads* with
// its 16 operation-id bytes (`peek_operation_id`); the bundle records those
// raw bytes against `(block ChunkRef, offset-in-decoded-payload)` coordinates
// (`envelope_offsets` + `OperationIndex`) without ever interpreting an
// envelope. The index is an acceleration structure, not canonical: absent →
// rebuild by scanning blocks; present but stale or corrupt → reject and
// rebuild, never bundle corruption.
/// Stages real canonical envelope encodings into operation blocks of at most
/// `per_block` envelopes each (forcing a multi-block layout regardless of the
/// 1 MiB soft target, so index ordinals are actually exercised).
fn staged_envelope_blocks(envelopes: &[OperationEnvelope], per_block: usize) -> Vec<StagedChunk> {
envelopes
.chunks(per_block)
.map(|group| {
let payloads: Vec<Vec<u8>> = group.iter().map(|e| e.to_canonical_bytes()).collect();
StagedChunk::operation_block(encode_block(&payloads))
})
.collect()
}
/// The reader-side rebuild the spec mandates when no usable index exists:
/// scan every operation block the manifest references, recover each
/// envelope's `(offset, bytes)` with [`envelope_offsets`], peek the leading
/// operation-id bytes with ops' [`peek_operation_id`], and build a fresh
/// [`OperationIndex`]. The same procedure serves the writer at commit time
/// (the spec's SHOULD-rebuild-on-commit), from the committed block refs.
pub fn scan_rebuild_operation_index<S: BlockStore>(bundle: &Bundle<S>) -> OperationIndex {
let blocks: Vec<IndexedBlock> = bundle
.manifest()
.operation_roots
.iter()
.map(|root| {
let payload = bundle.read_chunk(root).expect("operation block reads");
let entries = envelope_offsets(&payload)
.expect("a committed block payload decodes")
.into_iter()
.map(|(offset, bytes)| {
let id = peek_operation_id(bytes)
.expect("a canonical envelope leads with its 16 id bytes");
(id.canonical_bytes(), offset)
})
.collect();
(*root, entries)
})
.collect();
OperationIndex::build(&blocks).expect("scanned blocks build a valid index")
}
/// Commits `index` as an operation-index chunk and wires it into the
/// manifest's `operation_index_root` — the writer's commit-time update.
fn commit_index<S: BlockStore>(bundle: &mut Bundle<S>, index: &OperationIndex) {
bundle
.commit(&[StagedChunk::operation_index(index.encode())], |ctx| {
let mut m = ctx.previous_manifest.clone();
m.operation_index_root = Some(ctx.new_chunks[0]);
m
})
.expect("commit operation index");
}
/// Locates every envelope through the index and verifies each `(block,
/// offset)` coordinate addresses exactly that envelope's canonical bytes in
/// the block's decoded payload — plus a miss for an id no envelope uses.
fn assert_index_locates_all<S: BlockStore>(
bundle: &Bundle<S>,
index: &OperationIndex,
envelopes: &[OperationEnvelope],
) {
for env in envelopes {
let id = env.id.canonical_bytes();
let (block_ref, offset) = index
.locate(&id)
.unwrap_or_else(|| panic!("operation {:?} missing from the index", env.id));
let payload = bundle.read_chunk(block_ref).expect("indexed block reads");
let pairs = envelope_offsets(&payload).expect("indexed block payload decodes");
let (_, bytes) = pairs
.iter()
.find(|(off, _)| *off == offset)
.expect("the indexed offset lands on an envelope boundary");
assert_eq!(
*bytes,
env.to_canonical_bytes().as_slice(),
"the (block, offset) coordinate must address exactly this envelope's bytes"
);
// The bundle-opaque bytes really lead with this operation's id — the
// ops-side half of the seam.
assert_eq!(peek_operation_id(bytes), Some(env.id));
// The kind-checked block read path sees the same envelope.
let envs = bundle
.read_operation_block(block_ref)
.expect("kind-checked block read");
assert!(envs.iter().any(|e| e.as_slice() == *bytes));
}
assert_eq!(
index.locate(&[0xFF; 16]),
None,
"an id no envelope uses must miss"
);
}
/// End-to-end: generate real envelopes, pack multi-block, commit, build the
/// index at commit time and wire it into `operation_index_root`, reopen, and
/// verify the index is usable and locates every operation at its exact bytes.
pub fn assert_operation_index_end_to_end(seed: u64) {
let mut rng = Rng::new(seed);
let envelopes = generators::operation_envelopes(&mut rng, 36, 3, 8, 8);
let uuid = FileUuid(rng.array16());
let doc = DocumentId(rng.array16());
let mut bundle =
Bundle::create(MemStore::new(), uuid, Manifest::empty(doc)).expect("create bundle");
bundle
.commit(&staged_envelope_blocks(&envelopes, 12), append_roots)
.expect("commit operation blocks");
// Writer side: rebuild the index at commit time from the committed block
// refs (the spec's SHOULD) and reference it from the manifest.
let index = scan_rebuild_operation_index(&bundle);
assert!(
index.blocks().len() >= 2,
"the fixture must span multiple blocks to exercise ordinals"
);
assert_eq!(index.entries().len(), envelopes.len());
commit_index(&mut bundle, &index);
// Reader side: reopen from the durable image; the fresh index is usable
// and locates every operation.
let image = bundle.into_store().into_bytes();
let reopened = Bundle::open(MemStore::from_bytes(image)).expect("reopen");
let usable = reopened
.usable_operation_index()
.expect("a fresh, covering index is usable");
assert_eq!(usable, index, "the index round-trips through storage");
assert_index_locates_all(&reopened, &usable, &envelopes);
}
/// A commit that grows the operation set *without* updating the index leaves
/// a stale index: intact as a chunk, but no longer covering the operation
/// roots. Readers must reject it (`usable_operation_index` → `None`) and a
/// scan-rebuild must produce a fresh valid index (spec §"The Operation
/// Index": present but stale → reject and rebuild from blocks).
pub fn assert_stale_operation_index_rejected_and_rebuilt(seed: u64) {
let mut rng = Rng::new(seed);
// One authoring session, so ids are unique across both phases.
let envelopes = generators::operation_envelopes(&mut rng, 48, 3, 8, 8);
let (first, later) = envelopes.split_at(36);
let uuid = FileUuid(rng.array16());
let doc = DocumentId(rng.array16());
let mut bundle =
Bundle::create(MemStore::new(), uuid, Manifest::empty(doc)).expect("create bundle");
bundle
.commit(&staged_envelope_blocks(first, 12), append_roots)
.expect("commit first blocks");
let index = scan_rebuild_operation_index(&bundle);
commit_index(&mut bundle, &index);
assert!(bundle.usable_operation_index().is_some());
// Grow the operation set WITHOUT updating the index (the closure carries
// the old `operation_index_root` forward).
bundle
.commit(&staged_envelope_blocks(later, 6), append_roots)
.expect("commit later blocks");
let root = bundle
.manifest()
.operation_index_root
.expect("the stale index is still referenced");
// The chunk itself is intact — readable and well-formed —
let stale = bundle
.read_operation_index(&root)
.expect("the stale index chunk still reads and decodes");
// — but STALE: its block set no longer equals the operation roots.
assert!(!stale.covers(&bundle.manifest().operation_roots));
assert!(
bundle.usable_operation_index().is_none(),
"a stale index must be rejected"
);
// The same verdict from a cold reopen; then rebuild from blocks.
let image = bundle.into_store().into_bytes();
let mut reopened = Bundle::open(MemStore::from_bytes(image)).expect("reopen");
assert!(reopened.usable_operation_index().is_none());
let rebuilt = scan_rebuild_operation_index(&reopened);
commit_index(&mut reopened, &rebuilt);
let usable = reopened
.usable_operation_index()
.expect("the rebuilt index is usable");
assert_eq!(usable, rebuilt);
assert_index_locates_all(&reopened, &usable, &envelopes);
}
/// A defective operation index — a garbage payload staged as the index chunk,
/// or on-disk corruption of a valid index chunk's bytes — must be rejected
/// *without* being treated as bundle corruption (Chapter 8 §"Canonical and
/// Non-Canonical Manifest Roots"): the bundle still opens cleanly, canonical
/// chunks verify, canonical reads work, and only `usable_operation_index`
/// says `None` (rebuild from blocks).
pub fn assert_corrupt_operation_index_is_not_bundle_corruption(seed: u64) {
let mut rng = Rng::new(seed);
let envelopes = generators::operation_envelopes(&mut rng, 24, 3, 8, 8);
let uuid = FileUuid(rng.array16());
let doc = DocumentId(rng.array16());
let mut bundle =
Bundle::create(MemStore::new(), uuid, Manifest::empty(doc)).expect("create bundle");
bundle
.commit(&staged_envelope_blocks(&envelopes, 8), append_roots)
.expect("commit operation blocks");
// (a) A garbage index chunk, staged and referenced like a real one. The
// commit succeeds (the bundle does not interpret non-canonical chunks);
// readers must reject it and rebuild.
bundle
.commit(
&[StagedChunk::operation_index(b"not an index".to_vec())],
|ctx| {
let mut m = ctx.previous_manifest.clone();
m.operation_index_root = Some(ctx.new_chunks[0]);
m
},
)
.expect("commit garbage index chunk");
let image = bundle.into_store().into_bytes();
let mut reopened = Bundle::open(MemStore::from_bytes(image))
.expect("a defective index must not prevent opening");
assert!(reopened.anomalies().is_empty());
assert!(!reopened.is_read_only());
reopened
.verify_canonical_chunks()
.expect("canonical chunks are intact");
assert!(
reopened.usable_operation_index().is_none(),
"a malformed index is rejected: rebuild, not corruption"
);
for root in &reopened.manifest().operation_roots.clone() {
reopened
.read_operation_block(root)
.expect("canonical block reads are unaffected");
}
// Rebuild-from-blocks restores a usable index.
let rebuilt = scan_rebuild_operation_index(&reopened);
commit_index(&mut reopened, &rebuilt);
let usable = reopened
.usable_operation_index()
.expect("the rebuilt index is usable");
assert_index_locates_all(&reopened, &usable, &envelopes);
// (b) On-disk corruption of the now-valid index chunk's payload region.
let valid_image = reopened.into_store().into_bytes();
let probe = Bundle::open(MemStore::from_bytes(valid_image.clone())).expect("reopen");
let root = probe
.manifest()
.operation_index_root
.expect("the index is referenced");
let mut corrupt = valid_image;
corrupt[(root.offset + 3) as usize] ^= 0xFF;
let reopened = Bundle::open(MemStore::from_bytes(corrupt))
.expect("index-region corruption must not prevent opening");
assert!(reopened.anomalies().is_empty());
reopened
.verify_canonical_chunks()
.expect("canonical chunks are intact");
// The raw read surfaces the hash defect for diagnostics …
assert!(
matches!(
reopened.read_operation_index(&root),
Err(BundleError::ChunkHashMismatch { .. })
),
"the raw index read reports the hash mismatch"
);
// … while the reject-and-rebuild gate simply declares it unusable.
assert!(reopened.usable_operation_index().is_none());
for root in &reopened.manifest().operation_roots {
reopened
.read_operation_block(root)
.expect("canonical block reads are unaffected");
}
}
// --- The edit-barrier declaration harness (Chapter 8 §"Forward Compatibility
// and Edit Barriers" / §"Behavior Under Unknown Extensions") ------------------
/// The end-to-end edit-barrier round-trip: a manifest [`ExtensionDeclaration`]
/// carrying *really-encoded* barrier blobs (the provisional canonical byte
/// form owned by `epiphany-layout-ir`) commits, reopens byte-verbatim — the
/// bundle preserves the blobs opaquely, exactly as it preserves unknown
/// extension chunks — decodes through the owning layer's codec, and
/// *evaluates*: the reopened barrier prohibits exactly the edits the authored
/// one did.
pub fn run_barrier_declaration_roundtrip(seed: u64) {
use epiphany_core::{EventId, TypedObjectId};
use epiphany_layout_ir::{
decode_affected_object_kinds, decode_edit_barriers, encode_affected_object_kinds,
encode_edit_barriers, AlwaysLiveOracle, BarrierCondition, BarrierScope, EditBarrier,
EditContext, ObjectKind, OperationKindTag,
};
let mut rng = Rng::new(seed);
let protected = TypedObjectId::Event(EventId::from_raw(rng.next_u64() as u128));
// One deterministic barrier the evaluation half asserts against, plus one
// generated barrier so arbitrary shapes ride the same declaration.
let locked_barrier = EditBarrier {
scope: BarrierScope::ObjectSet(vec![protected]),
affected_object_kinds: vec![ObjectKind::of(&protected)],
prohibited_operation_kinds: vec![OperationKindTag::DeleteEvent],
condition: BarrierCondition::Always,
};
let authored = vec![
locked_barrier.clone(),
crate::layout_stub::gen_edit_barrier(&mut rng),
];
let kinds = vec![ObjectKind::of(&protected)];
let declaration = ExtensionDeclaration {
extension_id: ExtensionId(rng.array16()),
version: SemVer {
major: 1,
minor: 0,
patch: 0,
},
required: false,
preserved_chunk_roots: Vec::new(),
affected_object_kinds: encode_affected_object_kinds(&kinds),
edit_barriers: encode_edit_barriers(&authored),
};
let extension_id = declaration.extension_id;
let barrier_bytes = declaration.edit_barriers.clone();
let kind_bytes = declaration.affected_object_kinds.clone();
// Commit a manifest carrying the declaration; reopen from the raw image.
let uuid = FileUuid(rng.array16());
let doc = DocumentId(rng.array16());
let mut bundle =
Bundle::create(MemStore::new(), uuid, Manifest::empty(doc)).expect("create bundle");
bundle
.commit(&staged(&[rng.byte_vec(1, 40)]), |ctx| {
let mut m = append_roots(ctx);
m.extension_declarations.push(declaration.clone());
m
})
.expect("commit the declaration");
let image = bundle.into_store().into_bytes();
let reopened = Bundle::open(MemStore::from_bytes(image)).expect("reopen");
// The bundle preserved the opaque blobs verbatim ...
let decl = reopened
.manifest()
.extension_declarations
.iter()
.find(|d| d.extension_id == extension_id)
.expect("the declaration survives the commit");
assert_eq!(
decl.edit_barriers, barrier_bytes,
"barrier blob is verbatim"
);
assert_eq!(
decl.affected_object_kinds, kind_bytes,
"object-kind blob is verbatim"
);
// ... the owning layer's codec decodes them, re-encoding byte-identically ...
let decoded = decode_edit_barriers(&decl.edit_barriers).expect("the canonical blob decodes");
assert_eq!(
encode_edit_barriers(&decoded),
barrier_bytes,
"decode → re-encode is byte-identical"
);
assert_eq!(
decode_affected_object_kinds(&decl.affected_object_kinds).expect("kinds decode"),
kinds
);
// ... and the reopened barrier evaluates as authored: deleting the
// protected event is prohibited; a different operation class and a
// different object are not.
let reopened_barrier = decoded
.iter()
.find(|b| **b == locked_barrier)
.expect("the locked barrier survives the round-trip");
let ctx = EditContext::default();
assert!(reopened_barrier.prohibits_edit(
OperationKindTag::DeleteEvent,
&protected,
&ctx,
&AlwaysLiveOracle
));
assert!(!reopened_barrier.prohibits_edit(
OperationKindTag::InsertEvent,
&protected,
&ctx,
&AlwaysLiveOracle
));
let other = TypedObjectId::Event(EventId::from_raw(u128::MAX));
assert!(!reopened_barrier.prohibits_edit(
OperationKindTag::DeleteEvent,
&other,
&ctx,
&AlwaysLiveOracle
));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn barrier_declaration_roundtrip() {
for seed in 0..8u64 {
run_barrier_declaration_roundtrip(0xBA22_1E20_0000 + seed);
}
}
#[test]
fn crash_recovery_smoke() {
run_crash_recovery(400, 0xF00D_BEEF_1234_5678);
@ -253,4 +666,19 @@ mod tests {
fn manifest_selection_passes() {
run_manifest_selection(7);
}
#[test]
fn operation_index_end_to_end() {
assert_operation_index_end_to_end(0x0091_D0EC_5EED_0001);
}
#[test]
fn stale_operation_index_is_rejected_and_rebuilt() {
assert_stale_operation_index_rejected_and_rebuilt(0x0091_D0EC_5EED_0002);
}
#[test]
fn corrupt_operation_index_is_not_bundle_corruption() {
assert_corrupt_operation_index_is_not_bundle_corruption(0x0091_D0EC_5EED_0003);
}
}

View File

@ -727,6 +727,17 @@ pub fn gen_layout_constraint(rng: &mut Rng) -> LayoutConstraint {
}
}
/// A constraint strength (both variants).
pub fn gen_constraint_strength(rng: &mut Rng) -> ConstraintStrength {
if rng.boolean() {
ConstraintStrength::Required
} else {
ConstraintStrength::Preferred {
weight: (rng.range(1, 1_000) as f64) / 100.0,
}
}
}
/// A bundled glyph metric (drawn from the in-tree Bravura table).
pub fn gen_glyph_metric(rng: &mut Rng) -> GlyphMetric {
BRAVURA_METRICS[rng.below(BRAVURA_METRICS.len() as u64) as usize].clone()
@ -790,6 +801,21 @@ pub fn gen_override_target(rng: &mut Rng) -> OverrideTarget {
}
}
/// A break anchor for a generated break override (a representative subset: a
/// wall-clock instant or an event anchor without offset).
pub fn gen_break_anchor(rng: &mut Rng) -> epiphany_core::TimeAnchor {
if rng.boolean() {
epiphany_core::TimeAnchor::WallClock {
time: epiphany_core::WallClockTime(rng.range(0, 10_000) as i64),
}
} else {
epiphany_core::TimeAnchor::Event {
id: epiphany_core::EventId::from_raw(rng.next_u64() as u128),
offset: epiphany_core::AnchorOffset::Zero,
}
}
}
pub fn gen_override_kind(rng: &mut Rng) -> OverrideKind {
match rng.below(9) {
0 => OverrideKind::StemDirection(if rng.boolean() {
@ -799,8 +825,12 @@ pub fn gen_override_kind(rng: &mut Rng) -> OverrideKind {
}),
1 => OverrideKind::AccidentalParenthesized(rng.boolean()),
2 => OverrideKind::AccidentalVisible(rng.boolean()),
3 => OverrideKind::SystemBreak,
4 => OverrideKind::PageBreak,
3 => OverrideKind::SystemBreak {
anchor: gen_break_anchor(rng),
},
4 => OverrideKind::PageBreak {
anchor: gen_break_anchor(rng),
},
5 => OverrideKind::HiddenObject,
6 => OverrideKind::CustomPosition(gen_point(rng)),
7 => OverrideKind::LedgerLineSuppression,
@ -1286,6 +1316,43 @@ mod tests {
use epiphany_core::TypedObjectId;
use epiphany_determinism::CanonicalEncode;
#[test]
fn edit_barrier_blob_codec_round_trips_generated_barriers() {
// Property gate for the provisional manifest-blob byte form
// (`ExtensionDeclaration.edit_barriers` / `.affected_object_kinds`):
// any generated barrier set encodes to a blob that decodes and
// re-encodes byte-identically, and each decoded barrier's own canonical
// bytes round-trip through the single-barrier decoder.
let mut rng = Rng::new(0xBA22_1E2C_0DEC);
for _ in 0..256 {
let barriers: Vec<EditBarrier> = (0..rng.range_usize(0, 4))
.map(|_| gen_edit_barrier(&mut rng))
.collect();
let blob = encode_edit_barriers(&barriers);
let decoded = decode_edit_barriers(&blob).expect("a canonical blob decodes");
assert_eq!(
encode_edit_barriers(&decoded),
blob,
"decode → re-encode is byte-identical"
);
for barrier in &decoded {
let bytes = barrier.to_canonical_bytes();
assert_eq!(
EditBarrier::decode_canonical_bytes(&bytes).as_ref(),
Ok(barrier)
);
}
let kinds: Vec<ObjectKind> = (0..rng.range_usize(0, 6))
.map(|_| gen_object_kind(&mut rng))
.collect();
let kind_blob = encode_affected_object_kinds(&kinds);
let decoded_kinds =
decode_affected_object_kinds(&kind_blob).expect("a canonical blob decodes");
assert_eq!(encode_affected_object_kinds(&decoded_kinds), kind_blob);
}
}
#[test]
fn ir_generators_are_deterministic_and_well_formed() {
let mut a = Rng::new(13);
@ -1295,11 +1362,14 @@ mod tests {
let generated = gen_logical_layout_ir(&mut Rng::new(14));
let constrained = try_to_constrained(&generated)
.expect("generated logical IR must be structurally transformable");
// The spacing stage emits real constraints, which the stub does not
// evaluate: the solve is renderable either way, and satisfaction is
// claimed exactly when the problem is constraint-free.
let report = StubSolver.solve(&constrained, &SolverConfig::default());
assert!(report.status.is_renderable());
assert_eq!(
StubSolver
.solve(&constrained, &SolverConfig::default())
.status,
SolveStatus::Solved
report.satisfied_hard_constraints,
constrained.constraints.is_empty()
);
// Provenance ids are consistent with their synthesis: a projected one
// (no synthesis) carries the source-only id; a synthesized one does not.
@ -1404,6 +1474,7 @@ mod tests {
let _ = gen_spring_slot(&mut rng);
let _ = gen_constrained_layout_region(&mut rng);
let _ = gen_layout_constraint(&mut rng);
let _ = gen_constraint_strength(&mut rng);
let _ = gen_engraving_override(&mut rng);
let _ = gen_resolved_staff(&mut rng);
let _ = gen_resolved_measure(&mut rng);

View File

@ -229,11 +229,14 @@ pub fn assert_decompositions_reconstruct(score: &Score, ann: &DerivedAnnotations
);
}
// Map and taxonomy count agree.
// Map and taxonomy counts agree: the effective map is exactly the inferred
// plus authored-override outcomes (an authored `DecompositionAttachment`
// outranking `Inferred` replaces the derived one and is counted
// distinctly, mirroring the spelling buckets).
assert_eq!(
ann.decompositions.len(),
ann.taxonomy.decompositions_inferred,
"decomposition map size disagrees with the taxonomy count"
ann.taxonomy.decompositions_inferred + ann.taxonomy.decompositions_authored,
"decomposition map size disagrees with the taxonomy counts"
);
}

View File

@ -42,11 +42,30 @@ code instead is the failure mode this batch exists to prevent.
| P12-H7 | `epiphany-core` H | Authored decompositions for inference-ineligible events: an authored attachment is exactly how a user would notate an *ungriddable* event, but the derived-annotation surface (mirroring spelling, which likewise ignores attachments on spelling-unavailable pitches) only resolves overrides where inferred output exists. Needs a spec answer for both pre-passes. | G / Pass 12 (pre-passes) |
| P12-K3 | `epiphany-ops` K | Content modification of a `SYSTEM_DERIVED` pitch: `ModifyEvent`/`ModifyIdentifiedPitch` can rewrite a synthetic pitch's intrinsic content in place, silently invalidating the id's content-derivation (Invariant 11). The new reduction-time collision check deliberately does not treat in-place rewrites as mints. Decide whether reduction must refuse them outright. | G / Pass 12 (identity) |
| P12-K4 | `epiphany-ops` K | `ResolveConflict` beyond the concurrent case: the spec pins outcomes only for *concurrent* differing resolves; the implementation applies the same rule to causally-later resolves (so an intentional re-resolution cannot supersede) and reads `AlreadyApplied` for any resolve against a `Dismissed` conflict. Also: the meta-conflict record cannot name the contested conflict in `affected_objects` because `TypedObjectId` has no Conflict kind. Pin the causally-later semantics and decide whether conflict records need an addressable object kind. | G / Pass 12 (conflict resolution) |
| P12-C1 | `epiphany-ops` C | Multi-source cue re-anchoring: the rule table's action is plain "cascade-delete" (implemented: any source deletion cascades, like Tie) but its rationale ("a cue with no source is meaningless") implies truncate-while-any-source-survives. Pin one reading. | G / Pass 12 (re-anchoring) |
| P12-C2 | `epiphany-ops` C | Graphic-gesture Range "truncate" is underdetermined. Implemented reading: a dead event-anchored range endpoint moves to its containing region's edge (start→Start, end→End, zero offset). Define "truncate" normatively. | G / Pass 12 (re-anchoring) |
| P12-C3 | `epiphany-ops` C | Analytical-annotation range reconstruction: a wall-clock (region-relative) or indeterminate event span cannot be expressed as a stored `Range` anchor without region-origin resolution, so such annotations orphan. State whether orphaning is the sanctioned outcome or an expressible form is required. | G / Pass 12 (re-anchoring) |
| P12-C4 | `epiphany-ops` C | `ReanchorReason` has no same-canvas variant: a rank-4 (same-canvas) survivor is recorded `ExplicitFallback` rather than appending a discriminant. Ratify a beyond-region reason or bless the fallback recording. | G / Pass 12 (re-anchoring) |
| P12-K5 | `epiphany-ops` K | Equivocation resolution's third path — a profile-declared deterministic selection function (e.g. lowest-hash-wins) — is unpinned and unimplemented; the reducer has no policy hook. Referenced from the catalog §ResolveEquivocation rationale. | G / Pass 12 (equivocation) |
| P12-K6 | `epiphany-ops` K | `ResolveEquivocation` edge semantics to pin: cascaded resolves (a promoted candidate that is itself a resolve does not govern a further promotion — single-pass implemented, vs. fixpoint); interaction with HLC-monotonicity quarantine (may a quarantined resolve govern?); a resolve held pending by its own causal gaps still governs promotion (set-level rule) while its effect stays pending; whether the invalid-target/chosen no-op warrants a dedicated `PreconditionFailureReason` (implemented: reuses `TargetMissing`). | G / Pass 12 (equivocation) |
| P12-K7 | `epiphany-ops` K | Advisory-precondition catalog: the spec declares the duration-boundary advisory only for InsertEvent while the implementation also applies it to ModifyEvent's replacement value — state ModifyEvent's bucket explicitly. Also: which advisory checks are blocked on graph-model completion (Instrument declared range; a Region slur-spanning permission flag; which extent shapes constitute a resolvable musical end bound). | G / Pass 12 (validation) |
| P12-I4 | `epiphany-layout-ir` I | Constraint-strength attachment: Ch9 defines `ConstraintStrength` and says the solver consumes constraints "in normalized form", but neither the normalized form nor Ch7's `LayoutConstraint` provides a channel for an instance to carry strength. Implemented rule: break strength = `BreakKind` (Hard→Required, Soft→Preferred{1.0}); other core families Required; `Registered` conservative Required. Bless the rule or add a strength field. | G / Pass 12 (solver) |
| P12-I5 | `epiphany-layout-ir` I | No renderable status exists for "constraints present but not evaluated": every renderable `SolveStatus` is documented as "all hard constraints satisfied", leaving a below-conformance passthrough solver no honest report. Implemented encoding: `SolvedWithWarnings` + `satisfied_hard_constraints == false` + a warning. Sanction it or define a non-evaluating-tier report shape. | G / Pass 12 (solver) |
| P12-I6 | `epiphany-layout-ir` I | The spacing pass MUST "build collision constraints" but no per-tier minimum emission set is named. Implemented floor: successive-notehead-column no-collision chains + per-glyph region containment + user-break constraints. A normative Minimal-tier floor would make the acceptance surface testable. | G / Pass 12 (solver) |
| P12-D1 | `epiphany-bundle` D | Operation-index provisional encoding (block-refs + id-sorted entries with u32 block ordinal and u32 in-block offset; golden-locked) awaiting Binary Format companion ratification, together with: the offset's meaning (first content byte within the uncompressed block payload), a normative definition of "stale" (implemented: index block-set ≠ manifest `operation_roots` under full-`ChunkRef` equality), the one-slot-per-id invariant, the load-bearing property that the envelope encoding *leads* with the 16-byte OperationId, and whether the commit-time "grown significantly" SHOULD gets a threshold or stays implementation-defined. | J (Binary Format companion) |
| P12-E1 | `epiphany-layout-ir` E | Provisional canonical byte form for the `EditBarrier`/`BarrierScope`/`BarrierCondition` tree and the two `ExtensionDeclaration` blobs (`push_set` framing, u64 LE lengths; golden-locked). Ratify into the Binary Format companion. | J (Binary Format companion) |
| P12-E2 | `epiphany-layout-ir` E | The spec places no bound on `BarrierCondition` recursion; the decoder needs one against adversarial bytes. `MAX_CONDITION_DEPTH = 64` implemented — ratify a normative bound or bless the constant. | J (Binary Format companion) |
| P12-E3 | `epiphany-layout-ir` E | Barrier `ObjectKind` byte form = the `TypedObjectId` 16-bit discriminant (2 LE bytes) with open-value decode (unknown kinds never match, preserving append-only forward compat). Ratify representation + stance. | J (Binary Format companion) |
| P12-E4 | `epiphany-editor-core` E | Barrier matching for operations with no graph target (`SetMetadata`, `DeclareTransaction` — implemented: score-wide barriers only) and for opaque `Registered` operations (implemented: fully conservative match) is unspecified. | G (Ch. 8) |
| P12-E5 | `epiphany-editor-core` E | The unsafe-edit tombstone MUST has no defined mechanism: the manifest-side form (drop declaration + preserved roots? an explicit tombstone record?), interaction with `required = true`, and whether crossing immediately deactivates the extension's remaining barriers (implemented: yes, recorded via `extensions_requiring_tombstone()` for the next bundle write). | G (Ch. 8) |
## Not yet open elsewhere
Agent I (Track A) has contributed P12-I1..I3 above. Track B's Agent K has
contributed P12-K1..K4; H has contributed P12-H1..H7 (H6/H7 from the 2026-07
spec-compliance audit follow-up, alongside K3/K4). Agent J (Binary Format
companion) has not yet contributed; when it does, append rows — the batch is
already open, so it joins directly (no new threshold).
Agent I (Track A) has contributed P12-I1..I6. Track B's Agent K has
contributed P12-K1..K7; H has contributed P12-H1..H7 (H6/H7 from the 2026-07
spec-compliance audit follow-up, alongside K3/K4). The 2026-07 Push-3 wiring
work added C1..C4 (re-anchoring), D1 (bundle operation index), and E1..E5
(edit barriers). Agent J (Binary Format companion) has not yet contributed;
when it does, append rows — the batch is already open, so it joins directly
(no new threshold). Note the P12-D1/E1/E2/E3 rows are *inputs* to J's
companion rather than G-dispositions.

View File

@ -30,6 +30,17 @@ P12-H/K rows await G's ratification alongside Phase 2's open questions and are
reduction-rule behavior text). Core spec: revision-history row "Pass 12
tranche 1 (audit spec-alignment)" appended.
## Tranche 1 addendum (2026-07-02, Push-3 enablers)
Two further ratifications landed the same day as spec-side *enablers* for the
Push-3 wiring work — each defines normative text the implementation then built
against (spec-first, per the batch discipline):
| Item | Disposition | Spec locus | Consumer |
|---|---|---|---|
| `ResolveEquivocation` meta-operation | **fixed (dangling prose reference)** — the core spec named the operation only in prose (§Equivocation, resolution path 2) with no payload schema, no catalog entry, no K1 slot. Ratified: catalog §ResolveEquivocation pins `ResolveEquivocationPayload { target: OperationId, chosen: EnvelopeHash }`, order-independent earliest-resolve-governs promotion, `AlreadyApplied` idempotence, `StructuralFieldCollision` on `equivocation_resolution` for differing resolves, precondition no-ops, and the equivocated-resolve exclusion; core spec `OperationPayload` gains the variant. Catalog 0.3.0 → 0.4.0. The profile-policy path stays open (P12-K5) | operation_catalog §ResolveEquivocation; core_spec §Operation Envelope | `epiphany-ops` (payload discriminant 3 appended; set-level promotion pre-pass) |
| Anchored break overrides | **fixed (representational gap)**`OverrideKind::SystemBreak`/`PageBreak` were nullary, so a break's *position* was unrepresentable and the logical-stage projection required by §Engraving Overrides could not exist. Ratified: both kinds carry `anchor: TimeAnchor`; the `ScoreGraph` target names the owning region; projected break overrides carry `Internal` origin (authorship lives in the op log, P11-C8) | core_spec Ch7 §Engraving Overrides listing + note | `epiphany-layout-ir` (`to_logical` projection + paired `UserOverride` decisions) |
**Deliberately not touched here** (still open in `PASS12_BATCH.md`): every
P12-H/K row (algorithm-id ratification, decomposition precedence
configurability, authored decompositions for ineligible events, system-pitch

Binary file not shown.

View File

@ -5678,6 +5678,12 @@ pub enum OperationPayload {
/// transaction; the realization of "undo" (see
/// Section~\ref{sec:semops:undo}).
UndoTransaction(UndoTransactionPayload),
/// A meta-operation that resolves an equivocated operation slot
/// by naming the chosen candidate envelope (see
/// Section~\ref{sec:semops:equivocation}; payload schema in the
/// Operation Catalog companion).
ResolveEquivocation(ResolveEquivocationPayload),
}
/// The catalog of primitive operation kinds. The variants listed
@ -8407,8 +8413,8 @@ pub enum OverrideKind {
AccidentalVisible(bool),
NoteheadShape(NoteheadShape),
BeamGeometry(BeamGeometryOverride),
SystemBreak,
PageBreak,
SystemBreak { anchor: TimeAnchor },
PageBreak { anchor: TimeAnchor },
HiddenObject,
CustomPosition(Point2D),
EnharmonicSpelling(PitchSpelling),
@ -8417,6 +8423,16 @@ pub enum OverrideKind {
// ... extensible
}
// System- and page-break overrides address a *position*, not an
// object: the break kind carries the break's TimeAnchor, while the
// override's ScoreGraph target names the owning region. They are
// projected during the logical stage from the score graph's
// authoritative user_system_breaks / user_page_breaks lists
// (Chapter 5). Break authorship (author, timestamp) lives in the
// operation log, not the materialized break lists, so projected
// break overrides carry OverrideOrigin::Internal until the
// snapshot-undo refinement (P11-C8) surfaces authorship.
pub enum OverridePriority {
/// The engraver MUST honor this override. Failure produces an
/// error rather than a silent override-of-the-override.
@ -14518,6 +14534,25 @@ layouts they own versus inherit:
\texttt{spec/PASS12\_RATIFICATION\_LOG.md}; the Pass 12 batch
itself remains open.
\\
\today & Pass 12 tranche 1 addendum (Push-3 enablers) &
Two enabler ratifications for the machinery-wiring work.
\texttt{ResolveEquivocation}, previously a dangling prose
reference in the equivocation section, is now a defined
meta-operation: \texttt{OperationPayload} gains the variant here,
and the Operation Catalog (0.3.0 $\rightarrow$ 0.4.0) pins its
payload schema (\texttt{\{ target: OperationId, chosen:
EnvelopeHash \}}), the order-independent
earliest-resolve-governs promotion, idempotence, differing-resolve
meta-conflict, and precondition no-ops; the profile-declared
selection-policy path remains open (P12-K5). Engraving break
overrides became position-addressable:
\texttt{OverrideKind::SystemBreak}/\texttt{PageBreak} carry the
break's \texttt{TimeAnchor} (the \texttt{ScoreGraph} target names
the owning region; projected break overrides carry
\texttt{Internal} origin pending P11-C8 authorship surfacing),
closing the representational gap that blocked the logical-stage
override projection §Engraving Overrides requires.
\\
\bottomrule
\end{longtable}

Binary file not shown.

View File

@ -226,7 +226,7 @@
{\Large\scshape\color{epiphanyslate}Operation Catalog}\\[6pt]
{\large\itshape\color{epiphanyslate}A companion to the Core Specification}\\[14pt]
{\color{epiphanygold}\rule{3in}{0.8pt}}\\[24pt]
{\normalsize\color{epiphanyink}Version 0.3.0 --- Phase 2 (K0 representative + broad-K0 M2 groups)}\\[4pt]
{\normalsize\color{epiphanyink}Version 0.4.0 --- Phase 2 (K0 representative + broad-K0 M2 groups)}\\[4pt]
{\small\color{epiphanyslate}Normative for the operation kinds it defines}
\vfill
\end{titlepage}
@ -855,6 +855,54 @@ representable. The catalog records that \texttt{Dismiss} is the action that
selects it (resolving the v0 ambiguity P11-C10).
\end{rationale}
\section{ResolveEquivocation (meta-operation)}
\label{sec:k0:resolve-equivocation}
\textbf{Payload schema.} \texttt{ResolveEquivocationPayload \{ target:
OperationId, chosen: EnvelopeHash \}} --- the equivocated slot and the
candidate envelope (by canonical-bytes hash) that shall stand. Value-complete.
\textbf{Canonical encoding.} \texttt{target} (16 canonical bytes), then
\texttt{chosen} (32 bytes), per the codec baseline.
\textbf{Reduction rule.} Order-independent promotion of an equivocated slot
(core specification Chapter~6, \sectionsc{Equivocation}): when the operation
set holds an \texttt{Equivocated} slot for \texttt{target} and \texttt{chosen}
names one of its candidates, the slot reduces as if it had always been
\texttt{Single} with the chosen envelope --- the chosen candidate contributes
to canonical reduction at its own canonical position, and operations that were
pending on the equivocated id unblock. Among multiple resolves naming the same
slot, the one earliest in canonical order governs; a later resolve naming the
\emph{same} candidate reduces idempotently (\texttt{AlreadyApplied}). The
resolve operation must itself occupy a \texttt{Single} slot; an equivocated
resolve is excluded from reduction like any other equivocated slot. A resolved
slot records no \texttt{OperationSlotEquivocated} anomaly; the losing
candidates remain in the diagnostic candidate store only.
\textbf{Conflict cases.} Two resolves of one slot naming \emph{differing}
candidates produce a \texttt{StructuralFieldCollision} meta-conflict on the
field \texttt{equivocation\_resolution}, recording the governing resolve
(earlier in canonical order) as winner and the later as loser, with both
operations in \texttt{caused\_by} --- the same discipline as
\texttt{ResolveConflict} meta-conflicts. Preconditions: a resolve whose
\texttt{target} is not an equivocated slot, or whose \texttt{chosen} is not
among the slot's candidates, is a precondition no-op.
\textbf{Undo semantics.} Mints nothing; not inverted under the prototype's
minted-object undo (P11-C8).
\textbf{Re-anchoring.} Not applicable (the payload references an operation
slot, not a graph object).
\begin{rationale}
The core specification names three resolution paths for an equivocated slot:
transport-level reconciliation, this explicit operation, and a
profile-declared deterministic selection policy. This entry pins the schema
for the explicit-operation path, which the core specification previously named
only in prose. The profile-policy path remains unpinned and unimplemented ---
a Pass-12 question (P12-K5).
\end{rationale}
\section{UndoTransaction (meta-operation)}
\label{sec:k0:undo}