diff --git a/CONFORMANCE.md b/CONFORMANCE.md new file mode 100644 index 0000000..ce4cf6c --- /dev/null +++ b/CONFORMANCE.md @@ -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. diff --git a/Cargo.lock b/Cargo.lock index a449985..72a3b56 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 35aef9c..446ab7d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/crates/epiphany-bundle/Cargo.toml b/crates/epiphany-bundle/Cargo.toml index b3b363b..33625a9 100644 --- a/crates/epiphany-bundle/Cargo.toml +++ b/crates/epiphany-bundle/Cargo.toml @@ -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" diff --git a/crates/epiphany-bundle/DECISIONS.md b/crates/epiphany-bundle/DECISIONS.md index 9dc4a23..893053b 100644 --- a/crates/epiphany-bundle/DECISIONS.md +++ b/crates/epiphany-bundle/DECISIONS.md @@ -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 diff --git a/crates/epiphany-bundle/README.md b/crates/epiphany-bundle/README.md index 822e745..61d6852 100644 --- a/crates/epiphany-bundle/README.md +++ b/crates/epiphany-bundle/README.md @@ -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. diff --git a/crates/epiphany-bundle/src/block.rs b/crates/epiphany-bundle/src/block.rs index 00b434d..43a30f6 100644 --- a/crates/epiphany-bundle/src/block.rs +++ b/crates/epiphany-bundle/src/block.rs @@ -46,11 +46,53 @@ pub fn encode_block(envelopes: &[Vec]) -> Vec { /// 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>, 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, 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> = (0..100).map(|i| vec![i as u8; 8]).collect(); diff --git a/crates/epiphany-bundle/src/bundle.rs b/crates/epiphany-bundle/src/bundle.rs index fdad198..74bfc7f 100644 --- a/crates/epiphany-bundle/src/bundle.rs +++ b/crates/epiphany-bundle/src/bundle.rs @@ -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) -> 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 Bundle { 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 { + 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 { + 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, 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 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, 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, + compression: CompressionAlgorithm, + declared_len: u64, +) -> Result, 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, 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, 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, + 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, + 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 = 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 = 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 = 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 = 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 { + 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(_)) + )); + } } diff --git a/crates/epiphany-bundle/src/chunk.rs b/crates/epiphany-bundle/src/chunk.rs index 5414f1a..1c78f48 100644 --- a/crates/epiphany-bundle/src/chunk.rs +++ b/crates/epiphany-bundle/src/chunk.rs @@ -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), diff --git a/crates/epiphany-bundle/src/codec.rs b/crates/epiphany-bundle/src/codec.rs index e35b232..a201622 100644 --- a/crates/epiphany-bundle/src/codec.rs +++ b/crates/epiphany-bundle/src/codec.rs @@ -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, 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, DecodeError> { + Ok(self.get_var_slice()?.to_vec()) } /// A `u32`-length-prefixed UTF-8 string. diff --git a/crates/epiphany-bundle/src/error.rs b/crates/epiphany-bundle/src/error.rs index b5f62eb..e3db922 100644 --- a/crates/epiphany-bundle/src/error.rs +++ b/crates/epiphany-bundle/src/error.rs @@ -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, } diff --git a/crates/epiphany-bundle/src/lib.rs b/crates/epiphany-bundle/src/lib.rs index 439f4ed..9c3e9bd 100644 --- a/crates/epiphany-bundle/src/lib.rs +++ b/crates/epiphany-bundle/src/lib.rs @@ -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, diff --git a/crates/epiphany-bundle/src/opindex.rs b/crates/epiphany-bundle/src/opindex.rs new file mode 100644 index 0000000..d10a1f4 --- /dev/null +++ b/crates/epiphany-bundle/src/opindex.rs @@ -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, + /// The entries, strictly ascending by id bytes. + entries: Vec, +} + +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 { + 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 = (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 = order.iter().map(|&i| blocks[i].0).collect(); + + let mut entries: Vec = 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 { + 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 { + 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 = 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])); + } +} diff --git a/crates/epiphany-core/DECISIONS.md b/crates/epiphany-core/DECISIONS.md index 0b5fdcd..6a1256b 100644 --- a/crates/epiphany-core/DECISIONS.md +++ b/crates/epiphany-core/DECISIONS.md @@ -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. diff --git a/crates/epiphany-core/src/lib.rs b/crates/epiphany-core/src/lib.rs index ad58b4b..f0220f0 100644 --- a/crates/epiphany-core/src/lib.rs +++ b/crates/epiphany-core/src/lib.rs @@ -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}; diff --git a/crates/epiphany-core/src/pitch.rs b/crates/epiphany-core/src/pitch.rs index ca10f2a..d8c6bca 100644 --- a/crates/epiphany-core/src/pitch.rs +++ b/crates/epiphany-core/src/pitch.rs @@ -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 { +/// +/// 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 { // 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 diff --git a/crates/epiphany-core/src/prepass.rs b/crates/epiphany-core/src/prepass.rs index 8cbf847..79a294e 100644 --- a/crates/epiphany-core/src/prepass.rs +++ b/crates/epiphany-core/src/prepass.rs @@ -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; diff --git a/crates/epiphany-core/src/prepass/tests.rs b/crates/epiphany-core/src/prepass/tests.rs index f2cf4b4..023ac7a 100644 --- a/crates/epiphany-core/src/prepass/tests.rs +++ b/crates/epiphany-core/src/prepass/tests.rs @@ -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::(); + 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::(); + 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 diff --git a/crates/epiphany-core/src/tempo.rs b/crates/epiphany-core/src/tempo.rs index 0f7d000..0689ef7 100644 --- a/crates/epiphany-core/src/tempo.rs +++ b/crates/epiphany-core/src/tempo.rs @@ -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 { @@ -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. diff --git a/crates/epiphany-editor-core/src/barriers.rs b/crates/epiphany-editor-core/src/barriers.rs new file mode 100644 index 0000000..30328d5 --- /dev/null +++ b/crates/epiphany-editor-core/src/barriers.rs @@ -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, +} + +/// 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, staff_instance: Option) -> 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 { + 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 { + let events = |ids: Vec| 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 { + 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 +} diff --git a/crates/epiphany-editor-core/src/lib.rs b/crates/epiphany-editor-core/src/lib.rs index 4b385bb..29dc1ae 100644 --- a/crates/epiphany-editor-core/src/lib.rs +++ b/crates/epiphany-editor-core/src/lib.rs @@ -44,8 +44,12 @@ //! GUI plugs in the real `Engraver`, the stub, or any conformant solver. It //! produces a [`RenderIR`]; turning that into pixels is the renderer's job. +mod barriers; + +pub use barriers::ActiveExtension; + use std::cmp::Reverse; -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::fmt; use epiphany_core::prepass::{derive_annotations, DerivedAnnotations, PrePassProfile}; @@ -55,20 +59,20 @@ use epiphany_core::{ PitchSpaceId, PitchSpacePosition, PitchSpelling, PitchedEvent, RationalTime, RegionId, RegionTimeModel, ReplicaId, ScalePosition, Score, SpellingDirective, SpellingNominal, SpellingScope, SpellingSourceKind, StaffId, StaffInstance, StaffInstanceId, StemConfiguration, - TimeSignature, TimeSignatureDisplay, TransactionId, TuningReference, TypedObjectId, VoiceId, - WallClockTime, + TimeSignature, TimeSignatureDisplay, TransactionId, TuningReference, TupletId, TypedObjectId, + VoiceId, WallClockTime, }; use epiphany_layout_ir::{ active_clef, manifestation_layout_id, staff_step_pitch, to_constrained, to_logical, to_render, - ConstraintSolver, HitTestMap, LayoutContent, LayoutObjectId, LogicalLayoutIR, Point, RenderIR, - ResolvedLayoutIR, SolverConfig, TimePoint, + ConstraintSolver, ExtensionRef, HitTestMap, LayoutContent, LayoutObjectId, LogicalLayoutIR, + Point, RenderIR, ResolvedLayoutIR, SolverConfig, TimePoint, }; use epiphany_ops::{ - AcceptOutcome, AuthorId, CausalContext, DeleteEventOp, DeleteIdentifiedPitchOp, - HybridLogicalClock, InsertEventOp, InsertIdentifiedPitchOp, ModifyEventOp, - ModifyIdentifiedPitchOp, OperationEnvelope, OperationKind, OperationPayload, OperationSet, - OperationStamp, RespellPitchOp, TransactionCategory, TransactionDescriptor, TransposeOp, - TupletCompensation, + advisory_violations, AcceptOutcome, AuthorId, CausalContext, DeleteEventOp, + DeleteIdentifiedPitchOp, HybridLogicalClock, InsertEventOp, InsertIdentifiedPitchOp, + ModifyEventOp, ModifyIdentifiedPitchOp, OperationEnvelope, OperationKind, OperationKindTag, + OperationPayload, OperationSet, OperationStamp, RespellPitchOp, TransactionCategory, + TransactionDescriptor, TransposeOp, TupletCompensation, }; /// The current selection: the score-graph object to act on, plus the stable layout @@ -175,11 +179,14 @@ pub enum EditorError { /// region is non-metric or has too few rendered events to place a position, or the /// staff has no voice / no diatonic clef. Nothing is inserted. NoInsertTarget, - /// A click-to-insert's make-room would have to trim or delete a **tuplet member**, - /// whose duration is governed by the tuplet's ratio. Compensating a tuplet on a - /// pencil overwrite is a later refinement, so the insert is refused rather than - /// leaving the tuplet inconsistent. Also raised by a duration edit of a tuplet - /// member itself. + /// A make-room overwrite could not treat an overlapped tuplet atomically. Tuplets are + /// atomic: a pencil overwrite touching any member of a *flat* tuplet cascades the whole + /// tuplet away (every member tombstoned, structure removed), freeing its span for the + /// new note. This error is raised only when that cascade is not available — the + /// overlapped member belongs to a **nested** tuplet (parent or child), whose ratio + /// arithmetic a flat cascade would misstate. Also raised by a *resize* + /// ([`set_selection_duration`](EditorSession::set_selection_duration)) of a tuplet + /// member itself: in-place rescaling of a member is a later refinement. OverlapsTuplet, /// A duration edit was given a non-positive duration, which is not a valid written /// note value. Nothing changes. @@ -190,6 +197,29 @@ pub enum EditorError { /// later refinement, so the edit is refused rather than left inconsistent. (A delete /// is fine; the tombstoned target's decomposition is no longer checked.) DecomposedEvent, + /// The operation failed an advisory precondition against the current materialized + /// score (Chapter 6 §"Validation Modes"). The session is **authoring mode**: all + /// preconditions are enforced, and advisory failures refuse the edit *before an + /// envelope is minted* — nothing enters the op log, and a peer never sees the + /// operation. (Replay/remote reduction enforces only invariant preconditions, so + /// the same envelope, had it been minted elsewhere, would reduce cleanly.) + AdvisoryViolation { + /// Every advisory precondition the operation failed. + violations: Vec, + }, + /// The edit matches an active edit barrier (Chapter 8 §"Behavior Under + /// Unknown Extensions": *edits MUST be checked against every active edit + /// barrier; a match is prohibited unless the user explicitly performs an + /// unsafe edit*). Nothing is minted and nothing changes. Crossing the + /// barrier deliberately is [`EditorSession::apply_unsafe`] / + /// [`EditorSession::apply_transaction_unsafe`], which acknowledges the loss + /// of the named extension's data. + BarrierProhibited { + /// The extension whose edit barrier prohibits the edit. + extension: ExtensionRef, + /// The prohibited operation class. + operation: OperationKindTag, + }, } impl fmt::Display for EditorError { @@ -219,12 +249,30 @@ impl fmt::Display for EditorError { f.write_str("the click resolved to no insert target (off-staff, non-metric, or no voice)") } EditorError::OverlapsTuplet => { - f.write_str("the edit would have to trim or delete a tuplet member") + f.write_str("the edit would have to alter a nested tuplet, or resize a tuplet member") } EditorError::InvalidDuration => f.write_str("a non-positive duration is not a note value"), EditorError::DecomposedEvent => { f.write_str("the edit would change the duration of an event with a decomposition") } + EditorError::AdvisoryViolation { violations } => { + write!( + f, + "the edit failed {} advisory precondition(s) (authoring mode): {violations:?}", + violations.len() + ) + } + EditorError::BarrierProhibited { + extension, + operation, + } => { + write!( + f, + "the edit ({operation:?}) is prohibited by an edit barrier declared by \ + extension {extension:?}; only an explicit unsafe edit may cross it \ + (tombstoning that extension's data)" + ) + } } } } @@ -282,6 +330,16 @@ pub struct EditorSession { // active prefix. (`applied` is always a — possibly non-contiguous, after a fork — // subsequence of `authored`.) authored: Vec, + // The active extension declarations whose edit barriers gate edits (Chapter 8 + // §"Behavior Under Unknown Extensions"). Injected via `set_active_extensions` + // (the session opens on a bare `Score`, so it never reads a manifest itself); + // empty means no barriers, i.e. every edit passes the gate. + active_extensions: Vec, + // Extensions crossed by an unsafe edit. Per the spec, an unsafe edit MUST + // tombstone the crossed extension's chunks; the session has no bundle + // plumbing, so it records the obligation here (see + // `extensions_requiring_tombstone`) and deactivates the extension's barriers. + pending_extension_tombstones: BTreeSet, } /// One materialization of the op log: the reduced score and everything derived from it @@ -316,6 +374,8 @@ impl EditorSession { undo_units: Vec::new(), redo_stack: Vec::new(), authored: Vec::new(), + active_extensions: Vec::new(), + pending_extension_tombstones: BTreeSet::new(), }) } @@ -389,6 +449,74 @@ impl EditorSession { self.applied.last() } + /// Installs the active extension set whose edit barriers gate subsequent + /// edits (Chapter 8 §"Behavior Under Unknown Extensions": edits MUST be + /// checked against every active edit barrier). Replaces any previous set. + /// + /// The session opens on a bare [`Score`], not a bundle, so it never reads a + /// manifest itself: whoever opened the bundle decodes each declaration's + /// `edit_barriers` blob ([`epiphany_layout_ir::decode_edit_barriers`]) and + /// injects the result here. A session with no active extensions (the + /// default) has no barriers, and every edit passes the gate. + pub fn set_active_extensions(&mut self, extensions: Vec) { + self.active_extensions = extensions; + } + + /// The currently active extension declarations (barriers included). An + /// extension crossed by an unsafe edit is removed from this set — its data + /// is bound for tombstoning, so its barriers no longer bind. + pub fn active_extensions(&self) -> &[ActiveExtension] { + &self.active_extensions + } + + /// Extensions crossed by an unsafe edit in this session, whose chunks MUST + /// be tombstoned per the spec (§"Behavior Under Unknown Extensions": *the + /// unsafe-edit operation MUST tombstone the relevant extension chunks (so + /// they are no longer preserved) rather than silently breaking extension + /// invariants*). The session has no bundle plumbing of its own, so it + /// records the obligation: the next bundle write reads this set and drops + /// the named extensions' declarations (and with them their + /// `preserved_chunk_roots`) from the manifest it commits. + pub fn extensions_requiring_tombstone(&self) -> &BTreeSet { + &self.pending_extension_tombstones + } + + /// The first active-barrier match for `kind`, as the refusal `apply` / + /// `apply_transaction` surface (spec: a matching edit *is prohibited*). + fn barrier_refusal(&self, kind: &OperationKind) -> Option { + self.crossed_extensions(kind) + .into_iter() + .next() + .map(|extension| EditorError::BarrierProhibited { + extension, + operation: kind.tag(), + }) + } + + /// Every active extension with a barrier prohibiting `kind` against the + /// current materialized score — the extensions an unsafe edit crosses. + fn crossed_extensions(&self, kind: &OperationKind) -> Vec { + if self.active_extensions.is_empty() { + return Vec::new(); + } + let subjects = barriers::subjects_of(kind, &self.score); + barriers::prohibiting_extensions( + &self.active_extensions, + kind.tag(), + &subjects, + &barriers::ScoreOracle(&self.score), + ) + } + + /// Records that an unsafe edit crossed `extension`: its chunks are now + /// bound for tombstoning ([`Self::extensions_requiring_tombstone`]), and — + /// since its data will no longer be preserved — its barriers no longer + /// bind, so the declaration leaves the active set. + fn record_unsafe_crossing(&mut self, extension: ExtensionRef) { + self.pending_extension_tombstones.insert(extension); + self.active_extensions.retain(|e| e.extension != extension); + } + /// The staff and diatonic pitch at a world `point` — the **vertical half** of a /// click-to-insert. Finds the staff the click is over (the nearest staff by its /// rendered line band) and the natural pitch at that height under the staff's @@ -797,10 +925,61 @@ impl EditorSession { /// Applies a single primitive operation: mints an envelope and commits it. A /// [`OperationKind::DeclareTransaction`] is not a primitive mutation — the session /// declares transactions via [`Self::apply_transaction`] — so it is refused here. + /// + /// Two pre-mint gates run, in order (both refuse with the op log untouched): + /// + /// 1. **Edit barriers** (Chapter 8 §"Behavior Under Unknown Extensions"): + /// the edit is checked against every active barrier; a match is refused + /// ([`EditorError::BarrierProhibited`]). The barrier gate runs *first* + /// because its prohibition is the spec's normative MUST and its refusal + /// names the unsafe-edit escape ([`Self::apply_unsafe`]); the advisory + /// check is the author's local policy. + /// 2. **Advisory preconditions** (Chapter 6 §"Validation Modes"): the + /// session is authoring mode, so advisory preconditions are checked + /// against the current materialized score, and any violation refuses the + /// edit ([`EditorError::AdvisoryViolation`]). Reduction itself stays pure + /// replay mode — it never consults advisory checks. pub fn apply(&mut self, kind: OperationKind) -> Result { if matches!(kind, OperationKind::DeclareTransaction(_)) { return Err(EditorError::DeclareTransactionNotAllowed); } + if let Some(refusal) = self.barrier_refusal(&kind) { + return Err(refusal); + } + self.apply_past_barriers(kind) + } + + /// Applies a single primitive operation as an **unsafe edit** (Chapter 8 + /// §"Behavior Under Unknown Extensions"): the explicit user action that + /// crosses matching edit barriers, acknowledging the loss of the crossed + /// extensions' data. Everything else about [`Self::apply`] holds — the + /// advisory gate still runs, and a failed edit changes nothing (including + /// the tombstone record: an edit that did not land loses no data). + /// + /// On success, every crossed extension is recorded in + /// [`Self::extensions_requiring_tombstone`] (the spec's MUST: its chunks + /// are tombstoned rather than silently invalidated) and removed from the + /// active set. An unsafe apply that crosses no barrier is an ordinary + /// apply. + pub fn apply_unsafe(&mut self, kind: OperationKind) -> Result { + if matches!(kind, OperationKind::DeclareTransaction(_)) { + return Err(EditorError::DeclareTransactionNotAllowed); + } + let crossed = self.crossed_extensions(&kind); + let outcome = self.apply_past_barriers(kind)?; + for extension in crossed { + self.record_unsafe_crossing(extension); + } + Ok(outcome) + } + + /// The shared tail of [`Self::apply`] / [`Self::apply_unsafe`]: everything + /// past the barrier gate (advisory gate, mint, commit). + fn apply_past_barriers(&mut self, kind: OperationKind) -> Result { + let violations = advisory_violations(&kind, &self.score); + if !violations.is_empty() { + return Err(EditorError::AdvisoryViolation { violations }); + } // The next id is one past every id ever minted (monotonic across undo), and the // context covers the currently-applied ops. let counter = self.authored.len() as u64; @@ -828,6 +1007,47 @@ impl EditorSession { category: Option, kinds: Vec, ) -> Result { + Self::check_transaction_shape(&kinds)?; + // The barrier gate (see `apply` for the gate ordering): the descriptor + // the session will mint and every member are each checked against every + // active barrier; any match refuses the whole transaction before + // anything is minted. + if let Some(refusal) = self.transaction_barrier_refusal(&kinds) { + return Err(refusal); + } + self.apply_transaction_past_barriers(label, category, kinds) + } + + /// [`Self::apply_transaction`] as an **unsafe edit** — the transaction + /// sibling of [`Self::apply_unsafe`]: matching edit barriers are crossed + /// rather than refused, and on success every crossed extension is recorded + /// for tombstoning and deactivated. The structural checks and the advisory + /// gate still apply, and a transaction that fails to commit records + /// nothing. + pub fn apply_transaction_unsafe( + &mut self, + label: &str, + category: Option, + kinds: Vec, + ) -> Result { + Self::check_transaction_shape(&kinds)?; + let mut crossed = self.crossed_extensions(&Self::descriptor_probe()); + for kind in &kinds { + for extension in self.crossed_extensions(kind) { + if !crossed.contains(&extension) { + crossed.push(extension); + } + } + } + let outcome = self.apply_transaction_past_barriers(label, category, kinds)?; + for extension in crossed { + self.record_unsafe_crossing(extension); + } + Ok(outcome) + } + + /// The structural refusals shared by both transaction entry points. + fn check_transaction_shape(kinds: &[OperationKind]) -> Result<(), EditorError> { // A member-less transaction would log a descriptor-only no-op (a dead // undo/sync unit). Refuse before minting anything. if kinds.is_empty() { @@ -841,6 +1061,49 @@ impl EditorSession { { return Err(EditorError::DeclareTransactionNotAllowed); } + Ok(()) + } + + /// The first active-barrier match across the transaction the session is + /// about to mint: the `DeclareTransaction` descriptor (a score-level + /// operation a score-wide barrier can prohibit), then each member in order. + fn transaction_barrier_refusal(&self, kinds: &[OperationKind]) -> Option { + self.barrier_refusal(&Self::descriptor_probe()) + .or_else(|| kinds.iter().find_map(|kind| self.barrier_refusal(kind))) + } + + /// A stand-in `DeclareTransaction` for gating: barrier matching reads only + /// the operation *class* (and, for a descriptor, no payload-named object), + /// so the placeholder id and label never influence the verdict. + fn descriptor_probe() -> OperationKind { + OperationKind::DeclareTransaction(TransactionDescriptor { + id: TransactionId::new(ReplicaId(0), 0), + label: String::new(), + category: None, + }) + } + + /// The shared tail of [`Self::apply_transaction`] / + /// [`Self::apply_transaction_unsafe`]: everything past the barrier gate. + fn apply_transaction_past_barriers( + &mut self, + label: &str, + category: Option, + kinds: Vec, + ) -> Result { + // Authoring mode (see `apply`): every member must pass its advisory + // preconditions before anything is minted. Members are checked against + // the current materialized score — the pre-transaction state — which is + // conservative for the rare member that only violates against another + // member's intermediate effect (advisory checks are the author's local + // policy, not canonical state, so this cannot diverge replicas). + let violations: Vec<_> = kinds + .iter() + .flat_map(|kind| advisory_violations(kind, &self.score)) + .collect(); + if !violations.is_empty() { + return Err(EditorError::AdvisoryViolation { violations }); + } let envelopes = self.transaction_envelopes(label, category, kinds); self.commit(envelopes) } @@ -1187,12 +1450,14 @@ impl EditorSession { /// goes into the staff's primary voice and **makes room** under an overwrite policy /// — an existing note/rest the new note fully covers is deleted, one it partially /// overlaps is trimmed, and one it lands inside is split (head trimmed, tail - /// re-inserted) — all as one transaction, so it applies atomically or not at all. + /// re-inserted), and a **tuplet** any part of the new note overlaps is cascaded away + /// whole (every member tombstoned, structure removed — tuplets are atomic) — all as + /// one transaction, so it applies atomically or not at all. /// /// Errors with [`EditorError::NoInsertTarget`] when the click resolves to no metric /// staff/position (or the overlap includes a non-note/rest event there is no - /// make-room rule for), or [`EditorError::OverlapsTuplet`] when make-room would have - /// to disturb a tuplet member (its duration is ratio-governed). + /// make-room rule for), or [`EditorError::OverlapsTuplet`] when the overlapped member + /// belongs to a *nested* tuplet, which the flat cascade cannot treat atomically. pub fn insert_note_at( &mut self, point: Point, @@ -1257,10 +1522,12 @@ impl EditorSession { /// Sets the selected event's written **duration** (a notation duration-palette /// gesture; the selection may be a notehead or a rest/stem). Shrinking just frees /// the space after the event; **lengthening makes room** under the overwrite policy - /// — the events it grows over are trimmed, deleted, or split, atomically with the - /// resize. Errors: [`InvalidDuration`](EditorError::InvalidDuration) for a - /// non-positive duration, [`OverlapsTuplet`](EditorError::OverlapsTuplet) when the - /// event (or one it grows over) is a tuplet member, and + /// — the events it grows over are trimmed, deleted, or split, and a tuplet it grows + /// over is cascaded away whole (tuplets are atomic), atomically with the resize. + /// Errors: [`InvalidDuration`](EditorError::InvalidDuration) for a non-positive + /// duration, [`OverlapsTuplet`](EditorError::OverlapsTuplet) when the *selected* event + /// is itself a tuplet member (in-place rescaling of a member is a later refinement) or + /// it grows over a *nested* tuplet the flat cascade cannot treat atomically, and /// [`WrongSelection`](EditorError::WrongSelection) when nothing apt is selected or /// the event is not metric. pub fn set_selection_duration( @@ -1348,8 +1615,13 @@ impl EditorSession { /// The events make-room must change to clear `[start, end)` in `voice` (other than /// `exclude`, the event being inserted/resized): whole-event deletes, in-place - /// trims, and splits. Errors on a tuplet member or a non-note/rest overlap there is - /// no make-room rule for. + /// trims, splits, and whole-tuplet cascade deletes. A tuplet is **atomic** — an + /// overlap with any of its members removes the whole tuplet (every member, plus the + /// structure), since a member's duration is ratio-bound and cannot be trimmed in + /// place. Errors with [`OverlapsTuplet`](EditorError::OverlapsTuplet) for a *nested* + /// tuplet (cascading one level only is not yet safe), [`NoInsertTarget`] for a + /// non-note/rest overlap, and [`DecomposedEvent`](EditorError::DecomposedEvent) for a + /// trim/split of a (non-tuplet) decomposed event. fn make_room( &self, voice: VoiceId, @@ -1358,6 +1630,8 @@ impl EditorSession { exclude: Option, ) -> Result { let mut room = MakeRoom::default(); + let mut cascade_tuplets: std::collections::BTreeSet = + std::collections::BTreeSet::new(); for event in self.score.events.iter() { if event.voice() != voice || Some(event.id()) == exclude { continue; @@ -1376,8 +1650,17 @@ impl EditorSession { if !(ep < end && start < &event_end) { continue; // disjoint from [start, end) } - if self.event_in_tuplet(event.id()) { - return Err(EditorError::OverlapsTuplet); + // A tuplet member: mark its (flat) tuplet for whole-tuplet cascade deletion, + // and stop treating it as an ordinary overlap. A nested tuplet is refused. + let containing = self.tuplets_containing(event.id()); + if !containing.is_empty() { + for tuplet in containing { + if !self.is_flat_tuplet(tuplet) { + return Err(EditorError::OverlapsTuplet); + } + cascade_tuplets.insert(tuplet); + } + continue; } if !matches!(event, Event::Pitched(_) | Event::Rest(_)) { return Err(EditorError::NoInsertTarget); // no make-room rule for this kind @@ -1412,9 +1695,64 @@ impl EditorSession { )), } } + // Expand each cascade tuplet into deletes of all its live members. The first + // member removes the tuplet structure (so the rest no longer belong to a tuplet + // and delete as ordinary events); the order is preserved by `make_room_ops`. + for tuplet in cascade_tuplets { + for (i, member) in self.tuplet_members(tuplet).into_iter().enumerate() { + let compensation = if i == 0 { + TupletCompensation::CascadeDeleteTuplets { + tuplets: vec![tuplet], + } + } else { + TupletCompensation::NotInTuplet + }; + room.cascade_deletes.push((member, compensation)); + } + } Ok(room) } + /// The ids of the tuplets `event` is a member of. + fn tuplets_containing(&self, event: EventId) -> Vec { + self.score + .cross_cutting + .tuplets + .iter() + .filter(|tuplet| tuplet.members.contains(&event)) + .map(|tuplet| tuplet.id) + .collect() + } + + /// Whether `tuplet` is flat — not nested inside another and not the parent of one. + /// Cascade-deleting a nested tuplet would leave the parent referencing tombstoned + /// members, so make-room refuses it for now. + fn is_flat_tuplet(&self, tuplet: TupletId) -> bool { + let tuplets = &self.score.cross_cutting.tuplets; + tuplets + .iter() + .find(|t| t.id == tuplet) + .is_some_and(|t| t.parent.is_none()) + && !tuplets.iter().any(|t| t.parent == Some(tuplet)) + } + + /// `tuplet`'s live member events, in member order. + fn tuplet_members(&self, tuplet: TupletId) -> Vec { + self.score + .cross_cutting + .tuplets + .iter() + .find(|t| t.id == tuplet) + .map(|t| { + t.members + .iter() + .copied() + .filter(|m| self.score.events.get(*m).is_some()) + .collect() + }) + .unwrap_or_default() + } + /// Turns a [`MakeRoom`] into operations: trims (`ModifyEvent`), deletes /// (`DeleteEvent`), and split tails (`InsertEvent`, cloning the original event's /// shape with fresh ids, carrying any authored spelling via `RespellPitch`). Order @@ -1426,6 +1764,14 @@ impl EditorSession { minter: &mut Minter, ) -> Vec { let mut ops: Vec = Vec::new(); + // Cascade-delete whole tuplets first, in order — the structure-removing delete + // must precede the members that then delete as ordinary (no-longer-tuplet) events. + for (event, tuplet_compensation) in room.cascade_deletes { + ops.push(OperationKind::DeleteEvent(DeleteEventOp { + event, + tuplet_compensation, + })); + } for event in room.trims { ops.push(OperationKind::ModifyEvent(ModifyEventOp { event })); } @@ -1802,6 +2148,12 @@ struct MakeRoom { trims: Vec, deletes: Vec, tails: Vec<(Event, MusicalPosition, MusicalDuration)>, + /// Whole-tuplet cascade deletes: every member of each overlapped tuplet, paired with + /// its delete compensation. The first member of a tuplet carries + /// [`TupletCompensation::CascadeDeleteTuplets`] (which removes the tuplet structure); + /// the rest are then ordinary [`NotInTuplet`](TupletCompensation::NotInTuplet) + /// deletes, so they must apply in this order. + cascade_deletes: Vec<(EventId, TupletCompensation)>, } /// Mints fresh event/pitch ids within one intent, advancing local counters (checked, @@ -2991,20 +3343,76 @@ mod tests { } #[test] - fn insert_note_at_refuses_to_disturb_a_tuplet() { + fn insert_note_at_cascades_a_whole_tuplet() { let mut session = open_rich(0x5EED); - // The rich fixture's first metric region is an eighth-note triplet; covering a - // member would need tuplet compensation, so the insert is refused. + // The rich fixture's first metric region is a 3:2 eighth-note triplet over + // [0, 1/4), and its first member carries an in-tuplet decomposition. Tuplets are + // atomic: a pencil overwrite touching any member removes the *whole* tuplet — + // every member, the structure, and the now-orphaned decomposition — freeing the + // span for the new note and leaving an invariant-valid graph. let region = a_region_with(&session, true); + let voice = primary_voice(&session, region); + let members: Vec = voice_events(&session, voice) + .into_iter() + .map(|(id, _, _)| id) + .collect(); + assert_eq!(members.len(), 3, "the region is a three-member triplet"); assert!( - session - .score() - .cross_cutting - .tuplets - .iter() - .any(|t| !t.members.is_empty()), - "the fixture region is a tuplet" + !session.score().cross_cutting.tuplets.is_empty(), + "the region starts as a tuplet" ); + + // Click the first member's onset with a grid of its own (eighth-note) value, so + // the new note overlaps just that one member — yet the whole triplet cascades. + let (_, pos, dur) = voice_events(&session, voice).into_iter().next().unwrap(); + let (_, _, origin_y) = region_staff_line(&session, region); + let at = click_for_position(&session, region, &pos, origin_y + 1.0); + session + .insert_note_at(at, &GridResolution { step: dur.clone() }) + .expect("the overwrite cascades the tuplet and inserts the note"); + + let after = voice_events(&session, voice); + assert!( + members + .iter() + .all(|m| !after.iter().any(|(id, _, _)| id == m)), + "every original triplet member is gone" + ); + assert!( + session.score().cross_cutting.tuplets.is_empty(), + "the tuplet structure is removed" + ); + let inserted = after + .iter() + .find(|(id, _, _)| !members.contains(id)) + .expect("the new note was inserted"); + assert_eq!(inserted.1, pos, "the new note sits at the clicked onset"); + assert_eq!(inserted.2, dur, "the new note has the grid duration"); + assert!( + epiphany_core::check_invariants(session.score()).is_empty(), + "the cascaded graph is invariant-valid" + ); + } + + #[test] + fn insert_note_at_over_a_nested_tuplet_is_refused() { + use epiphany_core::{Tuplet, TupletRatio}; + // A nested tuplet's ratio arithmetic a flat cascade cannot restate, so make-room + // refuses rather than corrupt it. Give the fixture's flat triplet a child tuplet + // so it is no longer flat, then try to overwrite one of its members. + let mut score = valid_score_rich(0x5EED); + let triplet_id = score.cross_cutting.tuplets[0].id; + let replica = score.identity.replica_id; + score.cross_cutting.tuplets.push(Tuplet { + id: TupletId::new(replica, 7_000_002), + ratio: TupletRatio::new(3, 2).unwrap(), + members: vec![], + parent: Some(triplet_id), + required_total: MusicalDuration(RationalTime::new(1, 8).unwrap()), + }); + let mut session = EditorSession::open(score, Box::new(StubSolver)).expect("renders"); + + let region = a_region_with(&session, true); let voice = primary_voice(&session, region); let (_, pos, dur) = voice_events(&session, voice).into_iter().next().unwrap(); let (_, _, origin_y) = region_staff_line(&session, region); @@ -3645,6 +4053,86 @@ mod tests { ); } + #[test] + fn advisory_violating_edit_is_refused_in_authoring_but_reduces_in_replay() { + // Chapter 6 §"Validation Modes": the session is authoring mode — an + // advisory-violating operation is refused before an envelope is minted + // — while the same operation, minted elsewhere, reduces cleanly through + // raw OperationSet reduction (replay mode enforces only invariant + // preconditions; advisory ones fail silently). + use epiphany_core::{ + AnchorOffset, MusicalDuration, MusicalPosition, RationalTime, RegionEdge, TimeAnchor, + WallClockTime, + }; + + // The plain fixture, with its single region given a *musical* end + // bound of 12 whole units (the fixture's own extent is wall-clock, + // which the advisory boundary check cannot resolve). + let mut score = valid_score(0x5EED); + let region_id = score.canvas.regions[0].id; + score.canvas.regions[0].time_extent.end = TimeAnchor::Region { + id: region_id, + edge: RegionEdge::Start, + offset: AnchorOffset::Musical(MusicalDuration(RationalTime::from_int(12))), + }; + let instance = score.canvas.regions[0].staff_instances()[0].id; + let voice = score.canvas.regions[0].staff_instances()[0].voices[0].id; + + // An insert whose span straddles the bound: starts at 10, ends at 14 — + // applying it would require splitting the event across the boundary. + let event_id = EventId::new(ReplicaId(50), 999); + let kind = OperationKind::InsertEvent(InsertEventOp { + staff_instance: instance, + event: epiphany_ops::valuegen::insert_event_value( + event_id, + voice, + MusicalPosition(RationalTime::from_int(10)), + MusicalDuration(RationalTime::from_int(4)), + &[PitchId::new(ReplicaId(50), 998)], + ), + }); + + // Authoring: refused pre-mint, on both the single-op and the + // transaction seam; the op log stays untouched. + let mut session = + EditorSession::open(score.clone(), Box::new(StubSolver)).expect("the fixture renders"); + let err = session + .apply(kind.clone()) + .expect_err("an advisory violation must refuse the edit"); + assert!(matches!(err, EditorError::AdvisoryViolation { .. })); + assert!(session.applied_operations().is_empty()); + let err = session + .apply_transaction("cross-boundary insert", None, vec![kind.clone()]) + .expect_err("an advisory-violating member must refuse the transaction"); + assert!(matches!(err, EditorError::AdvisoryViolation { .. })); + assert!(session.applied_operations().is_empty()); + + // Replay: the same operation in an envelope reduces cleanly and the + // event materializes — the advisory check has no channel into + // reduction. + let id = epiphany_core::OperationId::new(ReplicaId(50), 0); + let env = OperationEnvelope { + id, + author: AuthorId(7), + stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(1), 0), id), + causal_context: CausalContext::new(), + transaction: None, + payload: OperationPayload::Primitive(kind), + }; + let mut set = OperationSet::new(); + assert_eq!(set.accept(env), AcceptOutcome::Accepted); + let materialized = set.reduce_onto(&score); + assert!( + materialized.state.is_clean(), + "replay applies the advisory-violating insert cleanly: {:?}", + materialized.state + ); + assert!( + materialized.score.events.get(event_id).is_some(), + "the event materializes under replay" + ); + } + #[test] fn add_note_to_selection_adds_a_chord_note_above_the_top() { let mut session = open_rich(0x5EED); @@ -4611,4 +5099,307 @@ mod tests { "a rejected edit leaves the document untouched" ); } + + // --- The edit-barrier gate (Chapter 8 §"Behavior Under Unknown Extensions") --- + + use epiphany_layout_ir::{BarrierCondition, BarrierScope, EditBarrier, ObjectKind}; + + /// A whole-score barrier prohibiting one operation class (no object-kind or + /// condition narrowing), as extension `ext` would declare it. + fn extension_prohibiting(ext: u128, op: OperationKindTag) -> ActiveExtension { + ActiveExtension { + extension: ExtensionRef(ext), + barriers: vec![EditBarrier { + scope: BarrierScope::WholeScore, + affected_object_kinds: vec![], + prohibited_operation_kinds: vec![op], + condition: BarrierCondition::Always, + }], + } + } + + /// The first event in the plain fixture's (single) voice, and a delete of it. + fn first_event_delete(session: &EditorSession) -> (EventId, OperationKind) { + let event = session + .score() + .voices() + .find_map(|(_, _, v)| v.events.first().copied()) + .expect("the fixture has an event"); + let delete = OperationKind::DeleteEvent(DeleteEventOp { + event, + tuplet_compensation: TupletCompensation::NotInTuplet, + }); + (event, delete) + } + + #[test] + fn a_barrier_matching_edit_is_refused_and_a_non_matching_edit_proceeds() { + let mut session = open_plain(7); + let (_, delete) = first_event_delete(&session); + session.set_active_extensions(vec![extension_prohibiting( + 0xE1, + OperationKindTag::DeleteEvent, + )]); + + // The matching edit is refused, naming the declaring extension, with + // nothing minted (the op log untouched). + assert_eq!( + session.apply(delete.clone()), + Err(EditorError::BarrierProhibited { + extension: ExtensionRef(0xE1), + operation: OperationKindTag::DeleteEvent, + }) + ); + assert!(session.applied_operations().is_empty()); + + // An edit of a different operation class proceeds. + let pitch = last_event_pitch(&session); + let transpose = OperationKind::Transpose(TransposeOp { + targets: vec![pitch], + chromatic_steps: 1, + }); + session + .apply(transpose) + .expect("a non-prohibited class passes the gate"); + assert_eq!(session.applied_operations().len(), 1); + } + + #[test] + fn a_region_scoped_barrier_gates_by_the_targets_real_containment() { + // A barrier scoped to a *different* region does not prohibit deleting + // an event here — known scopes are evaluated precisely against the + // target's containment, not conservatively. + let mut session = open_plain(7); + let (_, delete) = first_event_delete(&session); + let elsewhere = RegionId::from_raw(u128::MAX); + let scoped = |region| ActiveExtension { + extension: ExtensionRef(0xE2), + barriers: vec![EditBarrier { + scope: BarrierScope::Region(region), + affected_object_kinds: vec![], + prohibited_operation_kinds: vec![OperationKindTag::DeleteEvent], + condition: BarrierCondition::Always, + }], + }; + session.set_active_extensions(vec![scoped(elsewhere)]); + session + .apply(delete.clone()) + .expect("a barrier over another region does not bind here"); + + // The same barrier scoped to the event's own region refuses the edit. + let mut session = open_plain(7); + let (_, delete) = first_event_delete(&session); + let here = session.score().canvas.regions[0].id; + session.set_active_extensions(vec![scoped(here)]); + assert_eq!( + session.apply(delete), + Err(EditorError::BarrierProhibited { + extension: ExtensionRef(0xE2), + operation: OperationKindTag::DeleteEvent, + }) + ); + } + + #[test] + fn an_object_kind_narrowed_barrier_matches_only_that_kind() { + // A barrier protecting only Pitch objects prohibits a transpose but not + // an event delete, even under the same prohibited class list. + let mut session = open_plain(7); + let pitch = last_event_pitch(&session); + let pitch_kind = ObjectKind::of(&TypedObjectId::Pitch(pitch)); + session.set_active_extensions(vec![ActiveExtension { + extension: ExtensionRef(0xE3), + barriers: vec![EditBarrier { + scope: BarrierScope::WholeScore, + affected_object_kinds: vec![pitch_kind], + prohibited_operation_kinds: vec![ + OperationKindTag::Transpose, + OperationKindTag::DeleteEvent, + ], + condition: BarrierCondition::Always, + }], + }]); + let transpose = OperationKind::Transpose(TransposeOp { + targets: vec![pitch], + chromatic_steps: 1, + }); + assert!(matches!( + session.apply(transpose), + Err(EditorError::BarrierProhibited { .. }) + )); + let (_, delete) = first_event_delete(&session); + session + .apply(delete) + .expect("an Event target does not match a Pitch-kind barrier"); + } + + #[test] + fn an_unsafe_edit_crosses_the_barrier_and_records_the_tombstone_obligation() { + let mut session = open_plain(7); + let (_, delete) = first_event_delete(&session); + session.set_active_extensions(vec![extension_prohibiting( + 0xE4, + OperationKindTag::DeleteEvent, + )]); + assert!(matches!( + session.apply(delete.clone()), + Err(EditorError::BarrierProhibited { .. }) + )); + + // The explicit unsafe edit proceeds ... + let outcome = session.apply_unsafe(delete).expect("the unsafe edit lands"); + assert!(outcome.graph_changed); + // ... records that the crossed extension's chunks MUST be tombstoned + // (spec §"Behavior Under Unknown Extensions") ... + assert!(session + .extensions_requiring_tombstone() + .contains(&ExtensionRef(0xE4))); + // ... and deactivates the extension (its data is gone, so its barriers + // no longer bind): the next matching edit passes the ordinary gate. + assert!(session.active_extensions().is_empty()); + let (_, next_delete) = first_event_delete(&session); + session + .apply(next_delete) + .expect("the crossed extension's barriers no longer bind"); + } + + #[test] + fn an_unsafe_edit_crossing_no_barrier_records_nothing() { + let mut session = open_plain(7); + session.set_active_extensions(vec![extension_prohibiting( + 0xE5, + OperationKindTag::DeleteEvent, + )]); + let pitch = last_event_pitch(&session); + session + .apply_unsafe(OperationKind::Transpose(TransposeOp { + targets: vec![pitch], + chromatic_steps: 1, + })) + .expect("an unsafe apply of a non-matching edit is an ordinary apply"); + assert!(session.extensions_requiring_tombstone().is_empty()); + assert_eq!(session.active_extensions().len(), 1); + } + + #[test] + fn the_barrier_gate_and_the_advisory_gate_coexist() { + use epiphany_core::{AnchorOffset, RegionEdge, ReplicaId}; + // The plain fixture with its region's end bound declared at 12 whole + // units — the shape under which an event spanning 10..14 fails the + // InsertEvent advisory precondition (Chapter 6 §6.10). + let mut score = valid_score(7); + let region_id = score.canvas.regions[0].id; + score.canvas.regions[0].time_extent.end = epiphany_core::TimeAnchor::Region { + id: region_id, + edge: RegionEdge::Start, + offset: AnchorOffset::Musical(MusicalDuration(RationalTime::from_int(12))), + }; + let instance = score.canvas.regions[0].staff_instances()[0].id; + let voice = score.canvas.regions[0].staff_instances()[0].voices[0].id; + let mut session = + EditorSession::open(score, Box::new(StubSolver)).expect("the bounded fixture renders"); + let crossing_insert = |duration: i32| { + OperationKind::InsertEvent(InsertEventOp { + staff_instance: instance, + event: epiphany_ops::valuegen::insert_event_value( + EventId::new(ReplicaId(50), 999), + voice, + MusicalPosition(RationalTime::from_int(10)), + MusicalDuration(RationalTime::from_int(duration)), + &[PitchId::new(ReplicaId(50), 998)], + ), + }) + }; + + // With no barriers, the advisory gate alone refuses the crossing span. + assert!(matches!( + session.apply(crossing_insert(4)), + Err(EditorError::AdvisoryViolation { .. }) + )); + + // With a matching barrier, the barrier gate fires first (its refusal is + // the spec's MUST and names the unsafe-edit escape). + session.set_active_extensions(vec![extension_prohibiting( + 0xE6, + OperationKindTag::InsertEvent, + )]); + assert!(matches!( + session.apply(crossing_insert(4)), + Err(EditorError::BarrierProhibited { .. }) + )); + + // The unsafe path crosses the barrier but still enforces the advisory + // gate — and a refused edit loses no data, so nothing is recorded. + assert!(matches!( + session.apply_unsafe(crossing_insert(4)), + Err(EditorError::AdvisoryViolation { .. }) + )); + assert!(session.extensions_requiring_tombstone().is_empty()); + assert_eq!(session.active_extensions().len(), 1, "nothing was crossed"); + + // A span inside the bound passes the advisory gate; unsafely applying + // it crosses the barrier and records the obligation. + let outcome = session + .apply_unsafe(crossing_insert(2)) + .expect("within the bound, only the barrier stood in the way"); + assert!(outcome.graph_changed); + assert!(session + .extensions_requiring_tombstone() + .contains(&ExtensionRef(0xE6))); + } + + #[test] + fn a_transaction_is_gated_per_member_and_has_an_unsafe_sibling() { + let mut session = open_plain(7); + let pitch = last_event_pitch(&session); + let transpose = OperationKind::Transpose(TransposeOp { + targets: vec![pitch], + chromatic_steps: 1, + }); + session.set_active_extensions(vec![extension_prohibiting( + 0xE7, + OperationKindTag::Transpose, + )]); + + // A member matching a barrier refuses the whole transaction, unminted. + assert_eq!( + session.apply_transaction("sharpen", None, vec![transpose.clone()]), + Err(EditorError::BarrierProhibited { + extension: ExtensionRef(0xE7), + operation: OperationKindTag::Transpose, + }) + ); + assert!(session.applied_operations().is_empty()); + + // The unsafe sibling crosses and records. + session + .apply_transaction_unsafe("sharpen", None, vec![transpose]) + .expect("the unsafe transaction lands"); + assert!(session + .extensions_requiring_tombstone() + .contains(&ExtensionRef(0xE7))); + + // A score-wide barrier on the descriptor class gates transactions as a + // whole: DeclareTransaction is a score-level operation. + let mut session = open_plain(7); + let pitch = last_event_pitch(&session); + session.set_active_extensions(vec![extension_prohibiting( + 0xE8, + OperationKindTag::DeclareTransaction, + )]); + assert_eq!( + session.apply_transaction( + "sharpen", + None, + vec![OperationKind::Transpose(TransposeOp { + targets: vec![pitch], + chromatic_steps: 1, + })], + ), + Err(EditorError::BarrierProhibited { + extension: ExtensionRef(0xE8), + operation: OperationKindTag::DeclareTransaction, + }) + ); + } } diff --git a/crates/epiphany-editor-gui/src/main.rs b/crates/epiphany-editor-gui/src/main.rs index 87b9f9c..c6a79ac 100644 --- a/crates/epiphany-editor-gui/src/main.rs +++ b/crates/epiphany-editor-gui/src/main.rs @@ -127,6 +127,7 @@ fn payload_label(payload: &OperationPayload) -> &'static str { }, OperationPayload::ResolveConflict(_) => "ResolveConflict", OperationPayload::UndoTransaction(_) => "UndoTransaction", + OperationPayload::ResolveEquivocation(_) => "ResolveEquivocation", } } diff --git a/crates/epiphany-engrave/src/lib.rs b/crates/epiphany-engrave/src/lib.rs index 3add97b..f40ce0e 100644 --- a/crates/epiphany-engrave/src/lib.rs +++ b/crates/epiphany-engrave/src/lib.rs @@ -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, + soft_violations: Vec, +} + +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) { +) -> ConstraintEvaluation { let by_id: BTreeMap = 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] diff --git a/crates/epiphany-layout-ir/DECISIONS.md b/crates/epiphany-layout-ir/DECISIONS.md index 3355bf7..47c82bb 100644 --- a/crates/epiphany-layout-ir/DECISIONS.md +++ b/crates/epiphany-layout-ir/DECISIONS.md @@ -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 1–2). 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 diff --git a/crates/epiphany-layout-ir/src/barrier.rs b/crates/epiphany-layout-ir/src/barrier.rs index 145b795..d912993 100644 --- a/crates/epiphany-layout-ir/src/barrier.rs +++ b/crates/epiphany-layout-ir/src/barrier.rs @@ -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 1–2); 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 { + 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 { + 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 = Result; + +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 { + 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 { + 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( + bytes: &[u8], + decode: impl FnOnce(&mut Reader<'_>) -> DecodeResult, +) -> DecodeResult { + 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, +) -> DecodeResult> { + 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 { + 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 { + 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( + reader: &mut Reader<'_>, + name: &'static str, +) -> DecodeResult { + T::decode_canonical(reader.take(16)?).map_err(|_| BarrierDecodeError::InvalidValue(name)) +} + +fn read_scope(reader: &mut Reader<'_>) -> DecodeResult { + 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 { + 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> { + 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 { + 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 { + 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 { + 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> { + 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 { + 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> { + 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 { + 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]) -> Vec { + 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 = 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 = 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| EditBarrier { diff --git a/crates/epiphany-layout-ir/src/constrained.rs b/crates/epiphany-layout-ir/src/constrained.rs index 95adbe7..fae3f78 100644 --- a/crates/epiphany-layout-ir/src/constrained.rs +++ b/crates/epiphany-layout-ir/src/constrained.rs @@ -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 = 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 = 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 = BTreeMap::new(); + let mut measure_starts: BTreeMap = BTreeMap::new(); + for object in ®ion.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, + measure_starts: &BTreeMap, +) -> Option { + 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 = + 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. diff --git a/crates/epiphany-layout-ir/src/engraving.rs b/crates/epiphany-layout-ir/src/engraving.rs index b271544..0d194c9 100644 --- a/crates/epiphany-layout-ir/src/engraving.rs +++ b/crates/epiphany-layout-ir/src/engraving.rs @@ -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(®ion.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); diff --git a/crates/epiphany-layout-ir/src/lib.rs b/crates/epiphany-layout-ir/src/lib.rs index 364b37b..816b0e2 100644 --- a/crates/epiphany-layout-ir/src/lib.rs +++ b/crates/epiphany-layout-ir/src/lib.rs @@ -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, diff --git a/crates/epiphany-layout-ir/src/logical.rs b/crates/epiphany-layout-ir/src/logical.rs index c1e0fef..26a4034 100644 --- a/crates/epiphany-layout-ir/src/logical.rs +++ b/crates/epiphany-layout-ir/src/logical.rs @@ -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, - /// 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, /// Objects spanning two or more layout regions. pub cross_region: Vec, @@ -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 = 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 { + 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 { +/// 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 { 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); diff --git a/crates/epiphany-layout-ir/src/roundtrip.rs b/crates/epiphany-layout-ir/src/roundtrip.rs index 0587729..5c6d958 100644 --- a/crates/epiphany-layout-ir/src/roundtrip.rs +++ b/crates/epiphany-layout-ir/src/roundtrip.rs @@ -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(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 diff --git a/crates/epiphany-layout-ir/src/solver.rs b/crates/epiphany-layout-ir/src/solver.rs index 5f4a830..f6f74a4 100644 --- a/crates/epiphany-layout-ir/src/solver.rs +++ b/crates/epiphany-layout-ir/src/solver.rs @@ -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 = 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] diff --git a/crates/epiphany-ops/DECISIONS.md b/crates/epiphany-ops/DECISIONS.md index ca3bd6d..e63d142 100644 --- a/crates/epiphany-ops/DECISIONS.md +++ b/crates/epiphany-ops/DECISIONS.md @@ -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 1–4 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. diff --git a/crates/epiphany-ops/src/decode.rs b/crates/epiphany-ops/src/decode.rs index eeb0965..0dd173b 100644 --- a/crates/epiphany-ops/src/decode.rs +++ b/crates/epiphany-ops/src/decode.rs @@ -194,6 +194,7 @@ fn pending_reason(reader: &mut Reader<'_>) -> Result { 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, diff --git a/crates/epiphany-ops/src/effect.rs b/crates/epiphany-ops/src/effect.rs index 33cc678..aa73886 100644 --- a/crates/epiphany-ops/src/effect.rs +++ b/crates/epiphany-ops/src/effect.rs @@ -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. diff --git a/crates/epiphany-ops/src/envelope.rs b/crates/epiphany-ops/src/envelope.rs index e347485..cc905b1 100644 --- a/crates/epiphany-ops/src/envelope.rs +++ b/crates/epiphany-ops/src/envelope.rs @@ -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 { + 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( diff --git a/crates/epiphany-ops/src/fuzz.rs b/crates/epiphany-ops/src/fuzz.rs index 5bf28bd..4b6d95a 100644 --- a/crates/epiphany-ops/src/fuzz.rs +++ b/crates/epiphany-ops/src/fuzz.rs @@ -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::>(); + + let mut items = vec![a.clone(), b.clone(), resolve.clone()]; + items.extend(noise); + + let mut reference: Option> = 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); diff --git a/crates/epiphany-ops/src/lib.rs b/crates/epiphany-ops/src/lib.rs index 754bae7..f928f6f 100644 --- a/crates/epiphany-ops/src/lib.rs +++ b/crates/epiphany-ops/src/lib.rs @@ -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; diff --git a/crates/epiphany-ops/src/migrate.rs b/crates/epiphany-ops/src/migrate.rs index 9719c37..687f69a 100644 --- a/crates/epiphany-ops/src/migrate.rs +++ b/crates/epiphany-ops/src/migrate.rs @@ -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 1–4. + 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), }) } diff --git a/crates/epiphany-ops/src/payload.rs b/crates/epiphany-ops/src/payload.rs index 47cb464..88c08c9 100644 --- a/crates/epiphany-ops/src/payload.rs +++ b/crates/epiphany-ops/src/payload.rs @@ -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 { + 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) { + // 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 = (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); diff --git a/crates/epiphany-ops/src/reduce.rs b/crates/epiphany-ops/src/reduce.rs index 118fbf2..db45d83 100644 --- a/crates/epiphany-ops/src/reduce.rs +++ b/crates/epiphany-ops/src/reduce.rs @@ -31,10 +31,11 @@ use std::collections::{BTreeMap, BTreeSet}; use epiphany_core::{ - derive_promoted_voice_id, AnchorOffset, CanonicalValue, Event, EventDuration, EventId, - EventPosition, MetricGrid, MusicalDuration, MusicalPosition, OperationId, Pitch, PitchId, - PitchSpelling, RegionEdge, RegionId, RegionTimeModel, Score, SpellingAttachment, - SpellingDirective, SpellingScope, SpellingSource, StaffInstance, StaffInstanceId, TimeAnchor, + canonical_pitch_bytes, derive_promoted_voice_id, AnchorOffset, AnnotationAnchor, + CanonicalValue, Event, EventDuration, EventId, EventPosition, GestureAnchoring, MetricGrid, + MusicalDuration, MusicalPosition, OperationId, Pitch, PitchId, PitchSpelling, RationalTime, + RegionEdge, RegionId, RegionTimeModel, ReplicaId, Score, SpellingAttachment, SpellingDirective, + SpellingScope, SpellingSource, StaffId, StaffInstance, StaffInstanceId, TimeAnchor, TransactionId, TypedObjectId, Voice, VoiceId, VoiceOrigin, }; use epiphany_determinism::CanonicalEncode; @@ -58,6 +59,7 @@ use crate::payload::{ RespellPitchOp, SetMetadataOp, SetMetricGridOp, SetUserPageBreakOp, TransposeOp, TupletCompensation, }; +use crate::support::{ObjectKind, SerializedCanonicalInputs}; use crate::undo::{UndoPolicy, UndoTransactionPayload}; /// Orders operation envelopes into the canonical reduction order (Chapter 6 @@ -163,6 +165,12 @@ pub enum PendingReason { DependsOnExcluded { on: OperationId }, /// A causal predecessor is itself held pending. DependsOnPending { on: OperationId }, + /// Reduction halted at a system-derived identifier collision (Chapter 5 + /// §"System-Derived Counter Collisions"): this operation is at or past the + /// collision point in canonical order — or is the earlier occupant of the + /// collided counter — and is held pending external recovery. `at` is the + /// operation whose mint collided. + HaltedBySystemCollision { at: OperationId }, } impl PendingReason { @@ -172,6 +180,7 @@ impl PendingReason { PendingReason::DependsOnEquivocated { .. } => 1, PendingReason::DependsOnExcluded { .. } => 2, PendingReason::DependsOnPending { .. } => 3, + PendingReason::HaltedBySystemCollision { .. } => 4, } } fn blocker(&self) -> OperationId { @@ -180,6 +189,7 @@ impl PendingReason { PendingReason::DependsOnEquivocated { on } | PendingReason::DependsOnExcluded { on } | PendingReason::DependsOnPending { on } => *on, + PendingReason::HaltedBySystemCollision { at } => *at, } } } @@ -352,6 +362,11 @@ struct Reducer<'a> { // voice's live events are read from `voice_occupancy`.) region_instances: BTreeMap>, instance_voices: BTreeMap>, + // The staff each staff instance manifests, for the containment-proximity + // key of the re-anchoring "nearest" ordering (same staff = rank 2). Kept + // base-free (seeded + maintained by CreateStaffInstance) so reduce() and + // reduce_onto() rank identically wherever both represent the scenario. + instance_staff: BTreeMap, // Regions whose content carries a staff-based slot (staff-based or hybrid), // and so can hold a metric grid or user break. FreeGraphic regions cannot. // Tracking this lets SetMetricGrid / SetUserPageBreak / SetUserSystemBreak @@ -367,8 +382,24 @@ struct Reducer<'a> { descriptors: BTreeMap, // Losing insert -> (promoted voice, winning insert). promotion: BTreeMap, + // System-derived mint registry for the counter-collision check (Chapter 5 + // §"System-Derived Counter Collisions"): (kind, derived counter) → the + // canonical inputs that derived it, plus the minting operation (None for + // an occupant seeded from the base graph). Consulted only by the pre-walk + // collision detection, never mutated during apply, so it needs no + // transaction snapshot. + system_mints: BTreeMap<(ObjectKind, u64), (SerializedCanonicalInputs, Option)>, tx_minted: BTreeMap>, current_tx: Option, + // ResolveEquivocation promotion results (operation_catalog + // §"ResolveEquivocation"), computed by the set-level pre-pass in `run`: + // target slot id → (governing resolve id, chosen candidate hash), and + // target slot id → the promoted candidate envelope (from the opset's + // diagnostic candidate store). Pure functions of the slot map — never + // mutated during apply — so, like `system_mints`, they need no transaction + // snapshot. + equivocation_resolutions: BTreeMap, + promoted_singles: BTreeMap, graph: Option, } @@ -390,6 +421,7 @@ struct WorkingSnapshot { structures: BTreeMap>, region_instances: BTreeMap>, instance_voices: BTreeMap>, + instance_staff: BTreeMap, staff_based_regions: BTreeSet, migrated_regions: BTreeSet, region_migrator: BTreeMap, @@ -422,6 +454,25 @@ fn apply_break_lww(breaks: &mut Vec, anchor: &TimeAnchor, present: b } } +/// The 64-byte canonical input preimage of a promoted-voice derivation +/// (`MUSCSVCE`, Chapter 5 §"System-Promoted Voices"): staff_instance ‖ +/// original_voice ‖ winning_op ‖ losing_op, 16 big-endian bytes each — exactly +/// the bytes [`derive_promoted_voice_id`] hashes. The collision check compares +/// these inputs to distinguish two derivations contending for one counter. +fn promoted_voice_inputs( + staff_instance: StaffInstanceId, + original_voice: VoiceId, + winning_op: OperationId, + losing_op: OperationId, +) -> Vec { + let mut inputs = Vec::with_capacity(64); + inputs.extend_from_slice(&staff_instance.canonical_bytes()); + inputs.extend_from_slice(&original_voice.canonical_bytes()); + inputs.extend_from_slice(&winning_op.canonical_bytes()); + inputs.extend_from_slice(&losing_op.canonical_bytes()); + inputs +} + fn intervals_overlap( a_position: &MusicalPosition, a_duration: &MusicalDuration, @@ -445,7 +496,7 @@ fn insert_intervals_overlap(a: &InsertEventOp, b: &InsertEventOp) -> bool { ) } -fn graph_voice_location(score: &Score, voice: VoiceId) -> Option<(usize, usize, usize)> { +pub(crate) fn graph_voice_location(score: &Score, voice: VoiceId) -> Option<(usize, usize, usize)> { for (region_index, region) in score.canvas.regions.iter().enumerate() { for (instance_index, instance) in region.staff_instances().iter().enumerate() { if let Some(voice_index) = instance @@ -488,6 +539,112 @@ fn graph_event_from_insert(op: &InsertEventOp, target_voice: VoiceId) -> Event { event } +// --- Re-anchoring referent support (Chapter 6 §"Total Ordering for Nearest", +// §"The Re-Anchoring Rule Table"). -------------------------------------------- + +/// The tombstoned referent's resolved placement and containment, captured from +/// the graph at the moment [`Reducer::materialize_graph_delete`] removes it — +/// the referent side of the four-key "nearest" ordering and of the range +/// reconstructions in the re-anchoring rule table. +struct ReferentContext { + voice: VoiceId, + region: Option, + position: EventPosition, + duration: EventDuration, +} + +/// Proximity bound "same staff instance" (the rule table's declared maximum for +/// markers and graphic gestures): candidates ranked farther than the referent's +/// staff instance are excluded from "nearest". +const PROXIMITY_SAME_STAFF_INSTANCE: u8 = 1; + +/// Maps an achieved containment-proximity rank (k1 of the "nearest" ordering) +/// to the ratified [`ReanchorReason`] vocabulary. Rank 4 (same canvas) has no +/// ratified reason variant, so a beyond-region survivor is recorded as the +/// explicit fallback rather than appending a new discriminant (see +/// DECISIONS.md — a spec-vocabulary question, batched for Pass 12). +fn reason_for_rank(rank: u8) -> ReanchorReason { + match rank { + 0 => ReanchorReason::SameVoiceNearer, + 1 => ReanchorReason::SameStaffInstanceNearer, + 2 => ReanchorReason::SameStaffNearer, + 3 => ReanchorReason::SameRegionNearer, + _ => ReanchorReason::ExplicitFallback, + } +} + +/// The event references among a set of [`TimeAnchor`]s (the referent-index +/// entries a tombstone must repair). Non-event anchors contribute nothing. +fn anchor_event_refs<'a>(anchors: impl IntoIterator) -> Vec { + anchors + .into_iter() + .filter_map(|anchor| match anchor { + TimeAnchor::Event { id, .. } => Some(TypedObjectId::Event(*id)), + _ => None, + }) + .collect() +} + +/// The event references an annotation anchor carries: its point event, or any +/// event-anchored range endpoints. Region anchors reference no event. +fn annotation_anchor_event_refs(anchor: &AnnotationAnchor) -> Vec { + match anchor { + AnnotationAnchor::Event(event) => vec![TypedObjectId::Event(*event)], + AnnotationAnchor::Range { start, end } => anchor_event_refs([start, end]), + AnnotationAnchor::Region(_) => Vec::new(), + } +} + +/// The event references a gesture anchoring carries. `Free` anchoring follows +/// no score content and so never enters the referent index (table row: "for +/// Free anchoring, no action"). +fn gesture_event_refs(anchoring: &GestureAnchoring) -> Vec { + match anchoring { + GestureAnchoring::Events(events) => { + events.iter().copied().map(TypedObjectId::Event).collect() + } + GestureAnchoring::Range { start, end, .. } => anchor_event_refs([start, end]), + GestureAnchoring::Free => Vec::new(), + } +} + +/// Replaces a range endpoint anchored to the tombstoned event with the +/// containing region's edge — the deterministic "truncate" reading for +/// range-anchored referents (start endpoints move to the region start, end +/// endpoints to the region end; see DECISIONS.md and the proposed Pass-12 row +/// on the underdetermined "truncate" semantics). +fn retarget_dead_endpoint( + endpoint: &mut TimeAnchor, + deleted: EventId, + region: RegionId, + edge: RegionEdge, +) { + if matches!(endpoint, TimeAnchor::Event { id, .. } if *id == deleted) { + *endpoint = TimeAnchor::Region { + id: region, + edge, + offset: AnchorOffset::Zero, + }; + } +} + +/// Degrades an orphaned annotation anchor's dead event references to the +/// containing-region forms, so the orphaned (kept) referent stays +/// reference-clean under invariant 10. The ledger records `Orphaned`; this is +/// anchor hygiene, not a re-anchoring choice. +fn orphan_annotation_anchor(anchor: &mut AnnotationAnchor, deleted: EventId, region: RegionId) { + match anchor { + AnnotationAnchor::Event(event) if *event == deleted => { + *anchor = AnnotationAnchor::Region(region); + } + AnnotationAnchor::Range { start, end } => { + retarget_dead_endpoint(start, deleted, region, RegionEdge::Start); + retarget_dead_endpoint(end, deleted, region, RegionEdge::End); + } + _ => {} + } +} + impl<'a> Reducer<'a> { fn new(op_set: &'a OperationSet) -> Self { Reducer { @@ -510,13 +667,17 @@ impl<'a> Reducer<'a> { structures: BTreeMap::new(), region_instances: BTreeMap::new(), instance_voices: BTreeMap::new(), + instance_staff: BTreeMap::new(), staff_based_regions: BTreeSet::new(), migrated_regions: BTreeSet::new(), region_migrator: BTreeMap::new(), descriptors: BTreeMap::new(), promotion: BTreeMap::new(), + system_mints: BTreeMap::new(), tx_minted: BTreeMap::new(), current_tx: None, + equivocation_resolutions: BTreeMap::new(), + promoted_singles: BTreeMap::new(), graph: None, } } @@ -581,6 +742,7 @@ impl<'a> Reducer<'a> { for instance in region.staff_instances() { self.objects .insert(TypedObjectId::StaffInstance(instance.id), ObjectState::Live); + self.instance_staff.insert(instance.id, instance.staff); let voice_set = self.instance_voices.entry(instance.id).or_default(); for voice in &instance.voices { voice_set.insert(voice.id); @@ -592,6 +754,30 @@ impl<'a> Reducer<'a> { for voice in &instance.voices { self.objects .insert(TypedObjectId::Voice(voice.id), ObjectState::Live); + // Register base system-promoted voices in the mint registry + // so a promotion minted by this reduction is collision-checked + // against them (Chapter 5 §"System-Derived Counter Collisions"). + // Base-internal duplicates keep the first registration: a base + // that already collided is invariant-11/18 territory, not a + // reduction-time mint. + if voice.id.replica() == ReplicaId::SYSTEM_DERIVED { + if let VoiceOrigin::SystemPromoted { + winning_operation, + losing_operation, + original_voice, + } = &voice.origin + { + let inputs = promoted_voice_inputs( + instance.id, + *original_voice, + *winning_operation, + *losing_operation, + ); + self.system_mints + .entry((ObjectKind::Voice, voice.id.counter())) + .or_insert((SerializedCanonicalInputs(inputs), None)); + } + } } } } @@ -607,6 +793,16 @@ impl<'a> Reducer<'a> { pitch_ids.push(pitch.id); self.objects .insert(TypedObjectId::Pitch(pitch.id), ObjectState::Live); + // Register base synthetic pitches in the mint registry (same + // rule as promoted voices above). + if pitch.id.replica() == ReplicaId::SYSTEM_DERIVED { + self.system_mints + .entry((ObjectKind::Pitch, pitch.id.counter())) + .or_insert(( + SerializedCanonicalInputs(canonical_pitch_bytes(&pitch.pitch)), + None, + )); + } } self.event_pitches.insert(event_id, pitch_ids); @@ -618,6 +814,22 @@ impl<'a> Reducer<'a> { .or_default() .push((position.clone(), duration.clone(), event_id)); } + + // A cue event *references* its source events (Chapter 5 §"Cue + // Events"), so it enters the referent index: a source tombstone + // cascade-deletes the cue through the re-anchoring rule table. + if let Event::Cue(cue) = event { + if !cue.source.is_empty() { + self.structures.insert( + TypedObjectId::Event(event_id), + cue.source + .iter() + .copied() + .map(TypedObjectId::Event) + .collect(), + ); + } + } } for slur in &score.cross_cutting.slurs { @@ -684,23 +896,47 @@ impl<'a> Reducer<'a> { .collect(), ); } + // The remaining referent kinds of the re-anchoring rule table enter the + // same index as slurs/ties/beams/spanners, keyed by their typed id and + // listing the event references a tombstone must repair. Non-event + // anchorings (region, measure, wall-clock, free) contribute no entry. for marker in &score.cross_cutting.markers { self.objects .insert(TypedObjectId::Marker(marker.id), ObjectState::Live); + let refs = anchor_event_refs([&marker.anchor]); + if !refs.is_empty() { + self.structures + .insert(TypedObjectId::Marker(marker.id), refs); + } } for annotation in &score.cross_cutting.analytical { self.objects.insert( TypedObjectId::AnalyticalAnnotation(annotation.id), ObjectState::Live, ); + let refs = annotation_anchor_event_refs(&annotation.anchor); + if !refs.is_empty() { + self.structures + .insert(TypedObjectId::AnalyticalAnnotation(annotation.id), refs); + } } for comment in &score.cross_cutting.comments { self.objects .insert(TypedObjectId::Comment(comment.id), ObjectState::Live); + let refs = annotation_anchor_event_refs(&comment.anchor); + if !refs.is_empty() { + self.structures + .insert(TypedObjectId::Comment(comment.id), refs); + } } for gesture in &score.cross_cutting.graphic_gestures { self.objects .insert(TypedObjectId::GraphicGesture(gesture.id), ObjectState::Live); + let refs = gesture_event_refs(&gesture.anchoring); + if !refs.is_empty() { + self.structures + .insert(TypedObjectId::GraphicGesture(gesture.id), refs); + } } for repeat in &score.cross_cutting.repeats { self.objects @@ -718,7 +954,7 @@ impl<'a> Reducer<'a> { fn run(mut self) -> (MaterializedState, Option) { let singles = self.op_set.single_envelopes(); - let equivocated: BTreeSet = + let equivocated_all: BTreeSet = self.op_set.equivocated_ids().into_iter().collect(); // 1. HLC monotonicity: exclude anomalous segments. @@ -731,21 +967,81 @@ impl<'a> Reducer<'a> { first_bad_counter: seg.first_bad_counter, }); } + + // 1b. ResolveEquivocation promotion (operation_catalog + // §"ResolveEquivocation"): a set-level, order-independent pre-pass. + // Among the Single-slot, non-excluded resolves whose `target` is an + // Equivocated slot and whose `chosen` names one of its candidates, the + // resolve earliest in canonical order (smallest reduction tuple — the + // same total HLC order `canonical_reduction_order` selects ready + // operations by) governs. The chosen candidate envelope joins the + // reducible set at its own canonical position — the slot reduces as if + // it had always been Single — and no `OperationSlotEquivocated` + // anomaly is recorded for it. The verdict is a pure function of the + // slot map (never of arrival order), so every replica agrees. A + // resolve that is itself equivocated occupies no Single slot and thus + // never governs; a resolve in a quarantined segment is excluded from + // reduction and likewise never governs. + let mut governing: BTreeMap = BTreeMap::new(); + for &env in &singles { + if excluded.contains(&env.id) { + continue; + } + let OperationPayload::ResolveEquivocation(op) = &env.payload else { + continue; + }; + let Some(slot) = self.op_set.slot(op.target) else { + continue; + }; + if !slot.is_equivocated() || !slot.candidates().any(|c| c == op.chosen) { + continue; + } + governing + .entry(op.target) + .and_modify(|current| { + if env.stamp.reduction_tuple() < current.stamp.reduction_tuple() { + *current = env; + } + }) + .or_insert(env); + } + for (target, resolve) in &governing { + let OperationPayload::ResolveEquivocation(op) = &resolve.payload else { + unreachable!("only ResolveEquivocation envelopes govern a promotion"); + }; + let candidate = self + .op_set + .candidate(op.chosen) + .expect("every candidate hash of an equivocated slot is retained in the store"); + self.promoted_singles.insert(*target, candidate); + self.equivocation_resolutions + .insert(*target, (resolve.id, op.chosen)); + } + // The losing candidates remain only in the opset's diagnostic + // candidate store; a resolved slot records no equivocation anomaly. + let equivocated: BTreeSet = equivocated_all + .into_iter() + .filter(|id| !governing.contains_key(id)) + .collect(); for id in &equivocated { self.record_anomaly(IntegrityAnomalyKind::OperationSlotEquivocated { operation_id: *id, }); } - // 2. Reducible candidates = Single slots minus excluded. + // 2. Reducible candidates = Single slots minus excluded, plus the + // promoted candidates (each at its own canonical position). let reducible: Vec<&OperationEnvelope> = singles .iter() .copied() .filter(|e| !excluded.contains(&e.id)) + .chain(self.promoted_singles.values().copied()) .collect(); let reducible_ids: BTreeSet = reducible.iter().map(|e| e.id).collect(); let declared_transactions: BTreeSet = singles .iter() + .copied() + .chain(self.promoted_singles.values().copied()) .filter_map(|env| match &env.payload { OperationPayload::Primitive(OperationKind::DeclareTransaction(descriptor)) => { Some(descriptor.id) @@ -774,9 +1070,54 @@ impl<'a> Reducer<'a> { // 5. Walk active ops in canonical reduction order; group transactions. let order = canonical_reduction_order(&active); let tx_members = transaction_members(&active); + + // 5a. System-derived counter collision check (Chapter 5 §"System-Derived + // Counter Collisions"): on a collision, reduction does not continue past + // the collision point, and neither colliding input set occupies the + // collided counter. Held operations stay in the (grow-only) operation + // set and surface in `pending` for external recovery. + let mut held: BTreeMap = BTreeMap::new(); + if let Some((halt_index, at, earlier_owner)) = self.detect_system_collision(&order) { + for env in &order[halt_index..] { + held.insert(env.id, PendingReason::HaltedBySystemCollision { at }); + } + if let Some(owner) = earlier_owner { + held.insert(owner, PendingReason::HaltedBySystemCollision { at }); + } + // Transitive closure: a transaction with a held member is wholly + // held (atomicity), and an operation causally covering a held + // operation is held behind it. + loop { + let mut changed = false; + for env in &order { + if held.contains_key(&env.id) { + continue; + } + let tx_blocked = member_transaction(env) + .and_then(|tx| tx_members.get(&tx)) + .and_then(|members| { + members + .iter() + .map(|m| m.id) + .filter(|id| held.contains_key(id)) + .min() + }); + let causal_blocked = + held.keys().copied().find(|h| env.causal_context.covers(*h)); + if let Some(on) = tx_blocked.into_iter().chain(causal_blocked).min() { + held.insert(env.id, PendingReason::DependsOnPending { on }); + changed = true; + } + } + if !changed { + break; + } + } + } + let mut processed: BTreeSet = BTreeSet::new(); for env in &order { - if processed.contains(&env.id) { + if processed.contains(&env.id) || held.contains_key(&env.id) { continue; } if let Some(tx) = member_transaction(env) { @@ -790,7 +1131,8 @@ impl<'a> Reducer<'a> { } } - let mut pending_vec: Vec<(OperationId, PendingReason)> = pending.into_iter().collect(); + let mut pending_vec: Vec<(OperationId, PendingReason)> = + pending.into_iter().chain(held).collect(); pending_vec.sort_by_key(|(id, _)| *id); let graph = self.graph.take(); @@ -813,7 +1155,12 @@ impl<'a> Reducer<'a> { } fn env_of(&self, id: OperationId) -> Option<&'a OperationEnvelope> { - self.op_set.slot(id).and_then(|s| s.single()) + // A slot promoted by a governing ResolveEquivocation reduces as if it + // had always been Single with the chosen candidate. + self.op_set + .slot(id) + .and_then(|s| s.single()) + .or_else(|| self.promoted_singles.get(&id).copied()) } // --- Voice promotion pre-pass (Chapter 6 §6.10 InsertEvent). ------------ @@ -866,6 +1213,107 @@ impl<'a> Reducer<'a> { } } + // --- System-derived counter collision check (Chapter 5 §"System-Derived + // Counter Collisions"). --------------------------------------------------- + + /// The system-derived identifiers an operation would admit into canonical + /// state, each with the canonical inputs of its derivation: the promoted + /// voice assigned by the promotion pre-pass, and any `SYSTEM_DERIVED`- + /// namespace pitch carried by a minting payload (InsertEvent, + /// InsertIdentifiedPitch). Non-minting references to system-derived ids — + /// e.g. a ModifyEvent rewriting a live pitch's content in place — are + /// deliberately not treated as mints (that is Invariant-11 territory, not a + /// derivation collision). + fn prospective_system_mints( + &self, + env: &OperationEnvelope, + ) -> Vec<((ObjectKind, u64), SerializedCanonicalInputs)> { + let mut mints = Vec::new(); + let OperationPayload::Primitive(kind) = &env.payload else { + return mints; + }; + match kind { + OperationKind::InsertEvent(op) => { + if let Some((promoted, winner)) = self.promotion.get(&env.id) { + mints.push(( + (ObjectKind::Voice, promoted.counter()), + SerializedCanonicalInputs(promoted_voice_inputs( + op.staff_instance, + op.voice(), + *winner, + env.id, + )), + )); + } + let mut pitches = Vec::new(); + op.event.collect_identified_pitches(&mut pitches); + for pitch in pitches { + if pitch.id.replica() == ReplicaId::SYSTEM_DERIVED { + mints.push(( + (ObjectKind::Pitch, pitch.id.counter()), + SerializedCanonicalInputs(canonical_pitch_bytes(&pitch.pitch)), + )); + } + } + } + OperationKind::InsertIdentifiedPitch(op) + if op.pitch.id.replica() == ReplicaId::SYSTEM_DERIVED => + { + mints.push(( + (ObjectKind::Pitch, op.pitch.id.counter()), + SerializedCanonicalInputs(canonical_pitch_bytes(&op.pitch.pitch)), + )); + } + _ => {} + } + mints + } + + /// Walks the canonical order checking every prospective system-derived + /// mint against the registry (base-seeded occupants plus earlier mints in + /// the walk). On the first collision — the same `(kind, counter)` claimed + /// by *different* canonical inputs — records the + /// `SystemIdentifierCollision` anomaly and returns the halt point + /// `(index, colliding op, earlier occupant op)`: reduction must not + /// continue past the collision, and neither input set may occupy the + /// collided counter (§"System-Derived Counter Collisions"). The earlier + /// occupant is `None` when it was seeded from the base graph, which cannot + /// be evicted by this reduction and is left to diagnostic recovery. + /// + /// The check is conservative: a claimed mint participates even if the + /// operation would later fail an unrelated apply-time precondition — + /// two input sets contending for one counter is a structural identity + /// failure regardless of which contender ultimately materializes. + fn detect_system_collision( + &mut self, + order: &[&OperationEnvelope], + ) -> Option<(usize, OperationId, Option)> { + for (index, env) in order.iter().enumerate() { + for (key, inputs) in self.prospective_system_mints(env) { + match self.system_mints.get(&key) { + None => { + self.system_mints.insert(key, (inputs, Some(env.id))); + } + Some((existing, _)) if existing.0 == inputs.0 => { + // The same derivation re-observed: not a collision. + } + Some((existing, owner)) => { + let owner = *owner; + let existing = existing.clone(); + self.record_anomaly(IntegrityAnomalyKind::SystemIdentifierCollision { + kind: key.0, + colliding_counter: key.1, + input_set_a: existing, + input_set_b: inputs, + }); + return Some((index, env.id, owner)); + } + } + } + } + None + } + fn graph_insert_precondition( &self, op: &InsertEventOp, @@ -1011,16 +1459,38 @@ impl<'a> Reducer<'a> { } } - fn materialize_graph_delete(&mut self, op: &DeleteEventOp) { + /// Removes the deleted event from the materialized graph and keeps it + /// reference-clean. Returns the repair records for the re-anchoring this + /// performs beyond the bookkeeping rules — the rule-table rows for the + /// graph-only referent kinds (markers, cue events, comments, analytical + /// annotations, graphic gestures) via [`Self::reanchor_event_referents`] — + /// so the triggering operation's effect can record them (Chapter 6 + /// §Re-Anchoring: "Re-anchoring actions MUST be recorded as RepairRecord + /// entries in the triggering operation's effect"). + fn materialize_graph_delete( + &mut self, + env: &OperationEnvelope, + op: &DeleteEventOp, + ) -> Vec { let Some(score) = self.graph.as_mut() else { - return; + return Vec::new(); }; let Some(event) = score.events.remove(op.event) else { - return; + return Vec::new(); }; let voice_id = event.voice(); let location = graph_voice_location(score, voice_id); let region_id = location.map(|(region, _, _)| score.canvas.regions[region].id); + // The referent side of the four-key "nearest" ordering, captured before + // any mutation: the tombstoned event's containment and resolved + // placement (positions are region-relative; exact rational time in + // metric regions). + let referent = ReferentContext { + voice: voice_id, + region: region_id, + position: event.position().clone(), + duration: event.duration().clone(), + }; let removed_event_index = location.and_then(|(region, instance, voice)| { score.canvas.regions[region].staff_instances()[instance].voices[voice] .events @@ -1086,6 +1556,17 @@ impl<'a> Reducer<'a> { .cross_cutting .tuplets .retain(|tuplet| !removed.contains(&tuplet.id)); + // A decomposition component records its tuplet by id; once that tuplet is + // gone the reference would dangle (invariant 6, cross-cutting refs + // resolve), so drop any attachment that names a removed tuplet. The + // member it described is being tombstoned in the same cascade, so the + // decomposition has nothing left to describe. + score.decomposition_attachments.retain(|attachment| { + !attachment + .components + .iter() + .any(|component| component.tuplet.is_some_and(|t| removed.contains(&t))) + }); } TupletCompensation::NotInTuplet | TupletCompensation::RewriteTuplets { .. } => {} } @@ -1182,21 +1663,19 @@ impl<'a> Reducer<'a> { line.events.retain(|event| *event != op.event); !line.events.is_empty() }); - if let Some(region) = region_id { - let fallback = TimeAnchor::Region { - id: region, - edge: RegionEdge::Start, - offset: AnchorOffset::Zero, - }; - for marker in &mut score.cross_cutting.markers { - if matches!(marker.anchor, TimeAnchor::Event { id, .. } if id == op.event) { - marker.anchor = fallback.clone(); - } - } - } + // The remaining rule-table rows — markers, cue events, comments, + // analytical annotations, graphic gestures — are decided and applied + // together (ledger record + graph mutation) once the event is out of + // the graph, so the two can never disagree. + self.reanchor_event_referents(env, op.event, &referent) } - fn materialize_graph_tombstones(&mut self, targets: &[TypedObjectId]) { + fn materialize_graph_tombstones( + &mut self, + env: &OperationEnvelope, + targets: &[TypedObjectId], + ) -> Vec { + let mut repairs = Vec::new(); let events: Vec = targets .iter() .filter_map(|target| match target { @@ -1210,14 +1689,17 @@ impl<'a> Reducer<'a> { } self.voice_occupancy .retain(|_, placements| !placements.is_empty()); - self.materialize_graph_delete(&DeleteEventOp { - event, - tuplet_compensation: TupletCompensation::NotInTuplet, - }); + repairs.extend(self.materialize_graph_delete( + env, + &DeleteEventOp { + event, + tuplet_compensation: TupletCompensation::NotInTuplet, + }, + )); } let Some(score) = self.graph.as_mut() else { - return; + return repairs; }; for target in targets { match target { @@ -1247,6 +1729,7 @@ impl<'a> Reducer<'a> { _ => {} } } + repairs } fn materialize_graph_cross_cutting( @@ -1312,6 +1795,7 @@ impl<'a> Reducer<'a> { }, OperationPayload::ResolveConflict(op) => self.resolve_conflict(env, op), OperationPayload::UndoTransaction(op) => self.undo_transaction(env, op), + OperationPayload::ResolveEquivocation(op) => self.resolve_equivocation(env, op), } } @@ -1502,6 +1986,22 @@ impl<'a> Reducer<'a> { } } }; + // Pitch-id freshness in base-free reduction: a carried pitch id that + // already exists in canonical state (live or tombstoned) is not fresh. + // The graph-aware precondition above already enforces this (with the + // same reason), so this only fires when no graph is present — it keeps + // the two reduction APIs in agreement on the same operation set. + if op + .pitch_ids() + .iter() + .any(|pitch| self.objects.contains_key(&TypedObjectId::Pitch(*pitch))) + { + return OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::TargetTombstoned, + }, + }; + } let voice_obj = TypedObjectId::Voice(orig_voice); match self.objects.get(&voice_obj) { Some(ObjectState::Tombstoned { .. }) => { @@ -1620,7 +2120,20 @@ impl<'a> Reducer<'a> { .find(|(_, _, event)| *event == op.event) .map(|(position, duration, _)| (*voice, position.clone(), duration.clone())) }); - self.materialize_graph_delete(op); + // The tombstoned referent's voice, for the containment-proximity key of + // the re-anchoring reasons below: the occupancy index (base-free) with + // the graph as fallback for non-metric events. Captured before the + // graph delete removes the event. + let referent_voice = deleted_placement + .as_ref() + .map(|(voice, _, _)| *voice) + .or_else(|| { + self.graph + .as_ref() + .and_then(|score| score.events.get(op.event)) + .map(Event::voice) + }); + let graph_repairs = self.materialize_graph_delete(env, op); let minter = self.minted_by.get(&ev_obj).copied().unwrap_or(env.id); self.objects.insert( @@ -1634,7 +2147,7 @@ impl<'a> Reducer<'a> { events.retain(|(_, _, event)| *event != op.event); } self.voice_occupancy.retain(|_, events| !events.is_empty()); - let mut repairs = Vec::new(); + let mut repairs = graph_repairs; // Tombstone contained pitches. if let Some(pitches) = self.event_pitches.get(&op.event).cloned() { @@ -1713,7 +2226,7 @@ impl<'a> Reducer<'a> { } // Re-anchor cross-cutting structures referencing the tombstoned event. - self.reanchor_for_tombstone(env, ev_obj, &mut repairs); + self.reanchor_for_tombstone(env, ev_obj, &mut repairs, referent_voice); if repairs.is_empty() { OperationEffect::Applied @@ -2138,6 +2651,8 @@ impl<'a> Reducer<'a> { .or_default() .insert(op.instance_id()); self.instance_voices.entry(op.instance_id()).or_default(); + self.instance_staff + .insert(op.instance_id(), op.instance.staff); OperationEffect::Applied } @@ -2524,20 +3039,26 @@ impl<'a> Reducer<'a> { } OperationEffect::Applied } - Some(RS::Resolved { action, .. }) => { + Some(RS::Resolved { by, action }) => { if action == op.action { OperationEffect::NoOp { reason: NoOpReason::AlreadyApplied, } } else { - // Differing concurrent resolution → meta-conflict. + // Differing concurrent resolution → meta-conflict. The + // earlier resolve stands (its action materialized), so it + // is the record's winner; this op is the loser, and both + // resolvers are named as causes ("at least two for a true + // conflict", Chapter 6 §Conflict Records). A conflict + // record has no TypedObjectId, so `affected_objects` + // cannot name the contested conflict and stays empty. let conflict = ConflictRecord::new( ConflictKind::StructuralFieldCollision { - winner: env.id, + winner: by, loser: env.id, field: FieldPath("conflict_resolution".to_string()), }, - vec![env.id], + vec![by, env.id], vec![], ); let cid = conflict.id; @@ -2551,6 +3072,73 @@ impl<'a> Reducer<'a> { } } + /// The recorded effect of a `ResolveEquivocation` (operation_catalog + /// §"ResolveEquivocation"). The slot promotion itself happened in the + /// set-level pre-pass ([`Reducer::run`] step 1b); this only records the + /// per-resolve verdict, mirroring [`Reducer::resolve_conflict`]'s + /// discipline: the governing (earliest-in-canonical-order) resolve is + /// `Applied`; a later resolve naming the same candidate reduces + /// idempotently; a later valid resolve naming a differing candidate is a + /// `StructuralFieldCollision` meta-conflict on `equivocation_resolution`. + /// A resolve whose target is not an equivocated slot, or whose chosen is + /// not among the slot's candidates, is a precondition no-op + /// (`TargetMissing` — the named target/candidate pair does not exist). + fn resolve_equivocation( + &mut self, + env: &OperationEnvelope, + op: &crate::payload::ResolveEquivocationPayload, + ) -> OperationEffect { + let precondition_noop = OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::TargetMissing, + }, + }; + match self.equivocation_resolutions.get(&op.target) { + // No governing resolve exists for the target: it is absent, holds + // a Single slot, or is equivocated with no valid resolve — in + // every case this resolve's precondition ("an Equivocated slot for + // `target` with `chosen` among its candidates") failed, or it + // would have governed. + None => precondition_noop, + Some((winner, chosen)) => { + if env.id == *winner { + OperationEffect::Applied + } else if op.chosen == *chosen { + // A later resolve naming the same candidate: idempotent. + OperationEffect::NoOp { + reason: NoOpReason::AlreadyApplied, + } + } else if !self + .op_set + .slot(op.target) + .is_some_and(|slot| slot.candidates().any(|c| c == op.chosen)) + { + // A differing `chosen` that never named a real candidate + // is a failed precondition, not a contested resolution. + precondition_noop + } else { + // Two valid resolves naming differing candidates: the + // governing (earlier) resolve stands as the record's + // winner; this op is the loser, both named as causes. A + // slot is not a TypedObjectId, so `affected_objects` + // stays empty (the ResolveConflict discipline). + let conflict = ConflictRecord::new( + ConflictKind::StructuralFieldCollision { + winner: *winner, + loser: env.id, + field: FieldPath("equivocation_resolution".to_string()), + }, + vec![*winner, env.id], + vec![], + ); + let cid = conflict.id; + self.conflicts.insert(conflict); + OperationEffect::Conflicted { conflict: cid } + } + } + } + } + fn undo_transaction( &mut self, env: &OperationEnvelope, @@ -2585,7 +3173,7 @@ impl<'a> Reducer<'a> { target: *t, }); } - self.materialize_graph_tombstones(&targets); + repairs.extend(self.materialize_graph_tombstones(env, &targets)); OperationEffect::AppliedWithRepair { repairs } } else { // A target was already tombstoned/modified: strict undo conflicts. @@ -2627,7 +3215,7 @@ impl<'a> Reducer<'a> { tombstoned.push(*t); } } - self.materialize_graph_tombstones(&tombstoned); + repairs.extend(self.materialize_graph_tombstones(env, &tombstoned)); OperationEffect::AppliedWithRepair { repairs } } } @@ -2815,28 +3403,45 @@ impl<'a> Reducer<'a> { } fn transpose(&mut self, _env: &OperationEnvelope, op: &TransposeOp) -> OperationEffect { - // Precondition: every target pitch is live. Transpose is order-dependent - // (transpositions do not commute); its canonical footprint is the - // effect-log entry. The transposed values are materialized in the graph. - for pitch in &op.targets { - match self.objects.get(&TypedObjectId::Pitch(*pitch)) { - Some(ObjectState::Live) => {} - Some(ObjectState::Tombstoned { .. }) => { - return OperationEffect::NoOp { - reason: NoOpReason::TargetTombstoned, - } - } - None => { - return OperationEffect::NoOp { - reason: NoOpReason::PreconditionFailedUnderReduction { - reason: PreconditionFailureReason::TargetMissing, - }, - } - } - } + // Precondition: a target that never entered canonical state is a + // dangling reference — the whole operation refuses. Tombstoned targets + // are *skipped* per the catalog's re-anchoring rule ("the transpose + // applies only to live pitches", Operation Catalog §Transpose); the + // shift still applies to the remaining live targets. Transpose is + // order-dependent (transpositions do not commute); its canonical + // footprint is the effect-log entry. The transposed values are + // materialized in the graph. + if op + .targets + .iter() + .any(|pitch| !self.objects.contains_key(&TypedObjectId::Pitch(*pitch))) + { + return OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::TargetMissing, + }, + }; } - for pitch in &op.targets { - self.graph_transpose_pitch(*pitch, op.chromatic_steps); + let live: Vec = op + .targets + .iter() + .copied() + .filter(|pitch| { + matches!( + self.objects.get(&TypedObjectId::Pitch(*pitch)), + Some(ObjectState::Live) + ) + }) + .collect(); + if live.is_empty() { + // Every target was tombstoned by a causally-prior delete: the + // skip-all case degenerates to no effect. + return OperationEffect::NoOp { + reason: NoOpReason::TargetTombstoned, + }; + } + for pitch in live { + self.graph_transpose_pitch(pitch, op.chromatic_steps); } OperationEffect::Applied } @@ -3149,6 +3754,7 @@ impl<'a> Reducer<'a> { env: &OperationEnvelope, tombstoned: TypedObjectId, repairs: &mut Vec, + referent_voice: Option, ) { // Find structures referencing the tombstoned object. let referencing: Vec = self @@ -3163,13 +3769,18 @@ impl<'a> Reducer<'a> { // A tie's existence requires both endpoints: cascade-delete. self.cascade_structure(env, sid, repairs); } - TypedObjectId::Comment(_) | TypedObjectId::AnalyticalAnnotation(_) => { - // User content is never silently deleted: orphan. - repairs.push(RepairRecord { - kind: RepairKind::Orphaned, - target: sid, - }); - } + // The graph-only referent kinds — markers, cue events, + // comments, analytical annotations, graphic gestures — are + // repaired where their graph mutation happens + // (`reanchor_event_referents`, run from + // `materialize_graph_delete`), so the ledger record and the + // graph always agree. They only exist under graph-aware + // reduction (none is creatable by an operation). + TypedObjectId::Marker(_) + | TypedObjectId::Comment(_) + | TypedObjectId::AnalyticalAnnotation(_) + | TypedObjectId::GraphicGesture(_) + | TypedObjectId::Event(_) => {} TypedObjectId::Beam(_) => { let survivors = self.surviving_endpoints(sid, tombstoned); if survivors < 2 { @@ -3188,11 +3799,29 @@ impl<'a> Reducer<'a> { if survivors < 1 { self.cascade_structure(env, sid, repairs); } else if let Some(to) = self.nearest_survivor(sid, tombstoned) { + // The survivor is fixed (the structure's other + // endpoint — surviving-endpoint collapse per the rule + // table), so only the containment key applies: the + // reason names the survivor's actual proximity rank to + // the tombstoned endpoint rather than a hardcoded + // same-voice claim. + let survivor_voice = match to { + TypedObjectId::Event(event) => self.event_voice(event), + _ => None, + }; + let reason = match (referent_voice, survivor_voice) { + (Some(referent), Some(survivor)) => { + reason_for_rank(self.containment_rank(referent, survivor)) + } + // No indexed placement for either side (a + // non-metric endpoint): the pre-four-key default. + _ => ReanchorReason::SameVoiceNearer, + }; repairs.push(RepairRecord { kind: RepairKind::Reanchored { from: tombstoned, to, - reason: ReanchorReason::SameVoiceNearer, + reason, }, target: sid, }); @@ -3249,9 +3878,13 @@ impl<'a> Reducer<'a> { sid: TypedObjectId, just_tombstoned: TypedObjectId, ) -> Option { - // Deterministic "nearest" stand-in: the lexicographically-smallest - // surviving endpoint (the spec's full proximity ordering needs resolved - // positions; see DECISIONS.md). + // For slurs/spanners the rule table prescribes surviving-endpoint + // collapse, so the candidate set is the structure's own endpoints; the + // lexicographically-smallest survivor realizes the id tie-break (a + // two-endpoint structure has exactly one). Proximity-aware re-targeting + // beyond the endpoints stays a deferred refinement (the table says so + // explicitly); the open-candidate four-key ordering lives in + // `nearest_live_event`. self.structures.get(&sid).and_then(|eps| { eps.iter() .filter(|e| { @@ -3263,6 +3896,594 @@ impl<'a> Reducer<'a> { }) } + // --- The four-key "nearest" ordering (Chapter 6 §"Total Ordering for + // Nearest") and the graph-only rule-table rows. --------------------------- + + /// The staff instance a voice lives in, from the base-free ledger index. + fn voice_instance(&self, voice: VoiceId) -> Option { + self.instance_voices + .iter() + .find_map(|(instance, voices)| voices.contains(&voice).then_some(*instance)) + } + + /// The region a staff instance lives in, from the base-free ledger index. + fn instance_region_of(&self, instance: StaffInstanceId) -> Option { + self.region_instances + .iter() + .find_map(|(region, instances)| instances.contains(&instance).then_some(*region)) + } + + /// The voice of a live event with an indexed metric placement. + fn event_voice(&self, event: EventId) -> Option { + self.voice_occupancy.iter().find_map(|(voice, placements)| { + placements + .iter() + .any(|(_, _, placed)| *placed == event) + .then_some(*voice) + }) + } + + /// Containment proximity (key 1 of the "nearest" ordering): same voice 0, + /// same staff instance 1, same staff 2, same region 3, same canvas 4. + /// Computed from the base-free ledger indices, so `reduce()` and + /// `reduce_onto()` rank identically wherever both represent the scenario. + fn containment_rank(&self, referent_voice: VoiceId, candidate_voice: VoiceId) -> u8 { + if referent_voice == candidate_voice { + return 0; + } + let (Some(referent), Some(candidate)) = ( + self.voice_instance(referent_voice), + self.voice_instance(candidate_voice), + ) else { + return 4; + }; + if referent == candidate { + return 1; + } + if let (Some(a), Some(b)) = ( + self.instance_staff.get(&referent), + self.instance_staff.get(&candidate), + ) { + if a == b { + return 2; + } + } + if let (Some(a), Some(b)) = ( + self.instance_region_of(referent), + self.instance_region_of(candidate), + ) { + if a == b { + return 3; + } + } + 4 + } + + /// The nearest surviving live event to the tombstoned referent under the + /// four-key total order (Chapter 6 §"Total Ordering for Nearest"): the + /// strict lexicographic minimum of (containment proximity, absolute time + /// distance from the referent's resolved position, forward before + /// backward, typed id bytes ascending — an `EventId`'s numeric order *is* + /// its canonical 16-byte order). Candidates ranked farther than `max_rank` + /// are excluded. Read entirely from the canonical ledger indices, so the + /// choice is a function of canonical state (permutation-invariant). Only + /// *metric* placements are indexed; wall-clock distance (proportional + /// regions) is a deferred refinement, so a wall-clock referent finds no + /// candidate and falls to the kind's declared failure action. + fn nearest_live_event( + &self, + referent: &ReferentContext, + exclude: EventId, + max_rank: u8, + ) -> Option<(EventId, u8)> { + let EventPosition::Musical(referent_position) = &referent.position else { + return None; + }; + let mut best: Option<(u8, RationalTime, u8, EventId)> = None; + for (voice, placements) in &self.voice_occupancy { + let rank = self.containment_rank(referent.voice, *voice); + if rank > max_rank { + continue; + } + for (position, _, event) in placements { + if *event == exclude + || !matches!( + self.objects.get(&TypedObjectId::Event(*event)), + Some(ObjectState::Live) + ) + { + continue; + } + let signed = position.0.sub(&referent_position.0); + let (direction, distance) = if signed.is_negative() { + (1u8, RationalTime::zero().sub(&signed)) + } else { + (0u8, signed) + }; + let key = (rank, distance, direction, *event); + if best.as_ref().map_or(true, |current| key < *current) { + best = Some(key); + } + } + } + best.map(|(rank, _, _, event)| (event, rank)) + } + + /// Drops `dead` from `sid`'s referent-index entry, removing the entry when + /// no event reference remains. + fn drop_structure_ref(&mut self, sid: TypedObjectId, dead: TypedObjectId) { + if let Some(refs) = self.structures.get_mut(&sid) { + refs.retain(|existing| *existing != dead); + if refs.is_empty() { + self.structures.remove(&sid); + } + } + } + + /// The rule-table rows for the graph-only referent kinds — markers, cue + /// events, comments, analytical annotations, graphic gestures (Chapter 6 + /// §"The Re-Anchoring Rule Table"). Runs from + /// [`Self::materialize_graph_delete`], so both the DeleteEvent path and the + /// undo path record the same repairs in the triggering operation's effect. + /// Each row's ledger record and graph mutation are decided together, in + /// canonical id order ("the graph follows the ledger"). + fn reanchor_event_referents( + &mut self, + env: &OperationEnvelope, + deleted: EventId, + referent: &ReferentContext, + ) -> Vec { + let mut repairs = Vec::new(); + let deleted_obj = TypedObjectId::Event(deleted); + // The deleted event's own referent entry (a cue's source list) dies + // with it. + self.structures.remove(&deleted_obj); + let referencing: Vec = self + .structures + .iter() + .filter(|(sid, refs)| { + matches!( + sid, + TypedObjectId::Marker(_) + | TypedObjectId::Comment(_) + | TypedObjectId::AnalyticalAnnotation(_) + | TypedObjectId::GraphicGesture(_) + | TypedObjectId::Event(_) + ) && refs.contains(&deleted_obj) + && matches!(self.objects.get(sid), Some(ObjectState::Live)) + }) + .map(|(sid, _)| *sid) + .collect(); + for sid in referencing { + match sid { + TypedObjectId::Marker(_) => { + self.reanchor_marker(deleted, referent, sid, &mut repairs) + } + TypedObjectId::Event(cue) => self.cascade_cue(env, cue, &mut repairs), + TypedObjectId::Comment(_) => { + self.orphan_comment(deleted, referent, sid, &mut repairs) + } + TypedObjectId::AnalyticalAnnotation(_) => { + self.reanchor_annotation(deleted, referent, sid, &mut repairs) + } + TypedObjectId::GraphicGesture(_) => { + self.reanchor_gesture(deleted, referent, sid, &mut repairs) + } + _ => {} + } + } + repairs + } + + /// Row "Marker / Anchor": re-anchor to the nearest event in the same staff + /// instance (proximity max: same staff instance); orphan on failure. + fn reanchor_marker( + &mut self, + deleted: EventId, + referent: &ReferentContext, + sid: TypedObjectId, + repairs: &mut Vec, + ) { + let TypedObjectId::Marker(marker) = sid else { + return; + }; + match self.nearest_live_event(referent, deleted, PROXIMITY_SAME_STAFF_INSTANCE) { + Some((to, rank)) => { + if let Some(score) = self.graph.as_mut() { + if let Some(value) = score + .cross_cutting + .markers + .iter_mut() + .find(|value| value.id == marker) + { + if let TimeAnchor::Event { id, .. } = &mut value.anchor { + if *id == deleted { + // The anchor offset is preserved: the survivor + // shares the staff instance, hence the region + // and its offset discipline (invariant 9). + *id = to; + } + } + } + } + self.structures.insert(sid, vec![TypedObjectId::Event(to)]); + repairs.push(RepairRecord { + kind: RepairKind::Reanchored { + from: TypedObjectId::Event(deleted), + to: TypedObjectId::Event(to), + reason: reason_for_rank(rank), + }, + target: sid, + }); + } + None => { + // Orphan: the marker (user content) is kept. Invariant 10 + // rejects a dangling event anchor, so the graph anchor degrades + // to the containing region's start — anchor hygiene, not a + // re-anchoring choice; the ledger records the orphaning. + if let Some(region) = referent.region { + if let Some(score) = self.graph.as_mut() { + if let Some(value) = score + .cross_cutting + .markers + .iter_mut() + .find(|value| value.id == marker) + { + if matches!(value.anchor, TimeAnchor::Event { id, .. } if id == deleted) + { + value.anchor = TimeAnchor::Region { + id: region, + edge: RegionEdge::Start, + offset: AnchorOffset::Zero, + }; + } + } + } + } + self.structures.remove(&sid); + repairs.push(RepairRecord { + kind: RepairKind::Orphaned, + target: sid, + }); + } + } + } + + /// Row "Cue event / Source event": cascade-delete — the plain normative + /// action, on *any* source deletion (the multi-source rationale tension is + /// a proposed Pass-12 row). The cascaded cue is itself a tombstoned event, + /// so the full re-anchoring pass — the graph-only rows via the recursive + /// `materialize_graph_delete`, and the tie/beam/slur/spanner ledger arm via + /// `reanchor_for_tombstone` — runs over its own referents transitively, in + /// the same reduction step. + fn cascade_cue( + &mut self, + env: &OperationEnvelope, + cue: EventId, + repairs: &mut Vec, + ) { + let sid = TypedObjectId::Event(cue); + // A cue this pass already cascaded transitively (a cue-of-a-cue chain + // reaching back into the referencing list) must not double-record. + if !matches!(self.objects.get(&sid), Some(ObjectState::Live)) { + return; + } + let cue_voice = self.event_voice(cue); + self.cascade_structure(env, sid, repairs); + self.structures.remove(&sid); + for events in self.voice_occupancy.values_mut() { + events.retain(|(_, _, event)| *event != cue); + } + self.voice_occupancy.retain(|_, events| !events.is_empty()); + self.event_pitches.remove(&cue); + let cue_delete = DeleteEventOp { + event: cue, + tuplet_compensation: TupletCompensation::NotInTuplet, + }; + repairs.extend(self.materialize_graph_delete(env, &cue_delete)); + self.reanchor_for_tombstone(env, sid, repairs, cue_voice); + } + + /// Row "Comment / Anchor": orphan — user content is never silently + /// deleted. The comment stays live in ledger and graph; its dangling + /// anchor references degrade to the containing-region forms so invariant + /// 10 keeps holding. + fn orphan_comment( + &mut self, + deleted: EventId, + referent: &ReferentContext, + sid: TypedObjectId, + repairs: &mut Vec, + ) { + let TypedObjectId::Comment(comment) = sid else { + return; + }; + if let Some(region) = referent.region { + if let Some(score) = self.graph.as_mut() { + if let Some(value) = score + .cross_cutting + .comments + .iter_mut() + .find(|value| value.id == comment) + { + orphan_annotation_anchor(&mut value.anchor, deleted, region); + } + } + } + self.drop_structure_ref(sid, TypedObjectId::Event(deleted)); + repairs.push(RepairRecord { + kind: RepairKind::Orphaned, + target: sid, + }); + } + + /// Row "Analytical annotation / Anchor": re-anchor to a time range + /// preserving the original extent; orphan when the range cannot be + /// reconstructed. Reconstruction needs the containing region plus an exact + /// musical placement — the range endpoints become region-start offsets, so + /// they resolve to the deleted event's exact span. A wall-clock or + /// indeterminate span is not expressible as a stored region-relative range + /// (the expressibility gap is a proposed Pass-12 row), so it orphans. + fn reanchor_annotation( + &mut self, + deleted: EventId, + referent: &ReferentContext, + sid: TypedObjectId, + repairs: &mut Vec, + ) { + let TypedObjectId::AnalyticalAnnotation(annotation) = sid else { + return; + }; + let deleted_obj = TypedObjectId::Event(deleted); + let current = self.graph.as_ref().and_then(|score| { + score + .cross_cutting + .analytical + .iter() + .find(|value| value.id == annotation) + .map(|value| value.anchor.clone()) + }); + let Some(current) = current else { + self.drop_structure_ref(sid, deleted_obj); + return; + }; + // A stale index entry (the anchor no longer references the deleted + // event) drops the reference with no repair. + if !annotation_anchor_event_refs(¤t).contains(&deleted_obj) { + self.drop_structure_ref(sid, deleted_obj); + return; + } + let musical_span = match (&referent.position, &referent.duration) { + (EventPosition::Musical(position), EventDuration::Musical(duration)) => { + Some((position.0.clone(), duration.0.clone())) + } + _ => None, + }; + let range_point = |resolved: RationalTime, region: RegionId| TimeAnchor::Region { + id: region, + edge: RegionEdge::Start, + offset: AnchorOffset::Musical(MusicalDuration(resolved)), + }; + let reconstructed: Option = match ¤t { + AnnotationAnchor::Event(event) if *event == deleted => { + match (musical_span.as_ref(), referent.region) { + (Some((position, duration)), Some(region)) => Some(AnnotationAnchor::Range { + start: range_point(position.clone(), region), + end: range_point(position.add(duration), region), + }), + _ => None, + } + } + AnnotationAnchor::Range { start, end } => { + // Per endpoint: an event-anchored endpoint of the deleted event + // is rebuilt at its resolved position (the event's position + // plus any musical anchor offset); live endpoints are kept. + let rebuild = |endpoint: &TimeAnchor| -> Option { + match endpoint { + TimeAnchor::Event { id, offset } if *id == deleted => { + let (position, _) = musical_span.as_ref()?; + let region = referent.region?; + let resolved = match offset { + AnchorOffset::Zero => position.clone(), + AnchorOffset::Musical(delta) => position.add(&delta.0), + AnchorOffset::WallClock(_) => return None, + }; + Some(range_point(resolved, region)) + } + other => Some(other.clone()), + } + }; + match (rebuild(start), rebuild(end)) { + (Some(start), Some(end)) => Some(AnnotationAnchor::Range { start, end }), + _ => None, + } + } + // Stale index entry (the anchor no longer references the deleted + // event): drop the reference, no repair. + _ => { + self.drop_structure_ref(sid, deleted_obj); + return; + } + }; + match reconstructed { + Some(anchor) => { + let region = referent + .region + .expect("range reconstruction required the containing region"); + if let Some(score) = self.graph.as_mut() { + if let Some(value) = score + .cross_cutting + .analytical + .iter_mut() + .find(|value| value.id == annotation) + { + value.anchor = anchor; + } + } + self.drop_structure_ref(sid, deleted_obj); + repairs.push(RepairRecord { + kind: RepairKind::Reanchored { + from: deleted_obj, + to: TypedObjectId::Region(region), + reason: ReanchorReason::ExplicitFallback, + }, + target: sid, + }); + } + None => { + if let Some(region) = referent.region { + if let Some(score) = self.graph.as_mut() { + if let Some(value) = score + .cross_cutting + .analytical + .iter_mut() + .find(|value| value.id == annotation) + { + orphan_annotation_anchor(&mut value.anchor, deleted, region); + } + } + } + self.drop_structure_ref(sid, deleted_obj); + repairs.push(RepairRecord { + kind: RepairKind::Orphaned, + target: sid, + }); + } + } + } + + /// Row "Graphic gesture / Anchor event": re-anchor each deleted event + /// reference to the nearest surviving event of the same staff instance + /// (proximity max: same staff instance); with no candidate the reference is + /// dropped — truncation while references remain, orphaning when the list + /// empties. Range anchoring truncates (dead endpoints move to the region + /// edges); Free anchoring is never indexed. + fn reanchor_gesture( + &mut self, + deleted: EventId, + referent: &ReferentContext, + sid: TypedObjectId, + repairs: &mut Vec, + ) { + let TypedObjectId::GraphicGesture(gesture) = sid else { + return; + }; + let deleted_obj = TypedObjectId::Event(deleted); + let current = self.graph.as_ref().and_then(|score| { + score + .cross_cutting + .graphic_gestures + .iter() + .find(|value| value.id == gesture) + .map(|value| value.anchoring.clone()) + }); + let Some(anchoring) = current else { + self.drop_structure_ref(sid, deleted_obj); + return; + }; + let set_anchoring = |reducer: &mut Self, anchoring: GestureAnchoring| { + if let Some(score) = reducer.graph.as_mut() { + if let Some(value) = score + .cross_cutting + .graphic_gestures + .iter_mut() + .find(|value| value.id == gesture) + { + value.anchoring = anchoring; + } + } + }; + match anchoring { + GestureAnchoring::Events(events) => { + if !events.contains(&deleted) { + self.drop_structure_ref(sid, deleted_obj); + return; + } + match self.nearest_live_event(referent, deleted, PROXIMITY_SAME_STAFF_INSTANCE) { + Some((to, rank)) => { + let retargeted: Vec = events + .iter() + .map(|event| if *event == deleted { to } else { *event }) + .collect(); + self.structures.insert( + sid, + retargeted + .iter() + .copied() + .map(TypedObjectId::Event) + .collect(), + ); + set_anchoring(self, GestureAnchoring::Events(retargeted)); + repairs.push(RepairRecord { + kind: RepairKind::Reanchored { + from: deleted_obj, + to: TypedObjectId::Event(to), + reason: reason_for_rank(rank), + }, + target: sid, + }); + } + None => { + let remaining: Vec = events + .iter() + .copied() + .filter(|event| *event != deleted) + .collect(); + let emptied = remaining.is_empty(); + if emptied { + self.structures.remove(&sid); + } else { + self.structures.insert( + sid, + remaining + .iter() + .copied() + .map(TypedObjectId::Event) + .collect(), + ); + } + set_anchoring(self, GestureAnchoring::Events(remaining)); + repairs.push(RepairRecord { + kind: if emptied { + // The reference list emptied: the gesture (user + // content) is kept, reference-free. + RepairKind::Orphaned + } else { + RepairKind::SpannerTruncated { + removed_members: vec![deleted_obj], + } + }, + target: sid, + }); + } + } + } + GestureAnchoring::Range { start, end, staves } => { + let Some(region) = referent.region else { + self.drop_structure_ref(sid, deleted_obj); + return; + }; + let mut start = start; + let mut end = end; + retarget_dead_endpoint(&mut start, deleted, region, RegionEdge::Start); + retarget_dead_endpoint(&mut end, deleted, region, RegionEdge::End); + set_anchoring(self, GestureAnchoring::Range { start, end, staves }); + self.drop_structure_ref(sid, deleted_obj); + repairs.push(RepairRecord { + kind: RepairKind::Reanchored { + from: deleted_obj, + to: TypedObjectId::Region(region), + reason: ReanchorReason::ExplicitFallback, + }, + target: sid, + }); + } + GestureAnchoring::Free => { + self.drop_structure_ref(sid, deleted_obj); + } + } + } + // --- Transactions (Chapter 6 §6.6). ------------------------------------- fn reduce_transaction_block(&mut self, tx: TransactionId, members: &[&'a OperationEnvelope]) { @@ -3375,6 +4596,7 @@ impl<'a> Reducer<'a> { structures: self.structures.clone(), region_instances: self.region_instances.clone(), instance_voices: self.instance_voices.clone(), + instance_staff: self.instance_staff.clone(), staff_based_regions: self.staff_based_regions.clone(), migrated_regions: self.migrated_regions.clone(), region_migrator: self.region_migrator.clone(), @@ -3401,6 +4623,7 @@ impl<'a> Reducer<'a> { self.structures = s.structures; self.region_instances = s.region_instances; self.instance_voices = s.instance_voices; + self.instance_staff = s.instance_staff; self.staff_based_regions = s.staff_based_regions; self.migrated_regions = s.migrated_regions; self.region_migrator = s.region_migrator; @@ -4253,4 +5476,882 @@ mod tests { ConflictKind::StructuralFieldCollision { .. } )); } + + // --- Push-1 spec-compliance fixes (Transpose skip, meta-conflict record, + // marker re-anchor repair, system-derived counter collisions). ----------- + + /// An InsertEvent envelope whose event carries exactly one identified + /// pitch with the given id and intrinsic content. + #[allow(clippy::too_many_arguments)] + fn insert_with_pitch_content( + replica: u64, + counter: u64, + physical: i64, + voice: u64, + event: u64, + pos_units: i64, + pitch_id: PitchId, + content: &Pitch, + ) -> OperationEnvelope { + let mut env = insert(replica, counter, physical, voice, event, pos_units); + if let OperationPayload::Primitive(OperationKind::InsertEvent(ref mut op)) = env.payload { + op.event = crate::valuegen::insert_event_value( + op.event_id(), + op.voice(), + pos(pos_units), + epiphany_core::MusicalDuration::whole(), + &[pitch_id], + ); + if let Event::Pitched(pe) = &mut op.event { + pe.pitches[0].pitch = content.clone(); + } + } + env + } + + #[test] + fn transpose_skips_tombstoned_targets_and_shifts_the_live_ones() { + // Operation Catalog §Transpose (re-anchoring): "Tombstoned targets are + // skipped (the transpose applies only to live pitches)." + let p1 = PitchId::new(ReplicaId(9), 501); + let p2 = PitchId::new(ReplicaId(9), 502); + let neutral = crate::valuegen::pitch_value(); + let a = insert_with_pitch_content(1, 0, 10, 1, 100, 0, p1, &neutral); + let b = insert_with_pitch_content(1, 1, 11, 2, 101, 0, p2, &neutral); + let del = prim_env( + 1, + 2, + 20, + CausalContext::new().with_seen(ReplicaId(1), 1), + OperationKind::DeleteEvent(DeleteEventOp { + event: EventId::new(ReplicaId(1), 100), + tuplet_compensation: TupletCompensation::NotInTuplet, + }), + ); + let after_delete = CausalContext::new().with_seen(ReplicaId(1), 2); + // Mixed live/tombstoned targets: skips p1, shifts p2, applies. + let t_mixed = prim_env( + 3, + 0, + 30, + after_delete.clone(), + OperationKind::Transpose(TransposeOp { + targets: vec![p1, p2], + chromatic_steps: 2, + }), + ); + // All targets tombstoned: degenerates to no effect. + let t_dead = prim_env( + 4, + 0, + 31, + after_delete.clone(), + OperationKind::Transpose(TransposeOp { + targets: vec![p1], + chromatic_steps: 2, + }), + ); + // A target that never existed: dangling reference, whole op refuses. + let t_missing = prim_env( + 5, + 0, + 32, + after_delete, + OperationKind::Transpose(TransposeOp { + targets: vec![p2, PitchId::new(ReplicaId(9), 999)], + chromatic_steps: 2, + }), + ); + let mut set = OperationSet::new(); + set.accept_all(vec![ + a, + b, + del, + t_mixed.clone(), + t_dead.clone(), + t_missing.clone(), + ]); + let state = set.reduce(); + let effect = |id: OperationId| { + state + .effects + .iter() + .find(|(e, _)| *e == id) + .map(|(_, eff)| eff) + .expect("effect recorded") + }; + assert_eq!(effect(t_mixed.id), &OperationEffect::Applied); + assert_eq!( + effect(t_dead.id), + &OperationEffect::NoOp { + reason: NoOpReason::TargetTombstoned, + } + ); + assert_eq!( + effect(t_missing.id), + &OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::TargetMissing, + }, + } + ); + } + + #[test] + fn differing_concurrent_resolves_name_both_resolvers_in_the_meta_conflict() { + // Chapter 6 §Conflict Resolution Operations: a later differing resolve + // reduces to Conflicted with a meta-conflict record; the record names + // the earlier resolver (whose action stands) as winner and both + // resolvers as causes. + use crate::conflict::ResolutionAction; + use crate::payload::{ResolveConflictPayload, RespellPitchOp}; + + let pitch = PitchId::new(ReplicaId(9), 500); + let mut insert_env = insert(1, 0, 10, 1, 100, 0); + if let OperationPayload::Primitive(OperationKind::InsertEvent(ref mut op)) = + insert_env.payload + { + op.event = crate::valuegen::insert_event_value( + op.event_id(), + op.voice(), + op.musical_position(), + op.musical_duration(), + &[pitch], + ); + } + let respell = |replica: u64, physical: i64, byte: u8| { + prim_env( + replica, + 0, + physical, + CausalContext::new().with_seen(ReplicaId(1), 0), + OperationKind::RespellPitch(RespellPitchOp { + pitch, + spelling: crate::valuegen::spelling(byte), + }), + ) + }; + let respell_a = respell(2, 20, 0xAA); + let respell_b = respell(3, 21, 0xBB); + let mut set = OperationSet::new(); + set.accept_all(vec![ + insert_env.clone(), + respell_a.clone(), + respell_b.clone(), + ]); + let cid = set.reduce().conflicts.records()[0].id; + + let resolve = |replica: u64, physical: i64, action: ResolutionAction| { + let id = OperationId::new(ReplicaId(replica), 0); + OperationEnvelope { + id, + author: AuthorId(0), + stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(physical), 0), id), + causal_context: CausalContext::new() + .with_dot(respell_a.id) + .with_dot(respell_b.id), + transaction: None, + payload: OperationPayload::ResolveConflict(ResolveConflictPayload { + target: cid, + action, + }), + } + }; + let first = resolve(4, 30, ResolutionAction::KeepWinner); + let second = resolve(5, 31, ResolutionAction::AcceptLoser); + let mut set2 = OperationSet::new(); + set2.accept_all(vec![ + insert_env, + respell_a, + respell_b, + first.clone(), + second.clone(), + ]); + let state = set2.reduce(); + let meta = state + .conflicts + .records() + .iter() + .find(|r| r.id != cid) + .expect("a meta-conflict record exists"); + assert_eq!( + meta.kind, + ConflictKind::StructuralFieldCollision { + winner: first.id, + loser: second.id, + field: FieldPath("conflict_resolution".to_string()), + }, + "the earlier resolver's action stands, so it is the winner" + ); + assert_eq!( + meta.caused_by, + vec![first.id, second.id], + "both resolvers are named as causes" + ); + } + + #[test] + fn deleting_an_event_records_the_marker_reanchor_repair() { + // Chapter 6 §Re-Anchoring: "Re-anchoring actions MUST be recorded as + // RepairRecord entries in the triggering operation's effect", and the + // rule table's marker row: re-anchor to the *nearest event in the same + // staff instance* (four-key ordering). The fixture voice's events sit + // at ascending quarter positions, so deleting the first re-anchors the + // marker to the second (same voice: proximity rank 0 dominates any + // closer event in a sibling voice). + use epiphany_core::generators::valid_score; + let mut base = valid_score(0x5EED); + let voice_events = base + .voices() + .map(|(_, _, v)| v.events.clone()) + .next() + .expect("the fixture has a voice"); + let event_id = voice_events[0]; + let expected = voice_events[1]; + let marker_id = epiphany_core::MarkerId::new(ReplicaId(9), 700); + base.cross_cutting.markers.push(epiphany_core::Marker { + id: marker_id, + anchor: TimeAnchor::Event { + id: event_id, + offset: AnchorOffset::Zero, + }, + }); + + let del = prim_env( + 2, + 0, + 10, + CausalContext::new(), + OperationKind::DeleteEvent(DeleteEventOp { + event: event_id, + tuplet_compensation: TupletCompensation::NotInTuplet, + }), + ); + let mut set = OperationSet::new(); + set.accept_all(vec![del.clone()]); + let result = set.reduce_onto(&base); + + let effect = result + .state + .effects + .iter() + .find(|(id, _)| *id == del.id) + .map(|(_, e)| e) + .expect("delete effect recorded"); + let OperationEffect::AppliedWithRepair { repairs } = effect else { + panic!("expected AppliedWithRepair, got {effect:?}"); + }; + assert!( + repairs.iter().any(|r| { + r.target == TypedObjectId::Marker(marker_id) + && r.kind + == RepairKind::Reanchored { + from: TypedObjectId::Event(event_id), + to: TypedObjectId::Event(expected), + reason: ReanchorReason::SameVoiceNearer, + } + }), + "the marker re-anchor to the nearest same-voice event is a recorded \ + repair, not a silent mutation: {repairs:?}" + ); + let marker = result + .score + .cross_cutting + .markers + .iter() + .find(|m| m.id == marker_id) + .expect("marker survives"); + assert!( + matches!(marker.anchor, TimeAnchor::Event { id, .. } if id == expected), + "the graph agrees with the recorded repair" + ); + assert!(epiphany_core::check_invariants(&result.score).is_empty()); + } + + #[test] + fn marker_reanchor_prefers_the_forward_survivor_on_distance_ties() { + // Four-key ordering, key 3: forward (0) before backward (1). Three + // whole-note events at positions 10/12/14 in the base's first voice; + // deleting the middle one leaves survivors at equal distance 2 on both + // sides, so the *forward* neighbor (position 14) wins. + use epiphany_core::generators::valid_score; + let mut base = valid_score(0x5EED); + let (staff_instance, voice) = { + let instance = &base.canvas.regions[0].staff_instances()[0]; + (instance.id, instance.voices[0].id) + }; + let backward = EventId::new(ReplicaId(3), 0); + let referent = EventId::new(ReplicaId(3), 1); + let forward = EventId::new(ReplicaId(3), 2); + let insert_at = |counter: u64, event: EventId, position: i64| { + let ctx = if counter == 0 { + CausalContext::new() + } else { + CausalContext::new().with_seen(ReplicaId(3), counter - 1) + }; + prim_env( + 3, + counter, + 10 + counter as i64, + ctx, + OperationKind::InsertEvent(InsertEventOp { + staff_instance, + event: crate::valuegen::insert_event_value( + event, + voice, + pos(position), + epiphany_core::MusicalDuration::whole(), + &[], + ), + }), + ) + }; + let marker_id = epiphany_core::MarkerId::new(ReplicaId(9), 701); + base.cross_cutting.markers.push(epiphany_core::Marker { + id: marker_id, + anchor: TimeAnchor::Event { + id: referent, + offset: AnchorOffset::Zero, + }, + }); + let del = prim_env( + 3, + 3, + 20, + CausalContext::new().with_seen(ReplicaId(3), 2), + OperationKind::DeleteEvent(DeleteEventOp { + event: referent, + tuplet_compensation: TupletCompensation::NotInTuplet, + }), + ); + let mut set = OperationSet::new(); + set.accept_all(vec![ + insert_at(0, backward, 10), + insert_at(1, referent, 12), + insert_at(2, forward, 14), + del.clone(), + ]); + let result = set.reduce_onto(&base); + let effect = result + .state + .effects + .iter() + .find(|(id, _)| *id == del.id) + .map(|(_, e)| e) + .expect("delete effect recorded"); + let OperationEffect::AppliedWithRepair { repairs } = effect else { + panic!("expected AppliedWithRepair, got {effect:?}"); + }; + assert!( + repairs.iter().any(|r| { + r.target == TypedObjectId::Marker(marker_id) + && r.kind + == RepairKind::Reanchored { + from: TypedObjectId::Event(referent), + to: TypedObjectId::Event(forward), + reason: ReanchorReason::SameVoiceNearer, + } + }), + "equal distances tie-break forward before backward: {repairs:?}" + ); + assert!( + matches!( + result + .score + .cross_cutting + .markers + .iter() + .find(|m| m.id == marker_id) + .expect("marker survives") + .anchor, + TimeAnchor::Event { id, .. } if id == forward + ), + "the graph agrees with the recorded repair" + ); + assert!(epiphany_core::check_invariants(&result.score).is_empty()); + } + + #[test] + fn system_pitch_counter_collision_halts_reduction() { + // Chapter 5 §"System-Derived Counter Collisions": two different + // canonical input sets claiming one system-derived counter record a + // SystemIdentifierCollision, reduction does not continue past the + // collision, and neither input set occupies the collided counter. + use epiphany_core::derive_system_pitch_id; + let content_x = crate::valuegen::pitch_value_nth(1); + let content_y = crate::valuegen::pitch_value_nth(2); + let system_id = derive_system_pitch_id(&content_x); + assert_eq!(system_id.replica(), ReplicaId::SYSTEM_DERIVED); + + let before = insert(3, 0, 5, 4, 400, 0); + let legit = insert_with_pitch_content(1, 0, 10, 1, 100, 0, system_id, &content_x); + let claim = insert_with_pitch_content(2, 0, 20, 2, 200, 5, system_id, &content_y); + let after = insert(1, 1, 30, 3, 300, 9); + + let mut set = OperationSet::new(); + set.accept_all(vec![ + before.clone(), + legit.clone(), + claim.clone(), + after.clone(), + ]); + let state = set.reduce(); + + assert_eq!(state.anomalies.len(), 1, "exactly one collision recorded"); + match &state.anomalies[0].kind { + IntegrityAnomalyKind::SystemIdentifierCollision { + kind, + colliding_counter, + input_set_a, + input_set_b, + } => { + assert_eq!(*kind, ObjectKind::Pitch); + assert_eq!(*colliding_counter, system_id.counter()); + let mut sets = [input_set_a.0.clone(), input_set_b.0.clone()]; + sets.sort(); + let mut expected = [ + canonical_pitch_bytes(&content_x), + canonical_pitch_bytes(&content_y), + ]; + expected.sort(); + assert_eq!(sets, expected, "the anomaly retains both input sets"); + } + other => panic!("expected SystemIdentifierCollision, got {other:?}"), + } + // Only the operation before the collision point reduced. + assert_eq!(state.effects.len(), 1); + assert_eq!(state.effects[0].0, before.id); + // Neither input set occupies the collided counter. + assert!(!state.objects.contains_key(&TypedObjectId::Pitch(system_id))); + assert!(!state + .objects + .contains_key(&TypedObjectId::Event(EventId::new(ReplicaId(1), 100)))); + // The colliding pair and everything past the halt are held pending. + let pending: BTreeMap<_, _> = state.pending.iter().copied().collect(); + let halted = PendingReason::HaltedBySystemCollision { at: claim.id }; + assert_eq!(pending.get(&legit.id), Some(&halted)); + assert_eq!(pending.get(&claim.id), Some(&halted)); + assert_eq!(pending.get(&after.id), Some(&halted)); + // Determinism: any permutation reduces to identical bytes. + let mut reversed = OperationSet::new(); + reversed.accept_all(vec![after, claim, legit, before]); + assert_eq!(state.canonical_bytes(), reversed.reduce().canonical_bytes()); + } + + #[test] + fn reobserving_the_same_system_derivation_is_not_a_collision() { + // The same (counter, inputs) pair re-observed is idempotent, not a + // collision; the duplicate insert is refused by pitch-id freshness + // (base-free parity with the graph-aware precondition). + use epiphany_core::derive_system_pitch_id; + let content_x = crate::valuegen::pitch_value_nth(1); + let system_id = derive_system_pitch_id(&content_x); + let a = insert_with_pitch_content(1, 0, 10, 1, 100, 0, system_id, &content_x); + let b = insert_with_pitch_content(2, 0, 20, 2, 200, 5, system_id, &content_x); + let mut set = OperationSet::new(); + set.accept_all(vec![a.clone(), b.clone()]); + let state = set.reduce(); + assert!(state.anomalies.is_empty(), "same derivation: no collision"); + assert!(state.pending.is_empty(), "nothing is held"); + let effect = |id: OperationId| { + state + .effects + .iter() + .find(|(e, _)| *e == id) + .map(|(_, eff)| eff) + .expect("effect recorded") + }; + assert_eq!(effect(a.id), &OperationEffect::Applied); + assert_eq!( + effect(b.id), + &OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::TargetTombstoned, + }, + }, + "a reused pitch id is not fresh, in base-free reduction too" + ); + } + + #[test] + fn base_seeded_system_pitch_collides_with_an_op_claim() { + // The registry seeds from the base graph: an operation claiming a + // base-occupied system counter with different content collides. The + // base occupant is graph state (not an operation of this reduction), + // so it is left in place for diagnostic recovery. + use epiphany_core::generators::valid_score; + use epiphany_core::{derive_system_pitch_id, IdentifiedPitch}; + + let content_x = crate::valuegen::pitch_value_nth(1); + let content_y = crate::valuegen::pitch_value_nth(2); + let system_id = derive_system_pitch_id(&content_x); + + let mut base = valid_score(0x5EED); + let pitched_id = base + .voices() + .flat_map(|(_, _, v)| v.events.clone()) + .find(|e| matches!(base.events.get(*e), Some(Event::Pitched(_)))) + .expect("the fixture has a pitched event"); + if let Some(Event::Pitched(pe)) = base.events.get_mut(pitched_id) { + pe.pitches[0] = IdentifiedPitch { + id: system_id, + pitch: content_x.clone(), + }; + } + + let claim = insert_with_pitch_content(2, 0, 10, 2, 200, 5, system_id, &content_y); + let mut set = OperationSet::new(); + set.accept_all(vec![claim.clone()]); + let result = set.reduce_onto(&base); + + assert_eq!(result.state.anomalies.len(), 1); + assert!(matches!( + &result.state.anomalies[0].kind, + IntegrityAnomalyKind::SystemIdentifierCollision { + kind: ObjectKind::Pitch, + .. + } + )); + assert!(result.state.effects.is_empty(), "reduction halted"); + let pending: BTreeMap<_, _> = result.state.pending.iter().copied().collect(); + assert_eq!( + pending.get(&claim.id), + Some(&PendingReason::HaltedBySystemCollision { at: claim.id }) + ); + assert!( + result.score.events.get(pitched_id).is_some(), + "the base occupant stays; recovery is external" + ); + } + + // --- ResolveEquivocation (operation_catalog §"ResolveEquivocation"). ----- + + /// A `RespellPitch` envelope at `id` with an explicit causal context. + fn respell_at( + id: OperationId, + physical: i64, + spelling: u8, + ctx: CausalContext, + ) -> OperationEnvelope { + use crate::payload::RespellPitchOp; + OperationEnvelope { + id, + author: AuthorId(0), + stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(physical), 0), id), + causal_context: ctx, + transaction: None, + payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp { + pitch: epiphany_core::PitchId::new(ReplicaId(9), 500), + spelling: crate::valuegen::spelling(spelling), + })), + } + } + + /// A `ResolveEquivocation` envelope at `id` naming `(target, chosen)`. + fn resolve_equivocation_env( + id: OperationId, + physical: i64, + target: OperationId, + chosen: crate::EnvelopeHash, + ) -> OperationEnvelope { + OperationEnvelope { + id, + author: AuthorId(0), + stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(physical), 0), id), + causal_context: CausalContext::new(), + transaction: None, + payload: OperationPayload::ResolveEquivocation( + crate::payload::ResolveEquivocationPayload { target, chosen }, + ), + } + } + + fn effect_of(state: &MaterializedState, id: OperationId) -> Option<&OperationEffect> { + state + .effects + .iter() + .find(|(e, _)| *e == id) + .map(|(_, eff)| eff) + } + + #[test] + fn resolve_equivocation_promotes_the_chosen_candidate_and_unblocks_dependents() { + let pitch = epiphany_core::PitchId::new(ReplicaId(9), 500); + + // An InsertEvent carrying `pitch` makes the pitch Live. + let mut insert_env = insert(1, 0, 10, 1, 100, 0); + if let OperationPayload::Primitive(OperationKind::InsertEvent(ref mut op)) = + insert_env.payload + { + op.event = crate::valuegen::insert_event_value( + op.event_id(), + op.voice(), + op.musical_position(), + op.musical_duration(), + &[pitch], + ); + } + + // An equivocated pair of respellings under one id, both after the insert. + let eq_id = OperationId::new(ReplicaId(9), 0); + let after_insert = CausalContext::new().with_seen(ReplicaId(1), 0); + let cand_a = respell_at(eq_id, 20, 0xAA, after_insert.clone()); + let cand_b = respell_at(eq_id, 20, 0xBB, after_insert.clone()); + assert_ne!(cand_a.envelope_hash(), cand_b.envelope_hash()); + + // A dependent causally covering the equivocated id: previously held + // pending (DependsOnEquivocated); must unblock and reduce. + let dependent = respell_at( + OperationId::new(ReplicaId(2), 0), + 30, + 0xCC, + CausalContext::new() + .with_seen(ReplicaId(1), 0) + .with_seen(ReplicaId(9), 0), + ); + + // Baseline (no resolve): the slot is anomalous, the dependent pending. + let mut without = OperationSet::new(); + without.accept_all(vec![ + insert_env.clone(), + cand_a.clone(), + cand_b.clone(), + dependent.clone(), + ]); + let state = without.reduce(); + assert!(state.anomalies.iter().any(|an| matches!( + an.kind, + IntegrityAnomalyKind::OperationSlotEquivocated { operation_id } if operation_id == eq_id + ))); + let pending: BTreeMap<_, _> = state.pending.iter().copied().collect(); + assert_eq!( + pending.get(&dependent.id), + Some(&PendingReason::DependsOnEquivocated { on: eq_id }) + ); + + // With a resolve choosing candidate A: the slot reduces as if it had + // always been Single with A — A contributes at its own canonical + // position, the dependent unblocks, and no anomaly is recorded. + let resolve = resolve_equivocation_env( + OperationId::new(ReplicaId(3), 0), + 40, + eq_id, + cand_a.envelope_hash(), + ); + let all = vec![ + insert_env.clone(), + cand_a.clone(), + cand_b.clone(), + dependent.clone(), + resolve.clone(), + ]; + let mut set = OperationSet::new(); + set.accept_all(all.clone()); + let state = set.reduce(); + assert!( + state.is_clean(), + "no conflict, anomaly, or pending: {state:?}" + ); + assert_eq!(effect_of(&state, eq_id), Some(&OperationEffect::Applied)); + assert_eq!( + effect_of(&state, dependent.id), + Some(&OperationEffect::Applied) + ); + assert_eq!( + effect_of(&state, resolve.id), + Some(&OperationEffect::Applied) + ); + // The dependent respell is causally after the promoted candidate, so + // its spelling (0xCC) is the resolved value — an intentional overwrite. + assert_eq!( + state.spellings.get(&pitch), + Some(&crate::valuegen::spelling(0xCC)) + ); + // Losing candidate B remains only in the diagnostic candidate store. + assert!(set.candidate(cand_b.envelope_hash()).is_some()); + + // Order-independent: a reversed acceptance order reduces to the bytes. + let mut reversed = OperationSet::new(); + let mut rev = all; + rev.reverse(); + reversed.accept_all(rev); + assert_eq!(reversed.reduce().canonical_bytes(), state.canonical_bytes()); + } + + #[test] + fn later_resolves_reduce_idempotently_or_collide_on_equivocation_resolution() { + let eq_id = OperationId::new(ReplicaId(9), 0); + let cand_a = respell_at(eq_id, 10, 0xAA, CausalContext::new()); + let cand_b = respell_at(eq_id, 10, 0xBB, CausalContext::new()); + + // Three resolves: the earliest (in canonical order) governs; a later + // one naming the same candidate is idempotent; a later one naming a + // differing candidate collides. + let first = resolve_equivocation_env( + OperationId::new(ReplicaId(2), 0), + 20, + eq_id, + cand_a.envelope_hash(), + ); + let same = resolve_equivocation_env( + OperationId::new(ReplicaId(3), 0), + 30, + eq_id, + cand_a.envelope_hash(), + ); + let differing = resolve_equivocation_env( + OperationId::new(ReplicaId(4), 0), + 40, + eq_id, + cand_b.envelope_hash(), + ); + + let mut set = OperationSet::new(); + set.accept_all(vec![ + cand_a.clone(), + cand_b.clone(), + first.clone(), + same.clone(), + differing.clone(), + ]); + let state = set.reduce(); + + assert_eq!(effect_of(&state, first.id), Some(&OperationEffect::Applied)); + assert_eq!( + effect_of(&state, same.id), + Some(&OperationEffect::NoOp { + reason: NoOpReason::AlreadyApplied, + }) + ); + assert!(matches!( + effect_of(&state, differing.id), + Some(OperationEffect::Conflicted { .. }) + )); + assert_eq!(state.conflicts.records().len(), 1); + let record = &state.conflicts.records()[0]; + assert_eq!( + record.kind, + ConflictKind::StructuralFieldCollision { + winner: first.id, + loser: differing.id, + field: FieldPath("equivocation_resolution".to_string()), + } + ); + assert_eq!(record.caused_by, vec![first.id, differing.id]); + assert!(record.affected_objects.is_empty()); + // The slot still promoted A; no anomaly for it. + assert!(state.anomalies.is_empty()); + assert!(effect_of(&state, eq_id).is_some()); + } + + #[test] + fn resolve_without_a_matching_equivocation_is_a_precondition_noop() { + let precondition_noop = OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::TargetMissing, + }, + }; + + // (a) Target id entirely absent from the operation set. + let absent = resolve_equivocation_env( + OperationId::new(ReplicaId(2), 0), + 20, + OperationId::new(ReplicaId(9), 7), + crate::EnvelopeHash([1; 32]), + ); + let mut set = OperationSet::new(); + set.accept_all(vec![absent.clone()]); + let state = set.reduce(); + assert_eq!(effect_of(&state, absent.id), Some(&precondition_noop)); + + // (b) Target occupies an ordinary Single slot (no equivocation). + let single = respell_at( + OperationId::new(ReplicaId(9), 0), + 10, + 0xAA, + CausalContext::new(), + ); + let not_equivocated = resolve_equivocation_env( + OperationId::new(ReplicaId(2), 0), + 20, + single.id, + single.envelope_hash(), + ); + let mut set = OperationSet::new(); + set.accept_all(vec![single.clone(), not_equivocated.clone()]); + let state = set.reduce(); + assert_eq!( + effect_of(&state, not_equivocated.id), + Some(&precondition_noop) + ); + + // (c) Target is equivocated but `chosen` names no candidate: the slot + // stays equivocated (anomaly recorded, dependents still pending) and + // the resolve is a precondition no-op — behavior is otherwise exactly + // the unresolved baseline. + let eq_id = OperationId::new(ReplicaId(9), 0); + let cand_a = respell_at(eq_id, 10, 0xAA, CausalContext::new()); + let cand_b = respell_at(eq_id, 10, 0xBB, CausalContext::new()); + let dependent = respell_at( + OperationId::new(ReplicaId(4), 0), + 30, + 0xCC, + CausalContext::new().with_seen(ReplicaId(9), 0), + ); + let bogus = resolve_equivocation_env( + OperationId::new(ReplicaId(2), 0), + 20, + eq_id, + crate::EnvelopeHash([0xEE; 32]), + ); + let mut set = OperationSet::new(); + set.accept_all(vec![ + cand_a.clone(), + cand_b.clone(), + dependent.clone(), + bogus.clone(), + ]); + let state = set.reduce(); + assert_eq!(effect_of(&state, bogus.id), Some(&precondition_noop)); + assert!(effect_of(&state, eq_id).is_none()); + assert!(state.anomalies.iter().any(|an| matches!( + an.kind, + IntegrityAnomalyKind::OperationSlotEquivocated { operation_id } if operation_id == eq_id + ))); + let pending: BTreeMap<_, _> = state.pending.iter().copied().collect(); + assert_eq!( + pending.get(&dependent.id), + Some(&PendingReason::DependsOnEquivocated { on: eq_id }) + ); + } + + #[test] + fn an_equivocated_resolve_is_excluded_like_any_equivocated_slot() { + let eq_id = OperationId::new(ReplicaId(9), 0); + let cand_a = respell_at(eq_id, 10, 0xAA, CausalContext::new()); + let cand_b = respell_at(eq_id, 10, 0xBB, CausalContext::new()); + + // Two distinct resolve envelopes under one id (differing `chosen`): + // the resolve slot itself equivocates, so it never governs. + let resolve_id = OperationId::new(ReplicaId(5), 0); + let resolve_a = resolve_equivocation_env(resolve_id, 20, eq_id, cand_a.envelope_hash()); + let resolve_b = resolve_equivocation_env(resolve_id, 20, eq_id, cand_b.envelope_hash()); + assert_ne!(resolve_a.envelope_hash(), resolve_b.envelope_hash()); + + let mut set = OperationSet::new(); + set.accept_all(vec![cand_a, cand_b, resolve_a, resolve_b]); + let state = set.reduce(); + + // Neither slot contributes; both record equivocation anomalies. + assert!(state.effects.is_empty()); + for id in [eq_id, resolve_id] { + assert!( + state.anomalies.iter().any(|an| matches!( + an.kind, + IntegrityAnomalyKind::OperationSlotEquivocated { operation_id } if operation_id == id + )), + "expected an equivocation anomaly for {id:?}" + ); + } + } } diff --git a/crates/epiphany-ops/src/v0.rs b/crates/epiphany-ops/src/v0.rs index 5028840..645eb4c 100644 --- a/crates/epiphany-ops/src/v0.rs +++ b/crates/epiphany-ops/src/v0.rs @@ -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 1–4 kinds below. + ResolveEquivocation(ResolveEquivocationPayload), } /// Frozen v0 primitive kinds (the representative §6.10 set). diff --git a/crates/epiphany-ops/src/validate.rs b/crates/epiphany-ops/src/validate.rs new file mode 100644 index 0000000..a34bc22 --- /dev/null +++ b/crates/epiphany-ops/src/validate.rs @@ -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 { + 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, +) { + 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 { + match ®ion.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 { + 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()); + } +} diff --git a/crates/epiphany-ops/tests/graph_reduction.rs b/crates/epiphany-ops/tests/graph_reduction.rs index df7f5b2..9843db7 100644 --- a/crates/epiphany-ops/tests/graph_reduction.rs +++ b/crates/epiphany-ops/tests/graph_reduction.rs @@ -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 { + 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 { + 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, 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 = 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 = instance_events + .iter() + .copied() + .filter(|e| *e != marked) + .collect(); + order.push(marked); + let ops: Vec = 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" + ); + } +} diff --git a/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.engrave.snapshot.txt b/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.engrave.snapshot.txt index 6d1fdab..c082312 100644 --- a/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.engrave.snapshot.txt +++ b/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.engrave.snapshot.txt @@ -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: diff --git a/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.stub.snapshot.txt b/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.stub.snapshot.txt index 363ec67..91ab969 100644 --- a/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.stub.snapshot.txt +++ b/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.stub.snapshot.txt @@ -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: diff --git a/crates/epiphany-render-svg/tests/golden/valid_score_rich.engrave.snapshot.txt b/crates/epiphany-render-svg/tests/golden/valid_score_rich.engrave.snapshot.txt index caa35f9..ba9c1ea 100644 --- a/crates/epiphany-render-svg/tests/golden/valid_score_rich.engrave.snapshot.txt +++ b/crates/epiphany-render-svg/tests/golden/valid_score_rich.engrave.snapshot.txt @@ -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: diff --git a/crates/epiphany-render-svg/tests/golden/valid_score_rich.stub.snapshot.txt b/crates/epiphany-render-svg/tests/golden/valid_score_rich.stub.snapshot.txt index 6597dcb..de82ec7 100644 --- a/crates/epiphany-render-svg/tests/golden/valid_score_rich.stub.snapshot.txt +++ b/crates/epiphany-render-svg/tests/golden/valid_score_rich.stub.snapshot.txt @@ -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: diff --git a/crates/epiphany-testkit/src/bundle_harness.rs b/crates/epiphany-testkit/src/bundle_harness.rs index bc3655c..d869818 100644 --- a/crates/epiphany-testkit/src/bundle_harness.rs +++ b/crates/epiphany-testkit/src/bundle_harness.rs @@ -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 { + envelopes + .chunks(per_block) + .map(|group| { + let payloads: Vec> = 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(bundle: &Bundle) -> OperationIndex { + let blocks: Vec = 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(bundle: &mut Bundle, 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( + bundle: &Bundle, + 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); + } } diff --git a/crates/epiphany-testkit/src/layout_stub.rs b/crates/epiphany-testkit/src/layout_stub.rs index dfc8365..45bf6f6 100644 --- a/crates/epiphany-testkit/src/layout_stub.rs +++ b/crates/epiphany-testkit/src/layout_stub.rs @@ -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 = (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 = (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); diff --git a/crates/epiphany-testkit/src/prepass_harness.rs b/crates/epiphany-testkit/src/prepass_harness.rs index f02d980..2e7e2a4 100644 --- a/crates/epiphany-testkit/src/prepass_harness.rs +++ b/crates/epiphany-testkit/src/prepass_harness.rs @@ -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" ); } diff --git a/spec/PASS12_BATCH.md b/spec/PASS12_BATCH.md index 0ce1d4f..2f2e2e8 100644 --- a/spec/PASS12_BATCH.md +++ b/spec/PASS12_BATCH.md @@ -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. diff --git a/spec/PASS12_RATIFICATION_LOG.md b/spec/PASS12_RATIFICATION_LOG.md index 41a5fbf..42accb6 100644 --- a/spec/PASS12_RATIFICATION_LOG.md +++ b/spec/PASS12_RATIFICATION_LOG.md @@ -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 diff --git a/spec/core_spec.pdf b/spec/core_spec.pdf index 0aab333..16e8635 100644 Binary files a/spec/core_spec.pdf and b/spec/core_spec.pdf differ diff --git a/spec/core_spec.tex b/spec/core_spec.tex index 235a96e..93adc50 100644 --- a/spec/core_spec.tex +++ b/spec/core_spec.tex @@ -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} diff --git a/spec/operation_catalog.pdf b/spec/operation_catalog.pdf index 3c58fea..430e3a6 100644 Binary files a/spec/operation_catalog.pdf and b/spec/operation_catalog.pdf differ diff --git a/spec/operation_catalog.tex b/spec/operation_catalog.tex index 286b252..8f82c55 100644 --- a/spec/operation_catalog.tex +++ b/spec/operation_catalog.tex @@ -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}