Pushes 1+3: fix the MUST-level violations, wire the types-only machinery
Two audit pushes whose code edits interleave line-by-line in the same
files (reduce.rs, bundle.rs, the DECISIONS logs), committed together so
the tree at every commit builds. Gate: 784 workspace tests pass, clippy
-D warnings clean, fmt clean.
Push 1 — the true MUST violations, all fixed:
- bundle: zstd read support on both read paths, output bounded by the
declared uncompressed_length, typed decompression errors, explicit
CompressedManifest rejection (zstd 0.13 workspace dep; write path
stays uncompressed per the Phase-3 deferral).
- ops: system-derived counter collision check — mint registry seeded
from the base graph, canonical-order pre-walk, halt via the new
PendingReason::HaltedBySystemCollision (discriminant 4, additive)
with transaction-atomicity and causal-dependent closure; neither
input set occupies a collided counter. canonical_pitch_bytes made
pub in core for the MUSCSPCH preimage.
- ops: Transpose skips tombstoned targets per the catalog; missing
targets still refuse the whole operation.
- ops: marker re-anchoring recorded as a RepairRecord in the
triggering operation's effect; ResolveConflict meta-conflicts name
both resolvers; base-free pitch-id freshness; reserved effect
vocabulary annotated.
- core: decomposition pre-pass honors authored attachments
(resolve_decomposition, spec-default precedence); inversion
tolerance typed as a TempoIntegration-class Tolerance.
- CONFORMANCE.md: the determinism conformance statement required by
Appendix D — all seven declarations.
Push 3 — wiring the types-only machinery:
- layout-ir/engrave: to_constrained emits real constraints (successive
notehead no-collision chains, per-glyph region containment, soft
user-break constraints); ConstraintStrength{Required, Preferred}
with strength-by-rule; Preferred violations surface as warnings, not
failures; StubSolver reworked honest-but-renderable. SVG goldens
byte-identical; snapshot constraint counts regenerated (0->90/15).
- layout-ir: to_logical projects user system/page breaks as anchored
EngravingOverrides with paired UserOverride-sourced decisions
(OverrideKind::SystemBreak/PageBreak carry TimeAnchor, ratified in
the spec alongside).
- layout-ir/ops/editor-core: edit-barrier bridge — decode mirrors for
the whole barrier tree (reject-never-normalize, NFC revalidation,
MAX_CONDITION_DEPTH = 64), golden-locked blob codec for the
ExtensionDeclaration fields, a barrier gate in apply and
apply_transaction backed by a Score oracle and real containment
contexts, and apply_unsafe recording the crossed extensions in
extensions_requiring_tombstone() for the next bundle write.
- ops: ResolveEquivocation meta-operation per the newly ratified
catalog entry — payload discriminant 3 (appended), set-level
earliest-resolve-governs promotion, ResolveConflict-mirrored
meta-conflicts, permutation-invariance fuzz; the missing golden
locks on the OperationKind/OperationPayload wire tables added.
- ops/editor-core: validation modes — ValidationMode + a non-canonical
advisory layer (validate.rs), an authoring gate before minting, and
reduction pinned as replay mode by construction (canonical bytes
untouched).
- bundle: the operation index (opindex.rs) — provisional golden-locked
payload, binary-search locate, staleness defined as full-ChunkRef
set equality against operation_roots, and the reject-and-rebuild
discipline (a defective index is never bundle corruption).
- ops: re-anchoring rule table completed — the four-key "nearest"
ordering computed from base-free ledger indices; markers re-anchor
to the nearest live event in the same staff instance (replacing the
Push-1 region-start stand-in); cue-source cascade; graphic-gesture
Events/Range/Free rows; comment and analytical-annotation orphaning.
Zero appended discriminants.
Spec enablers ratified with Push 3: catalog §ResolveEquivocation
(0.3.0 -> 0.4.0) and anchored break overrides; 16 new Pass-12 rows
filed (C1-C4, K5-K7, I4-I6, D1, E1-E5). The data-model payload
expansion (SlurKind, beam geometry, voltas, instrument bodies,
metadata) is deliberately staged to the Binary Format companion — the
positional graph codec has no value-level versioning, so filling those
structs is a schema-major break that should land once, with J.
Also carries the pre-existing editor-track increment: the atomic
tuplet overwrite (CascadeDeleteTuplets prunes decomposition
attachments naming the cascaded tuplet).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
This commit is contained in:
parent
80e3fed99c
commit
92aaccf7e2
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -46,11 +46,53 @@ pub fn encode_block(envelopes: &[Vec<u8>]) -> Vec<u8> {
|
|||
|
||||
/// Splits a block payload back into its opaque envelope byte strings. Total and
|
||||
/// bounds-checked: a corrupt payload yields a [`DecodeError`], never a panic.
|
||||
/// Implemented over [`envelope_offsets`], so the two apply *identical*
|
||||
/// validation.
|
||||
pub fn decode_block(payload: &[u8]) -> Result<Vec<Vec<u8>>, DecodeError> {
|
||||
Ok(envelope_offsets(payload)?
|
||||
.into_iter()
|
||||
.map(|(_, bytes)| bytes.to_vec())
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Splits a block payload into each envelope's `(offset, bytes)` pair, under
|
||||
/// exactly the validation [`decode_block`] applies (they share one code path).
|
||||
///
|
||||
/// The offset is the byte offset of the envelope's **first content byte**
|
||||
/// within the uncompressed (decoded) block payload — that is,
|
||||
/// `payload[offset .. offset + bytes.len()]` *is* the envelope's encoded
|
||||
/// bytes, and the envelope's `u32` length prefix sits at `offset - 4`. This is
|
||||
/// the coordinate the operation index records (Chapter 8 §"The Operation
|
||||
/// Index": the `ChunkRef` of the enclosing block plus an offset within it),
|
||||
/// deterministically recoverable from the block framing alone.
|
||||
pub fn envelope_offsets(payload: &[u8]) -> Result<Vec<(u32, &[u8])>, DecodeError> {
|
||||
// Offsets are recorded as `u32`; a payload past that range is far beyond
|
||||
// every profile's block bound and unrepresentable in the index.
|
||||
if payload.len() > u32::MAX as usize {
|
||||
return Err(DecodeError::Malformed(
|
||||
"block payload exceeds the u32 offset range",
|
||||
));
|
||||
}
|
||||
let mut r = Reader::new(payload);
|
||||
let envelopes = r.get_seq(|r| r.get_var_bytes())?;
|
||||
// Mirror `Reader::get_seq`'s pre-allocation guards: the declared count is
|
||||
// checked against the bytes remaining (each envelope costs at least its
|
||||
// length prefix), and the reservation is capped.
|
||||
const MAX_RESERVE: usize = 1024;
|
||||
let count = r.get_u32()? as usize;
|
||||
if count > r.remaining() {
|
||||
return Err(DecodeError::LengthOverflow {
|
||||
declared: count as u64,
|
||||
remaining: r.remaining(),
|
||||
});
|
||||
}
|
||||
let mut out = Vec::with_capacity(count.min(MAX_RESERVE));
|
||||
for _ in 0..count {
|
||||
let prefix_at = payload.len() - r.remaining();
|
||||
let bytes = r.get_var_slice()?;
|
||||
out.push((prefix_at as u32 + ENVELOPE_FRAMING as u32, bytes));
|
||||
}
|
||||
r.finish()?;
|
||||
Ok(envelopes)
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Packs opaque envelope byte strings into block payloads at the 1 MiB soft
|
||||
|
|
@ -109,6 +151,48 @@ mod tests {
|
|||
assert_eq!(decode_block(&payload).unwrap(), envs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_offsets_address_each_first_content_byte() {
|
||||
let envs = vec![b"aa".to_vec(), b"b".to_vec(), b"cccc".to_vec()];
|
||||
let payload = encode_block(&envs);
|
||||
let got = envelope_offsets(&payload).unwrap();
|
||||
assert_eq!(got.len(), 3);
|
||||
// The first envelope's content begins after the u32 count and its own
|
||||
// u32 length prefix: offset 8. Each subsequent offset advances by the
|
||||
// previous content plus the next 4-byte prefix.
|
||||
assert_eq!(got[0].0, 8);
|
||||
assert_eq!(got[1].0, 8 + 2 + 4);
|
||||
assert_eq!(got[2].0, 8 + 2 + 4 + 1 + 4);
|
||||
for ((off, bytes), env) in got.iter().zip(&envs) {
|
||||
assert_eq!(bytes, env, "the returned slice is the envelope");
|
||||
// The offset definition: payload[offset .. offset+len] IS the
|
||||
// envelope's encoded bytes within the decoded block payload.
|
||||
let start = *off as usize;
|
||||
assert_eq!(&payload[start..start + env.len()], &env[..]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_offsets_validate_like_decode_block() {
|
||||
let payload = encode_block(&[b"xyz".to_vec()]);
|
||||
// Truncation fails both, with the same verdict.
|
||||
let cut = &payload[..payload.len() - 1];
|
||||
assert!(envelope_offsets(cut).is_err());
|
||||
assert!(decode_block(cut).is_err());
|
||||
// Trailing garbage fails both.
|
||||
let mut long = payload.clone();
|
||||
long.push(0);
|
||||
assert!(envelope_offsets(&long).is_err());
|
||||
assert!(decode_block(&long).is_err());
|
||||
// A corrupt count cannot over-allocate in either.
|
||||
let mut bytes = u32::MAX.to_le_bytes().to_vec();
|
||||
bytes.push(0);
|
||||
assert!(matches!(
|
||||
envelope_offsets(&bytes),
|
||||
Err(DecodeError::LengthOverflow { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_envelopes_share_one_block_and_preserve_order() {
|
||||
let envs: Vec<Vec<u8>> = (0..100).map(|i| vec![i as u8; 8]).collect();
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ use crate::error::{BundleError, IntegrityAnomaly};
|
|||
use crate::header::{FixedHeader, SLOT_A_OFFSET, SLOT_B_OFFSET};
|
||||
use crate::ids::{BlobId, FileUuid, ReductionAlgorithmVersion, SchemaVersion, WallClockTime};
|
||||
use crate::manifest::{BlobRef, Manifest, ProfileDeclaration};
|
||||
use crate::opindex::OperationIndex;
|
||||
use crate::store::{read_vec, BlockStore, MemStore};
|
||||
use crate::superblock::{
|
||||
select_active, CommitState, ProfileId, Slot, SlotParse, SlotReject, Superblock, SUPERBLOCK_LEN,
|
||||
|
|
@ -78,6 +79,19 @@ impl StagedChunk {
|
|||
payload,
|
||||
}
|
||||
}
|
||||
|
||||
/// A staged operation-index chunk at the current schema version (Chapter 8
|
||||
/// §"The Operation Index") — a non-canonical accelerator; the payload is an
|
||||
/// [`OperationIndex::encode`](crate::OperationIndex::encode). v0 writes it
|
||||
/// uncompressed, like every chunk (the spec's *MAY* compress indexes is a
|
||||
/// write-path option this version defers).
|
||||
pub fn operation_index(payload: Vec<u8>) -> Self {
|
||||
StagedChunk {
|
||||
kind: ChunkKind::OperationIndex,
|
||||
schema_version: SchemaVersion::V0,
|
||||
payload,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Context handed to a commit's manifest builder: the previous manifest, the
|
||||
|
|
@ -409,6 +423,47 @@ impl<S: BlockStore> Bundle<S> {
|
|||
block::decode_block(&payload).map_err(BundleError::Decode)
|
||||
}
|
||||
|
||||
/// Reads and decodes an operation-index chunk (Chapter 8 §"The Operation
|
||||
/// Index"). Rejects a reference of the wrong kind; the read itself is
|
||||
/// bounded by the reader's [`MAX_CHUNK_BYTES`] policy and hash-verified
|
||||
/// like any chunk read.
|
||||
///
|
||||
/// **The index is not canonical**: a failure here — corrupt bytes, a
|
||||
/// malformed payload, even an I/O error on the index region — must NOT be
|
||||
/// treated as bundle corruption. The spec's discipline is *reject and
|
||||
/// rebuild from blocks*; [`Bundle::usable_operation_index`] packages it
|
||||
/// (including the staleness check). Call this directly only when the
|
||||
/// underlying failure itself is wanted, e.g. for diagnostics.
|
||||
pub fn read_operation_index(&self, r: &ChunkRef) -> Result<OperationIndex, BundleError> {
|
||||
if r.kind != ChunkKind::OperationIndex {
|
||||
return Err(BundleError::Decode(DecodeError::Malformed(
|
||||
"chunk reference is not an operation index",
|
||||
)));
|
||||
}
|
||||
let payload = self.read_chunk(r)?;
|
||||
OperationIndex::decode(&payload).map_err(BundleError::Decode)
|
||||
}
|
||||
|
||||
/// The manifest's operation index, if — and only if — it is *usable*:
|
||||
/// declared, readable, hash-intact, well-formed, and covering exactly the
|
||||
/// manifest's current `operation_roots` ([`OperationIndex::covers`]).
|
||||
/// `None` on **any** defect: absent, stale (its block set differs from the
|
||||
/// operation roots), corrupt, malformed, or unreadable.
|
||||
///
|
||||
/// `None` always means "rebuild by scanning all blocks", never "the bundle
|
||||
/// is corrupt": the operation index is an acceleration structure, not
|
||||
/// canonical (Chapter 8 §"The Operation Index"), and failed verification
|
||||
/// of a non-canonical chunk MUST NOT be surfaced as bundle corruption
|
||||
/// (Chapter 8 §"Canonical and Non-Canonical Manifest Roots") — the reader
|
||||
/// discards the index and rebuilds it from the blocks.
|
||||
pub fn usable_operation_index(&self) -> Option<OperationIndex> {
|
||||
let root = self.manifest.operation_index_root.as_ref()?;
|
||||
let index = self.read_operation_index(root).ok()?;
|
||||
index
|
||||
.covers(&self.manifest.operation_roots)
|
||||
.then_some(index)
|
||||
}
|
||||
|
||||
/// The active profile's maximum uncompressed operation-block size
|
||||
/// (Chapter 8 §"Operation Envelope Blocks"). The *active* profile is the one
|
||||
/// the selected superblock names — a bundle opened under `Lite` must read
|
||||
|
|
@ -753,10 +808,17 @@ fn reduction_version_for(manifest: &Manifest) -> ReductionAlgorithmVersion {
|
|||
|
||||
/// Reads and fully verifies a chunk against its reference — the shared core of
|
||||
/// [`Bundle::read_chunk`] and the commit-time canonical-root validation. Checks
|
||||
/// body-placement, schema-major support, declared length, the content hash, and
|
||||
/// the `id == hash` redundancy (Chapter 8 §"Chunks").
|
||||
/// compression support (decompressing zstd payloads), body-placement,
|
||||
/// schema-major support, declared length, the content hash, and the
|
||||
/// `id == hash` redundancy (Chapter 8 §"Chunks").
|
||||
fn read_and_verify_chunk(store: &dyn BlockStore, r: &ChunkRef) -> Result<Vec<u8>, BundleError> {
|
||||
if r.compression != CompressionAlgorithm::None {
|
||||
// The manifest chunk is mandatorily uncompressed in this format version
|
||||
// (Chapter 8 §"Manifest Encoding"): a compressed manifest reference is
|
||||
// rejected outright, before any bytes are read.
|
||||
if r.kind == ChunkKind::Manifest && r.compression != CompressionAlgorithm::None {
|
||||
return Err(BundleError::CompressedManifest);
|
||||
}
|
||||
if let CompressionAlgorithm::Reserved(_) = r.compression {
|
||||
return Err(BundleError::UnsupportedCompression);
|
||||
}
|
||||
// A chunk reference must point into the body, never the fixed prelude.
|
||||
|
|
@ -773,16 +835,11 @@ fn read_and_verify_chunk(store: &dyn BlockStore, r: &ChunkRef) -> Result<Vec<u8>
|
|||
version: r.schema_version,
|
||||
});
|
||||
}
|
||||
// Bound the allocation by the reader's policy before touching the length.
|
||||
// Bound both allocations by the reader's policy before touching a length.
|
||||
enforce_limit(r.compressed_length, MAX_CHUNK_BYTES)?;
|
||||
enforce_limit(r.uncompressed_length, MAX_CHUNK_BYTES)?;
|
||||
let payload = read_chunk_bytes(store, r.offset, r.compressed_length)?;
|
||||
if payload.len() as u64 != r.uncompressed_length {
|
||||
return Err(BundleError::ChunkLengthMismatch {
|
||||
expected: r.uncompressed_length,
|
||||
actual: payload.len() as u64,
|
||||
});
|
||||
}
|
||||
let stored = read_chunk_bytes(store, r.offset, r.compressed_length)?;
|
||||
let payload = decode_stored_payload(stored, r.compression, r.uncompressed_length)?;
|
||||
let actual = content_hash_for(r.kind, r.schema_version, &payload);
|
||||
if actual != r.hash {
|
||||
return Err(BundleError::ChunkHashMismatch {
|
||||
|
|
@ -809,13 +866,16 @@ fn read_and_verify_blob(
|
|||
b: &BlobRef,
|
||||
max_bytes: u64,
|
||||
) -> Result<Vec<u8>, BundleError> {
|
||||
if b.compression != CompressionAlgorithm::None {
|
||||
if let CompressionAlgorithm::Reserved(_) = b.compression {
|
||||
return Err(BundleError::UnsupportedCompression);
|
||||
}
|
||||
let limit = b
|
||||
.declared_max_uncompressed_length
|
||||
.unwrap_or(u64::MAX)
|
||||
.min(max_bytes);
|
||||
// Chapter 8 §"Blobs": the declared uncompressed length is checked against
|
||||
// the reader's policy *before decompression begins* (and before any
|
||||
// allocation keyed on it).
|
||||
enforce_limit(b.uncompressed_length, limit)?;
|
||||
enforce_limit(b.compressed_length, max_bytes)?;
|
||||
if b.offset < BODY_START {
|
||||
|
|
@ -825,13 +885,8 @@ fn read_and_verify_blob(
|
|||
file_len: store.len(),
|
||||
});
|
||||
}
|
||||
let payload = read_chunk_bytes(store, b.offset, b.compressed_length)?;
|
||||
if payload.len() as u64 != b.uncompressed_length {
|
||||
return Err(BundleError::ChunkLengthMismatch {
|
||||
expected: b.uncompressed_length,
|
||||
actual: payload.len() as u64,
|
||||
});
|
||||
}
|
||||
let stored = read_chunk_bytes(store, b.offset, b.compressed_length)?;
|
||||
let payload = decode_stored_payload(stored, b.compression, b.uncompressed_length)?;
|
||||
let actual = BlobId::of_payload(&payload).0;
|
||||
if actual != b.hash || b.blob_id.0 != b.hash {
|
||||
return Err(BundleError::ChunkHashMismatch {
|
||||
|
|
@ -851,6 +906,70 @@ fn enforce_limit(length: u64, limit: u64) -> Result<(), BundleError> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Recovers a chunk's uncompressed payload from its stored (possibly
|
||||
/// compressed) bytes, verifying it is *exactly* `declared_len` bytes long
|
||||
/// (Chapter 8 §"Compression" / §"Chunks": a decompressed size that disagrees
|
||||
/// with the declared `uncompressed_length` is corruption). The caller has
|
||||
/// already validated `declared_len` against its resource-limit policy, so
|
||||
/// every allocation here is bounded. Content hashing happens strictly *after*
|
||||
/// this step, over the uncompressed bytes — compression is `ChunkRef`
|
||||
/// metadata, never part of content identity.
|
||||
fn decode_stored_payload(
|
||||
stored: Vec<u8>,
|
||||
compression: CompressionAlgorithm,
|
||||
declared_len: u64,
|
||||
) -> Result<Vec<u8>, BundleError> {
|
||||
match compression {
|
||||
CompressionAlgorithm::None => {
|
||||
if stored.len() as u64 != declared_len {
|
||||
return Err(BundleError::ChunkLengthMismatch {
|
||||
expected: declared_len,
|
||||
actual: stored.len() as u64,
|
||||
});
|
||||
}
|
||||
Ok(stored)
|
||||
}
|
||||
// Reading zstd at any level is a conformance MUST (Chapter 8
|
||||
// §"Compression"); the declared level byte is advisory metadata the
|
||||
// decoder does not need.
|
||||
CompressionAlgorithm::Zstd { .. } => decompress_zstd(&stored, declared_len),
|
||||
CompressionAlgorithm::Reserved(_) => Err(BundleError::UnsupportedCompression),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decompresses a zstd frame sequence into a buffer sized *exactly* by the
|
||||
/// declared uncompressed length, so a hostile stream can never allocate past
|
||||
/// the (already limit-checked) declaration:
|
||||
///
|
||||
/// * a stream that would exceed `declared_len` hits libzstd's
|
||||
/// destination-full error → [`BundleError::Decompression`];
|
||||
/// * a stream that ends short of `declared_len` →
|
||||
/// [`BundleError::ChunkLengthMismatch`];
|
||||
/// * a truncated or otherwise malformed stream (including trailing garbage
|
||||
/// after the final frame) → [`BundleError::Decompression`].
|
||||
///
|
||||
/// No path panics or allocates beyond `declared_len` plus libzstd's own
|
||||
/// bounded decoding context (whose window is capped internally).
|
||||
fn decompress_zstd(stored: &[u8], declared_len: u64) -> Result<Vec<u8>, BundleError> {
|
||||
let declared = usize::try_from(declared_len).map_err(|_| {
|
||||
// Unreachable on 64-bit targets; on narrower ones an unaddressable
|
||||
// declaration is a resource-limit refusal, not a wrap.
|
||||
BundleError::ResourceLimitExceeded {
|
||||
length: declared_len,
|
||||
limit: usize::MAX as u64,
|
||||
}
|
||||
})?;
|
||||
let mut payload = vec![0u8; declared];
|
||||
match zstd::bulk::decompress_to_buffer(stored, payload.as_mut_slice()) {
|
||||
Ok(n) if n as u64 == declared_len => Ok(payload),
|
||||
Ok(n) => Err(BundleError::ChunkLengthMismatch {
|
||||
expected: declared_len,
|
||||
actual: n as u64,
|
||||
}),
|
||||
Err(e) => Err(BundleError::Decompression(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates that every canonical root a manifest declares resolves to a
|
||||
/// present, hash-intact chunk of the *right kind and shape* before a commit
|
||||
/// publishes it — so a bundle that opens normally can never carry a dangling,
|
||||
|
|
@ -911,6 +1030,14 @@ fn validate_canonical_roots(
|
|||
/// the body — Chapter 8 fixes the prelude layout, so a "manifest" overlapping a
|
||||
/// header or superblock slot is foreign — and within the reader's manifest size
|
||||
/// limit, before allocating.
|
||||
///
|
||||
/// The manifest chunk is mandatorily *uncompressed* in this format version
|
||||
/// (Chapter 8 §"Manifest Encoding"): the superblock deliberately carries no
|
||||
/// compression field, so the stored bytes ARE the payload. A bundle whose
|
||||
/// manifest bytes are compressed anyway therefore fails downstream as
|
||||
/// malformed: hash verification (over the uncompressed preimage) rejects the
|
||||
/// slot, and even a colluding hash-over-compressed-bytes cannot survive
|
||||
/// `Manifest::decode`.
|
||||
fn read_manifest_payload(store: &dyn BlockStore, sb: &Superblock) -> Result<Vec<u8>, BundleError> {
|
||||
if sb.manifest_offset < BODY_START {
|
||||
return Err(BundleError::ChunkOutOfBounds {
|
||||
|
|
@ -1857,4 +1984,323 @@ mod tests {
|
|||
Err(BundleError::GenerationExhausted)
|
||||
));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Zstd read support (Chapter 8 §"Compression": reading zstd-compressed
|
||||
// chunks is a conformance MUST; this crate's writer still emits only
|
||||
// uncompressed chunks, so tests plant externally-compressed bytes).
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// Appends externally-zstd-compressed bytes to the bundle body (as a
|
||||
/// foreign compressing writer would have) and returns the reference
|
||||
/// describing them. `declared_len` lets a test lie about the uncompressed
|
||||
/// length; honest callers pass `payload.len()`.
|
||||
fn plant_zstd_chunk(
|
||||
bundle: &mut Bundle<MemStore>,
|
||||
kind: ChunkKind,
|
||||
payload: &[u8],
|
||||
declared_len: u64,
|
||||
) -> ChunkRef {
|
||||
let compressed = zstd::bulk::compress(payload, 3).unwrap();
|
||||
let offset = bundle.write_cursor;
|
||||
bundle.store.write_at(offset, &compressed).unwrap();
|
||||
bundle.write_cursor += compressed.len() as u64;
|
||||
let hash = content_hash_for(kind, SchemaVersion::V0, payload);
|
||||
ChunkRef {
|
||||
id: ChunkId(hash),
|
||||
kind,
|
||||
schema_version: SchemaVersion::V0,
|
||||
offset,
|
||||
compressed_length: compressed.len() as u64,
|
||||
uncompressed_length: declared_len,
|
||||
compression: CompressionAlgorithm::Zstd { level: 3 },
|
||||
hash,
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [`plant_zstd_chunk`], but for a blob (bare `MUSCBLOB` addressing).
|
||||
fn plant_zstd_blob(
|
||||
bundle: &mut Bundle<MemStore>,
|
||||
payload: &[u8],
|
||||
declared_len: u64,
|
||||
) -> BlobRef {
|
||||
let r = plant_zstd_chunk(bundle, ChunkKind::Blob, payload, declared_len);
|
||||
BlobRef {
|
||||
blob_id: BlobId(r.hash),
|
||||
media_type: "application/octet-stream".to_string(),
|
||||
offset: r.offset,
|
||||
compressed_length: r.compressed_length,
|
||||
uncompressed_length: declared_len,
|
||||
compression: r.compression,
|
||||
hash: r.hash,
|
||||
declared_max_uncompressed_length: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zstd_compressed_chunk_round_trips_with_hash_verified() {
|
||||
// §Compression round-trip: an externally-compressed chunk reads back
|
||||
// byte-identical, and the content hash is verified over the
|
||||
// *uncompressed* bytes (the preimage rule: compression is metadata).
|
||||
let mut bundle = fresh_bundle();
|
||||
let payload: Vec<u8> = b"layout-cache-bytes ".repeat(64); // compressible
|
||||
let r = plant_zstd_chunk(
|
||||
&mut bundle,
|
||||
ChunkKind::LayoutCache,
|
||||
&payload,
|
||||
payload.len() as u64,
|
||||
);
|
||||
assert!(
|
||||
r.compressed_length < r.uncompressed_length,
|
||||
"fixture actually compressed"
|
||||
);
|
||||
assert_eq!(bundle.read_chunk(&r).unwrap(), payload);
|
||||
|
||||
// The hash check runs on the decompressed payload: a tampered declared
|
||||
// hash is caught even though the stored (compressed) bytes are intact.
|
||||
let mut tampered = r;
|
||||
tampered.hash = ContentHash([0; 32]);
|
||||
tampered.id = ChunkId(ContentHash([0; 32])); // keep id == hash
|
||||
assert!(matches!(
|
||||
bundle.read_chunk(&tampered),
|
||||
Err(BundleError::ChunkHashMismatch { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compressed_operation_root_commits_and_reopens() {
|
||||
// End to end: a compressed operation block can be published as a
|
||||
// canonical root (commit-time validation decompresses + verifies it),
|
||||
// survives a reopen, and streams back through read_operation_block.
|
||||
let mut bundle = fresh_bundle();
|
||||
let envelopes = vec![b"env-1".to_vec(), b"env-2".to_vec()];
|
||||
let payload = block::encode_block(&envelopes);
|
||||
let root = plant_zstd_chunk(
|
||||
&mut bundle,
|
||||
ChunkKind::OperationEnvelopeBlock,
|
||||
&payload,
|
||||
payload.len() as u64,
|
||||
);
|
||||
bundle
|
||||
.commit(&[], |ctx| {
|
||||
let mut m = ctx.previous_manifest.clone();
|
||||
m.operation_roots.push(root);
|
||||
m
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let image = bundle.into_store().into_bytes();
|
||||
let reopened = Bundle::open(MemStore::from_bytes(image)).unwrap();
|
||||
reopened.verify_canonical_chunks().unwrap();
|
||||
let stored_root = reopened.manifest().operation_roots[0];
|
||||
assert_eq!(
|
||||
stored_root.compression,
|
||||
CompressionAlgorithm::Zstd { level: 3 }
|
||||
);
|
||||
assert_eq!(
|
||||
reopened.read_operation_block(&stored_root).unwrap(),
|
||||
envelopes
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zstd_stream_ending_short_of_declared_length_is_rejected() {
|
||||
// §Compression: "Decompression MUST verify the output length against
|
||||
// the declared uncompressed_length" — a stream that ends short of the
|
||||
// declaration is corruption, reported with both lengths.
|
||||
let mut bundle = fresh_bundle();
|
||||
let payload = b"short-stream".to_vec();
|
||||
let r = plant_zstd_chunk(
|
||||
&mut bundle,
|
||||
ChunkKind::LayoutCache,
|
||||
&payload,
|
||||
payload.len() as u64 + 5, // declares more than the stream yields
|
||||
);
|
||||
assert!(matches!(
|
||||
bundle.read_chunk(&r),
|
||||
Err(BundleError::ChunkLengthMismatch {
|
||||
expected: 17,
|
||||
actual: 12,
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zstd_stream_exceeding_declared_length_is_rejected() {
|
||||
// The dual failure: a stream that would decompress *past* the declared
|
||||
// length must be refused without allocating beyond the declaration
|
||||
// (the output buffer is sized by the declared length, so libzstd hits
|
||||
// destination-full and errors).
|
||||
let mut bundle = fresh_bundle();
|
||||
let payload: Vec<u8> = b"overlong ".repeat(32);
|
||||
let r = plant_zstd_chunk(
|
||||
&mut bundle,
|
||||
ChunkKind::LayoutCache,
|
||||
&payload,
|
||||
payload.len() as u64 - 1, // declares less than the stream yields
|
||||
);
|
||||
assert!(matches!(
|
||||
bundle.read_chunk(&r),
|
||||
Err(BundleError::Decompression(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_or_truncated_zstd_stream_is_a_typed_error() {
|
||||
let mut bundle = fresh_bundle();
|
||||
let payload: Vec<u8> = b"to-be-corrupted ".repeat(16);
|
||||
let r = plant_zstd_chunk(
|
||||
&mut bundle,
|
||||
ChunkKind::LayoutCache,
|
||||
&payload,
|
||||
payload.len() as u64,
|
||||
);
|
||||
|
||||
// Corrupt the frame header magic in the stored bytes: malformed stream.
|
||||
bundle.store.write_at(r.offset, &[0xFF]).unwrap();
|
||||
assert!(matches!(
|
||||
bundle.read_chunk(&r),
|
||||
Err(BundleError::Decompression(_))
|
||||
));
|
||||
|
||||
// Truncated stream: same compressed bytes, but the reference claims
|
||||
// fewer of them than the frame needs.
|
||||
let mut bundle = fresh_bundle();
|
||||
let mut truncated = plant_zstd_chunk(
|
||||
&mut bundle,
|
||||
ChunkKind::LayoutCache,
|
||||
&payload,
|
||||
payload.len() as u64,
|
||||
);
|
||||
truncated.compressed_length /= 2;
|
||||
assert!(matches!(
|
||||
bundle.read_chunk(&truncated),
|
||||
Err(BundleError::Decompression(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zstd_compressed_blob_round_trips_and_fails_typed() {
|
||||
// The blob path shares the decode: round-trip, short-stream, corrupt.
|
||||
let mut bundle = fresh_bundle();
|
||||
let payload: Vec<u8> = b"blob-audio-bytes ".repeat(64);
|
||||
let b = plant_zstd_blob(&mut bundle, &payload, payload.len() as u64);
|
||||
assert_eq!(bundle.read_blob(&b).unwrap(), payload);
|
||||
|
||||
// Declared length beyond the stream's yield → typed error.
|
||||
let mut short = b.clone();
|
||||
short.uncompressed_length = payload.len() as u64 + 3;
|
||||
assert!(matches!(
|
||||
bundle.read_blob(&short),
|
||||
Err(BundleError::ChunkLengthMismatch { .. })
|
||||
));
|
||||
|
||||
// The declared_max cap still applies *before* decompression begins.
|
||||
let mut capped = b.clone();
|
||||
capped.declared_max_uncompressed_length = Some(4);
|
||||
assert!(matches!(
|
||||
bundle.read_blob(&capped),
|
||||
Err(BundleError::ResourceLimitExceeded { .. })
|
||||
));
|
||||
|
||||
// Corrupt stored stream → typed error, no panic.
|
||||
bundle.store.write_at(b.offset, &[0xFF]).unwrap();
|
||||
assert!(matches!(
|
||||
bundle.read_blob(&b),
|
||||
Err(BundleError::Decompression(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_compression_is_still_unsupported() {
|
||||
// §Compression: Reserved algorithms belong to future format majors.
|
||||
let mut bundle = fresh_bundle();
|
||||
op_block(&mut bundle, &[b"env"]);
|
||||
let mut r = bundle.manifest().operation_roots[0];
|
||||
r.compression = CompressionAlgorithm::Reserved(7);
|
||||
assert!(matches!(
|
||||
bundle.read_chunk(&r),
|
||||
Err(BundleError::UnsupportedCompression)
|
||||
));
|
||||
|
||||
commit_blob(&mut bundle, b"blob");
|
||||
let mut b = bundle.manifest().blob_roots[0].clone();
|
||||
b.compression = CompressionAlgorithm::Reserved(7);
|
||||
assert!(matches!(
|
||||
bundle.read_blob(&b),
|
||||
Err(BundleError::UnsupportedCompression)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compressed_manifest_chunk_ref_is_rejected() {
|
||||
// §Manifest Encoding: the manifest chunk MUST be stored uncompressed in
|
||||
// this format version. A manifest reference declaring compression is
|
||||
// refused outright — even when the compressed bytes are a perfectly
|
||||
// valid zstd stream of a perfectly valid manifest.
|
||||
let mut bundle = fresh_bundle();
|
||||
let manifest_payload = Manifest::empty(DocumentId([7; 16])).encode();
|
||||
let r = plant_zstd_chunk(
|
||||
&mut bundle,
|
||||
ChunkKind::Manifest,
|
||||
&manifest_payload,
|
||||
manifest_payload.len() as u64,
|
||||
);
|
||||
assert!(matches!(
|
||||
bundle.read_chunk(&r),
|
||||
Err(BundleError::CompressedManifest)
|
||||
));
|
||||
}
|
||||
|
||||
/// A minimal image whose superblock points at `stored` as the manifest
|
||||
/// payload, declaring `manifest_hash` for it.
|
||||
fn craft_image_with_manifest_bytes(stored: &[u8], manifest_hash: ContentHash) -> Vec<u8> {
|
||||
let mut image = vec![0u8; BODY_START as usize];
|
||||
image.extend_from_slice(stored);
|
||||
let sb = Superblock {
|
||||
generation: 0,
|
||||
manifest_offset: BODY_START,
|
||||
manifest_length: stored.len() as u64,
|
||||
manifest_hash,
|
||||
manifest_schema_version: SchemaVersion::V0,
|
||||
reduction_algorithm_version: ReductionAlgorithmVersion(0),
|
||||
profile_id: ProfileId::Full,
|
||||
commit_state: CommitState::Committed,
|
||||
commit_timestamp: WallClockTime(0),
|
||||
};
|
||||
image[0..crate::header::HEADER_LEN as usize]
|
||||
.copy_from_slice(&FixedHeader::new(FileUuid([1; 16])).encode());
|
||||
image[SLOT_A_OFFSET as usize..SLOT_A_OFFSET as usize + SUPERBLOCK_LEN as usize]
|
||||
.copy_from_slice(&sb.encode());
|
||||
image
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_rejects_a_bundle_whose_manifest_bytes_are_compressed() {
|
||||
// §Manifest Encoding: "Implementations MUST reject as malformed any
|
||||
// bundle whose manifest payload is not directly parseable as a
|
||||
// canonical manifest chunk's uncompressed bytes." The superblock
|
||||
// carries no compression field, so the stored bytes are treated as the
|
||||
// payload — a compressed manifest fails with a typed error either way
|
||||
// a hostile writer declares its hash.
|
||||
let payload = Manifest::empty(DocumentId([1; 16])).encode();
|
||||
let compressed = zstd::bulk::compress(&payload, 3).unwrap();
|
||||
|
||||
// (a) Hash declared over the true (uncompressed) manifest content:
|
||||
// the stored bytes fail hash verification → no valid superblock.
|
||||
let image = craft_image_with_manifest_bytes(&compressed, manifest_chunk_hash(&payload));
|
||||
assert!(matches!(
|
||||
Bundle::open(MemStore::from_bytes(image)),
|
||||
Err(BundleError::NoValidSuperblock)
|
||||
));
|
||||
|
||||
// (b) Colluding hash over the compressed bytes: the slot verifies, but
|
||||
// the payload is not parseable as a manifest → rejected as
|
||||
// malformed.
|
||||
let image = craft_image_with_manifest_bytes(&compressed, manifest_chunk_hash(&compressed));
|
||||
assert!(matches!(
|
||||
Bundle::open(MemStore::from_bytes(image)),
|
||||
Err(BundleError::Decode(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -309,9 +309,13 @@ impl<'a> Reader<'a> {
|
|||
Ok(i64::from_le_bytes(self.take_array()?))
|
||||
}
|
||||
|
||||
/// A `u32`-length-prefixed byte string, copied out. The length prefix is
|
||||
/// checked against the bytes remaining *before* any allocation.
|
||||
pub fn get_var_bytes(&mut self) -> Result<Vec<u8>, DecodeError> {
|
||||
/// A `u32`-length-prefixed byte string, *borrowed* from the input rather
|
||||
/// than copied. The length prefix is checked against the bytes remaining
|
||||
/// before the slice is taken. The zero-copy counterpart of
|
||||
/// [`Reader::get_var_bytes`], for callers that need positions or slices
|
||||
/// into the original buffer (e.g. the per-envelope offsets the operation
|
||||
/// index records).
|
||||
pub fn get_var_slice(&mut self) -> Result<&'a [u8], DecodeError> {
|
||||
let len = self.get_u32()? as usize;
|
||||
if len > self.remaining() {
|
||||
return Err(DecodeError::LengthOverflow {
|
||||
|
|
@ -319,7 +323,13 @@ impl<'a> Reader<'a> {
|
|||
remaining: self.remaining(),
|
||||
});
|
||||
}
|
||||
Ok(self.take(len)?.to_vec())
|
||||
self.take(len)
|
||||
}
|
||||
|
||||
/// A `u32`-length-prefixed byte string, copied out. The length prefix is
|
||||
/// checked against the bytes remaining *before* any allocation.
|
||||
pub fn get_var_bytes(&mut self) -> Result<Vec<u8>, DecodeError> {
|
||||
Ok(self.get_var_slice()?.to_vec())
|
||||
}
|
||||
|
||||
/// A `u32`-length-prefixed UTF-8 string.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,504 @@
|
|||
//! The operation index (Chapter 8 §"The Operation Index").
|
||||
//!
|
||||
//! An optional chunk of kind [`ChunkKind::OperationIndex`] mapping each
|
||||
//! operation id to the [`ChunkRef`] of its enclosing operation-envelope block
|
||||
//! plus a byte offset within the block, for O(log n) lookup of an operation
|
||||
//! without scanning every block. It is **an acceleration structure, not
|
||||
//! canonical**: *"If absent, readers rebuild it by scanning all blocks. If
|
||||
//! present but corrupt or stale, readers MUST reject the index and rebuild
|
||||
//! from blocks"* — and failed verification of a non-canonical chunk is *not*
|
||||
//! bundle corruption (Chapter 8 §"Canonical and Non-Canonical Manifest
|
||||
//! Roots"). [`crate::Bundle::usable_operation_index`] packages that
|
||||
//! reject-and-rebuild discipline.
|
||||
//!
|
||||
//! ## Layering
|
||||
//!
|
||||
//! The bundle stays semantics-free: entries key on the **raw 16 canonical
|
||||
//! bytes** of an operation id, which the bundle never interprets. That the
|
||||
//! leading 16 bytes of a canonically encoded envelope *are* its operation id
|
||||
//! is an `epiphany-ops` invariant, vouched for by ops' `peek_operation_id`;
|
||||
//! index builders pair that helper with [`crate::envelope_offsets`] to produce
|
||||
//! this module's `(id bytes, offset)` inputs.
|
||||
//!
|
||||
//! ## Provisional payload layout (see `DECISIONS.md`)
|
||||
//!
|
||||
//! The spec defers the index's byte format to the Binary Format companion; the
|
||||
//! encoding here is a deterministic, golden-locked prototype under the crate's
|
||||
//! codec conventions:
|
||||
//!
|
||||
//! ```text
|
||||
//! u32 block_count
|
||||
//! block_count × ChunkRef — strictly ascending canonical order
|
||||
//! u32 entry_count
|
||||
//! entry_count × { id: [u8;16], block: u32, offset: u32 }
|
||||
//! — strictly ascending by id bytes
|
||||
//! ```
|
||||
//!
|
||||
//! `block` is an ordinal into the block vector; `offset` is the byte offset of
|
||||
//! the envelope's first content byte within the block's *decoded*
|
||||
//! (uncompressed) payload, exactly as [`crate::envelope_offsets`] reports it.
|
||||
//! The decoder **rejects** non-canonical bytes (unsorted or duplicated blocks
|
||||
//! or ids, out-of-range ordinals, wrong-kind block references, trailing
|
||||
//! bytes) rather than normalizing — the manifest decoder's discipline.
|
||||
|
||||
use crate::chunk::{ChunkKind, ChunkRef};
|
||||
use crate::codec::{DecodeError, Reader, Writer};
|
||||
|
||||
/// The raw 16 canonical bytes of an operation id. Opaque to the bundle (the
|
||||
/// semantics-free layering split): ops' `peek_operation_id` is what vouches
|
||||
/// that an envelope's leading 16 bytes are its id.
|
||||
pub type OperationIdBytes = [u8; 16];
|
||||
|
||||
/// One [`OperationIndex::build`] input: an operation-envelope block's
|
||||
/// [`ChunkRef`] plus, for each envelope it contains, the envelope's id bytes
|
||||
/// and its offset within the decoded block payload (the
|
||||
/// [`crate::envelope_offsets`] coordinate).
|
||||
pub type IndexedBlock = (ChunkRef, Vec<(OperationIdBytes, u32)>);
|
||||
|
||||
/// One index entry: an operation id (raw canonical bytes — the bundle never
|
||||
/// interprets them), the ordinal of its enclosing block in the index's block
|
||||
/// vector, and the byte offset of the envelope's first content byte within
|
||||
/// that block's decoded payload.
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||
pub struct OperationIndexEntry {
|
||||
/// The operation's 16 canonical id bytes (opaque to the bundle).
|
||||
pub id: [u8; 16],
|
||||
/// Ordinal into [`OperationIndex::blocks`] of the enclosing block.
|
||||
pub block: u32,
|
||||
/// Byte offset of the envelope's first content byte within the block's
|
||||
/// decoded payload (`crate::envelope_offsets` coordinates).
|
||||
pub offset: u32,
|
||||
}
|
||||
|
||||
/// Why [`OperationIndex::build`] refused its inputs. Building is the writer's
|
||||
/// act, so these are writer bugs — unlike a decode failure, which is just an
|
||||
/// unusable (discard-and-rebuild) index.
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||
pub enum OperationIndexBuildError {
|
||||
/// An operation id was supplied under two blocks (or twice in one block).
|
||||
/// An `OperationId` names exactly one slot, so it lives in exactly one
|
||||
/// enclosing block; two coordinates for one id is a builder bug.
|
||||
DuplicateOperationId([u8; 16]),
|
||||
/// The same block reference was supplied twice: the block set is a set.
|
||||
DuplicateBlock,
|
||||
/// A supplied block reference is not an operation-envelope block.
|
||||
NotAnOperationBlock,
|
||||
}
|
||||
|
||||
impl core::fmt::Display for OperationIndexBuildError {
|
||||
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||
match self {
|
||||
OperationIndexBuildError::DuplicateOperationId(id) => {
|
||||
write!(f, "operation id {id:02x?} indexed more than once")
|
||||
}
|
||||
OperationIndexBuildError::DuplicateBlock => {
|
||||
f.write_str("the same block reference was supplied twice")
|
||||
}
|
||||
OperationIndexBuildError::NotAnOperationBlock => {
|
||||
f.write_str("an index block reference is not an operation-envelope block")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for OperationIndexBuildError {}
|
||||
|
||||
/// The decoded operation index. Construct via [`OperationIndex::build`] (the
|
||||
/// writer path) or [`OperationIndex::decode`] (the reader path); both uphold
|
||||
/// the canonical-order invariants [`OperationIndex::locate`]'s binary search
|
||||
/// relies on.
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Default)]
|
||||
pub struct OperationIndex {
|
||||
/// The indexed operation-envelope blocks, in canonical [`ChunkRef`] order.
|
||||
blocks: Vec<ChunkRef>,
|
||||
/// The entries, strictly ascending by id bytes.
|
||||
entries: Vec<OperationIndexEntry>,
|
||||
}
|
||||
|
||||
impl OperationIndex {
|
||||
/// Builds an index from per-block entry lists: for each operation-envelope
|
||||
/// block's [`ChunkRef`], the `(id bytes, offset)` of every envelope it
|
||||
/// contains (as `peek_operation_id` over [`crate::envelope_offsets`]
|
||||
/// yields them). Usable at commit time from the [`ChunkRef`]s a commit's
|
||||
/// builder closure receives. Blocks are put into canonical order and the
|
||||
/// entry ordinals remapped accordingly; duplicate ids, duplicate blocks,
|
||||
/// and wrong-kind block references are rejected.
|
||||
pub fn build(blocks: &[IndexedBlock]) -> Result<OperationIndex, OperationIndexBuildError> {
|
||||
assert!(
|
||||
blocks.len() <= u32::MAX as usize,
|
||||
"block count {} overflows the u32 ordinal space",
|
||||
blocks.len()
|
||||
);
|
||||
if blocks
|
||||
.iter()
|
||||
.any(|(r, _)| r.kind != ChunkKind::OperationEnvelopeBlock)
|
||||
{
|
||||
return Err(OperationIndexBuildError::NotAnOperationBlock);
|
||||
}
|
||||
|
||||
// Canonical block order (ChunkRef's total Ord), remembering where each
|
||||
// input block landed so entry ordinals can be remapped.
|
||||
let mut order: Vec<usize> = (0..blocks.len()).collect();
|
||||
order.sort_by(|&a, &b| blocks[a].0.cmp(&blocks[b].0));
|
||||
if order.windows(2).any(|w| blocks[w[0]].0 == blocks[w[1]].0) {
|
||||
return Err(OperationIndexBuildError::DuplicateBlock);
|
||||
}
|
||||
let mut ordinal_of = vec![0u32; blocks.len()];
|
||||
for (ordinal, &input) in order.iter().enumerate() {
|
||||
ordinal_of[input] = ordinal as u32;
|
||||
}
|
||||
let sorted_blocks: Vec<ChunkRef> = order.iter().map(|&i| blocks[i].0).collect();
|
||||
|
||||
let mut entries: Vec<OperationIndexEntry> = Vec::new();
|
||||
for (i, (_, ids)) in blocks.iter().enumerate() {
|
||||
entries.extend(ids.iter().map(|&(id, offset)| OperationIndexEntry {
|
||||
id,
|
||||
block: ordinal_of[i],
|
||||
offset,
|
||||
}));
|
||||
}
|
||||
entries.sort_by_key(|e| e.id);
|
||||
if let Some(w) = entries.windows(2).find(|w| w[0].id == w[1].id) {
|
||||
return Err(OperationIndexBuildError::DuplicateOperationId(w[0].id));
|
||||
}
|
||||
|
||||
Ok(OperationIndex {
|
||||
blocks: sorted_blocks,
|
||||
entries,
|
||||
})
|
||||
}
|
||||
|
||||
/// Encodes the index to its (provisional, golden-locked) canonical chunk
|
||||
/// payload. Deterministic: [`OperationIndex::build`]/[`OperationIndex::decode`]
|
||||
/// established the canonical orders, so re-encoding is byte-stable.
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
let mut w = Writer::new();
|
||||
w.put_seq(&self.blocks, |w, b| b.encode(w));
|
||||
w.put_seq(&self.entries, |w, e| {
|
||||
w.put_bytes(&e.id);
|
||||
w.put_u32(e.block);
|
||||
w.put_u32(e.offset);
|
||||
});
|
||||
w.into_bytes()
|
||||
}
|
||||
|
||||
/// Decodes an index payload, **rejecting** (never normalizing) any
|
||||
/// non-canonical form: unsorted or duplicated blocks or entry ids, a
|
||||
/// non-operation-block reference, an out-of-range block ordinal, or
|
||||
/// trailing bytes. A rejection means the index is unusable and must be
|
||||
/// rebuilt from blocks — it is *not* bundle corruption (the index is
|
||||
/// non-canonical).
|
||||
pub fn decode(bytes: &[u8]) -> Result<OperationIndex, DecodeError> {
|
||||
let mut r = Reader::new(bytes);
|
||||
let blocks = r.get_seq(ChunkRef::decode)?;
|
||||
let entries = r.get_seq(|r| {
|
||||
Ok(OperationIndexEntry {
|
||||
id: r.take_array::<16>()?,
|
||||
block: r.get_u32()?,
|
||||
offset: r.get_u32()?,
|
||||
})
|
||||
})?;
|
||||
r.finish()?;
|
||||
|
||||
if blocks
|
||||
.iter()
|
||||
.any(|b| b.kind != ChunkKind::OperationEnvelopeBlock)
|
||||
{
|
||||
return Err(DecodeError::Malformed(
|
||||
"operation index references a non-operation-block chunk",
|
||||
));
|
||||
}
|
||||
// Strictly ascending (so also duplicate-free) in both vectors.
|
||||
if blocks.windows(2).any(|w| w[0] >= w[1]) {
|
||||
return Err(DecodeError::Malformed(
|
||||
"operation index blocks are not strictly canonically ordered",
|
||||
));
|
||||
}
|
||||
if entries.windows(2).any(|w| w[0].id >= w[1].id) {
|
||||
return Err(DecodeError::Malformed(
|
||||
"operation index entries are not strictly ascending by id",
|
||||
));
|
||||
}
|
||||
if entries.iter().any(|e| e.block as usize >= blocks.len()) {
|
||||
return Err(DecodeError::Malformed(
|
||||
"operation index entry names an out-of-range block ordinal",
|
||||
));
|
||||
}
|
||||
Ok(OperationIndex { blocks, entries })
|
||||
}
|
||||
|
||||
/// Locates an operation by its 16 canonical id bytes: the [`ChunkRef`] of
|
||||
/// its enclosing block and the byte offset of the envelope's first content
|
||||
/// byte within that block's decoded payload. Binary search — the O(log n)
|
||||
/// lookup the spec names as the index's purpose.
|
||||
pub fn locate(&self, id: &[u8; 16]) -> Option<(&ChunkRef, u32)> {
|
||||
let i = self.entries.binary_search_by(|e| e.id.cmp(id)).ok()?;
|
||||
let e = &self.entries[i];
|
||||
Some((&self.blocks[e.block as usize], e.offset))
|
||||
}
|
||||
|
||||
/// Whether this index covers exactly the given operation roots — the
|
||||
/// staleness gate (Chapter 8 §"The Operation Index": a stale index MUST be
|
||||
/// rejected and rebuilt). True iff the index's block set equals the root
|
||||
/// set as **full [`ChunkRef`]s** (not just chunk ids): `locate` hands out
|
||||
/// the index's *stored* references for reading, so a reference agreeing in
|
||||
/// content hash but differing in any locator field (offset, lengths,
|
||||
/// compression) is not the manifest's block and must count as stale rather
|
||||
/// than silently steering reads elsewhere. `false` = stale: reject and
|
||||
/// rebuild from blocks; it is *not* bundle corruption.
|
||||
pub fn covers(&self, operation_roots: &[ChunkRef]) -> bool {
|
||||
// The index's own blocks are strictly sorted; canonicalize the roots
|
||||
// the same way (a decoded manifest's roots already are — this only
|
||||
// shields against hand-assembled inputs).
|
||||
let mut roots = operation_roots.to_vec();
|
||||
roots.sort();
|
||||
roots.dedup();
|
||||
roots == self.blocks
|
||||
}
|
||||
|
||||
/// The indexed blocks, in canonical order.
|
||||
pub fn blocks(&self) -> &[ChunkRef] {
|
||||
&self.blocks
|
||||
}
|
||||
|
||||
/// The entries, strictly ascending by id bytes.
|
||||
pub fn entries(&self) -> &[OperationIndexEntry] {
|
||||
&self.entries
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::chunk::CompressionAlgorithm;
|
||||
use crate::ids::SchemaVersion;
|
||||
use epiphany_determinism::{ChunkId, ContentHash};
|
||||
|
||||
fn block_ref(hash_byte: u8, offset: u64, len: u64) -> ChunkRef {
|
||||
ChunkRef {
|
||||
id: ChunkId(ContentHash([hash_byte; 32])),
|
||||
kind: ChunkKind::OperationEnvelopeBlock,
|
||||
schema_version: SchemaVersion::V0,
|
||||
offset,
|
||||
compressed_length: len,
|
||||
uncompressed_length: len,
|
||||
compression: CompressionAlgorithm::None,
|
||||
hash: ContentHash([hash_byte; 32]),
|
||||
}
|
||||
}
|
||||
|
||||
fn sample() -> OperationIndex {
|
||||
OperationIndex::build(&[
|
||||
(block_ref(0x22, 1000, 64), vec![([3; 16], 8), ([1; 16], 40)]),
|
||||
(block_ref(0x11, 576, 32), vec![([2; 16], 8)]),
|
||||
])
|
||||
.expect("valid build inputs")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_encoding_is_golden() {
|
||||
// PROVISIONAL golden lock (DECISIONS.md "Operation index"): the byte
|
||||
// form awaits Binary Format companion ratification, but until then it
|
||||
// is deterministic and locked — a layout change must break here
|
||||
// deliberately. The expected bytes are spelled out literally.
|
||||
let index =
|
||||
OperationIndex::build(&[(block_ref(0x11, 576, 64), vec![([2; 16], 8), ([1; 16], 44)])])
|
||||
.expect("valid build inputs");
|
||||
|
||||
let mut expected: Vec<u8> = Vec::new();
|
||||
// u32 block count = 1.
|
||||
expected.extend_from_slice(&[1, 0, 0, 0]);
|
||||
// ChunkRef: id (32 raw bytes) …
|
||||
expected.extend_from_slice(&[0x11; 32]);
|
||||
// … kind discriminant (OperationEnvelopeBlock = 0) …
|
||||
expected.push(0);
|
||||
// … schema version 0.1 (u16 major LE, u16 minor LE) …
|
||||
expected.extend_from_slice(&[0, 0, 1, 0]);
|
||||
// … offset 576 (u64 LE), compressed and uncompressed length 64 …
|
||||
expected.extend_from_slice(&[0x40, 0x02, 0, 0, 0, 0, 0, 0]);
|
||||
expected.extend_from_slice(&[64, 0, 0, 0, 0, 0, 0, 0]);
|
||||
expected.extend_from_slice(&[64, 0, 0, 0, 0, 0, 0, 0]);
|
||||
// … compression None (discriminant 0, parameter 0) …
|
||||
expected.extend_from_slice(&[0, 0]);
|
||||
// … restated hash (32 raw bytes).
|
||||
expected.extend_from_slice(&[0x11; 32]);
|
||||
// u32 entry count = 2; entries ascend by id bytes.
|
||||
expected.extend_from_slice(&[2, 0, 0, 0]);
|
||||
// Entry { id [1;16], block ordinal 0 (u32 LE), offset 44 (u32 LE) }.
|
||||
expected.extend_from_slice(&[1; 16]);
|
||||
expected.extend_from_slice(&[0, 0, 0, 0]);
|
||||
expected.extend_from_slice(&[44, 0, 0, 0]);
|
||||
// Entry { id [2;16], block ordinal 0, offset 8 }.
|
||||
expected.extend_from_slice(&[2; 16]);
|
||||
expected.extend_from_slice(&[0, 0, 0, 0]);
|
||||
expected.extend_from_slice(&[8, 0, 0, 0]);
|
||||
|
||||
assert_eq!(index.encode(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_index_round_trips() {
|
||||
let empty = OperationIndex::build(&[]).expect("empty build");
|
||||
let bytes = empty.encode();
|
||||
// Two zero u32 counts.
|
||||
assert_eq!(bytes, vec![0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
let decoded = OperationIndex::decode(&bytes).expect("decodes");
|
||||
assert_eq!(decoded, empty);
|
||||
assert!(decoded.covers(&[]));
|
||||
assert_eq!(decoded.locate(&[0; 16]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_and_reencodes_byte_stably() {
|
||||
let index = sample();
|
||||
let bytes = index.encode();
|
||||
let decoded = OperationIndex::decode(&bytes).expect("decodes");
|
||||
assert_eq!(decoded, index);
|
||||
assert_eq!(decoded.encode(), bytes, "re-encode must be byte-identical");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_sorts_blocks_canonically_and_remaps_ordinals() {
|
||||
// Inputs arrive with the higher-hash block first; build must order by
|
||||
// the canonical ChunkRef key and keep each entry pointing at its own
|
||||
// block through the remapped ordinal.
|
||||
let index = sample();
|
||||
assert_eq!(index.blocks()[0].hash.as_bytes()[0], 0x11);
|
||||
assert_eq!(index.blocks()[1].hash.as_bytes()[0], 0x22);
|
||||
let (b, off) = index.locate(&[2; 16]).expect("hit");
|
||||
assert_eq!((b.hash.as_bytes()[0], off), (0x11, 8));
|
||||
let (b, off) = index.locate(&[1; 16]).expect("hit");
|
||||
assert_eq!((b.hash.as_bytes()[0], off), (0x22, 40));
|
||||
let (b, off) = index.locate(&[3; 16]).expect("hit");
|
||||
assert_eq!((b.hash.as_bytes()[0], off), (0x22, 8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locate_misses_an_unknown_id() {
|
||||
assert_eq!(sample().locate(&[9; 16]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_rejects_duplicate_ids_blocks_and_wrong_kinds() {
|
||||
// The same id under two blocks: one operation, one slot.
|
||||
assert_eq!(
|
||||
OperationIndex::build(&[
|
||||
(block_ref(0x11, 576, 32), vec![([1; 16], 8)]),
|
||||
(block_ref(0x22, 1000, 32), vec![([1; 16], 8)]),
|
||||
]),
|
||||
Err(OperationIndexBuildError::DuplicateOperationId([1; 16]))
|
||||
);
|
||||
// The same block twice: the block set is a set.
|
||||
assert_eq!(
|
||||
OperationIndex::build(&[
|
||||
(block_ref(0x11, 576, 32), vec![([1; 16], 8)]),
|
||||
(block_ref(0x11, 576, 32), vec![([2; 16], 8)]),
|
||||
]),
|
||||
Err(OperationIndexBuildError::DuplicateBlock)
|
||||
);
|
||||
// A non-operation-block reference.
|
||||
let mut wrong = block_ref(0x11, 576, 32);
|
||||
wrong.kind = ChunkKind::Snapshot;
|
||||
assert_eq!(
|
||||
OperationIndex::build(&[(wrong, vec![])]),
|
||||
Err(OperationIndexBuildError::NotAnOperationBlock)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_rejects_unsorted_blocks() {
|
||||
// Hand-assemble an index whose blocks are out of canonical order;
|
||||
// encode trusts the construction, so decode must be the gate.
|
||||
let bad = OperationIndex {
|
||||
blocks: vec![block_ref(0x22, 1000, 32), block_ref(0x11, 576, 32)],
|
||||
entries: vec![],
|
||||
};
|
||||
assert_eq!(
|
||||
OperationIndex::decode(&bad.encode()),
|
||||
Err(DecodeError::Malformed(
|
||||
"operation index blocks are not strictly canonically ordered"
|
||||
))
|
||||
);
|
||||
// Duplicates violate *strict* ascent too.
|
||||
let dup = OperationIndex {
|
||||
blocks: vec![block_ref(0x11, 576, 32), block_ref(0x11, 576, 32)],
|
||||
entries: vec![],
|
||||
};
|
||||
assert!(OperationIndex::decode(&dup.encode()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_rejects_unsorted_or_duplicate_entries() {
|
||||
let entry = |id: u8, block: u32| OperationIndexEntry {
|
||||
id: [id; 16],
|
||||
block,
|
||||
offset: 8,
|
||||
};
|
||||
let unsorted = OperationIndex {
|
||||
blocks: vec![block_ref(0x11, 576, 32)],
|
||||
entries: vec![entry(2, 0), entry(1, 0)],
|
||||
};
|
||||
assert!(OperationIndex::decode(&unsorted.encode()).is_err());
|
||||
let duplicated = OperationIndex {
|
||||
blocks: vec![block_ref(0x11, 576, 32)],
|
||||
entries: vec![entry(1, 0), entry(1, 0)],
|
||||
};
|
||||
assert!(OperationIndex::decode(&duplicated.encode()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_rejects_out_of_range_ordinals_wrong_kinds_and_trailing_bytes() {
|
||||
let out_of_range = OperationIndex {
|
||||
blocks: vec![block_ref(0x11, 576, 32)],
|
||||
entries: vec![OperationIndexEntry {
|
||||
id: [1; 16],
|
||||
block: 1, // only ordinal 0 exists
|
||||
offset: 8,
|
||||
}],
|
||||
};
|
||||
assert_eq!(
|
||||
OperationIndex::decode(&out_of_range.encode()),
|
||||
Err(DecodeError::Malformed(
|
||||
"operation index entry names an out-of-range block ordinal"
|
||||
))
|
||||
);
|
||||
|
||||
let mut wrong = block_ref(0x11, 576, 32);
|
||||
wrong.kind = ChunkKind::Snapshot;
|
||||
let wrong_kind = OperationIndex {
|
||||
blocks: vec![wrong],
|
||||
entries: vec![],
|
||||
};
|
||||
assert_eq!(
|
||||
OperationIndex::decode(&wrong_kind.encode()),
|
||||
Err(DecodeError::Malformed(
|
||||
"operation index references a non-operation-block chunk"
|
||||
))
|
||||
);
|
||||
|
||||
let mut trailing = sample().encode();
|
||||
trailing.push(0);
|
||||
assert_eq!(
|
||||
OperationIndex::decode(&trailing),
|
||||
Err(DecodeError::TrailingBytes { remaining: 1 })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn covers_is_exact_set_equality_over_full_refs() {
|
||||
let index = sample();
|
||||
let a = block_ref(0x11, 576, 32);
|
||||
let b = block_ref(0x22, 1000, 64);
|
||||
// Equal set (any input order; duplicates collapse).
|
||||
assert!(index.covers(&[b, a]));
|
||||
assert!(index.covers(&[a, b, a]));
|
||||
// Subset / superset are stale.
|
||||
assert!(!index.covers(&[a]));
|
||||
assert!(!index.covers(&[a, b, block_ref(0x33, 2000, 8)]));
|
||||
// Same content hash at a different locator is stale: the index would
|
||||
// hand out a reference that is not the manifest's block.
|
||||
let mut moved = b;
|
||||
moved.offset = 4096;
|
||||
assert!(!index.covers(&[a, moved]));
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -446,7 +446,13 @@ impl Pitch {
|
|||
/// system pitch identifier. Strings are length-prefixed and already NFC (the
|
||||
/// catalog ids normalize on construction); the layout is fixed-shape so equal
|
||||
/// pitches encode to equal bytes (Appendix D §"Canonical serialization").
|
||||
fn canonical_pitch_bytes(p: &Pitch) -> Vec<u8> {
|
||||
///
|
||||
/// Public because these bytes are the normative "canonical inputs" of the
|
||||
/// `MUSCSPCH` derivation (`req:graph:system-derived-pitch-id`): the reduction's
|
||||
/// system-derived counter-collision check (Chapter 5 §"System-Derived Counter
|
||||
/// Collisions") compares exactly these input bytes to distinguish two pitches
|
||||
/// contending for one derived counter.
|
||||
pub fn canonical_pitch_bytes(p: &Pitch) -> Vec<u8> {
|
||||
// Length-prefixed UTF-8, normalized to NFC at the derivation boundary so the
|
||||
// canonical input is NFC regardless of how the string was obtained (Appendix
|
||||
// D §"Text and Unicode"). Catalog ids are already NFC at construction, so this
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1019,6 +1019,227 @@ fn decomposition_components_sum_to_event_duration() {
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Decomposition precedence: authored attachments outrank the derived default
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A two-event metric score — two half notes on beats 1 and 3 — plus the two
|
||||
/// event ids. Each half note sits on a boundary of its own strength, so the
|
||||
/// pre-pass infers a single (undotted) half for both.
|
||||
fn two_half_note_score() -> (Score, crate::ids::EventId, crate::ids::EventId) {
|
||||
let mut ids = Vec::new();
|
||||
let score = metric_score(|idc, voice| {
|
||||
let mut events = Vec::new();
|
||||
for i in 0..2i64 {
|
||||
let eid = idc.mint();
|
||||
let pid = idc.mint::<PitchId>();
|
||||
ids.push(eid);
|
||||
events.push(pitched(
|
||||
eid,
|
||||
voice,
|
||||
r(i, 2),
|
||||
r(1, 2),
|
||||
vec![IdentifiedPitch {
|
||||
id: pid,
|
||||
pitch: integer_pitch(48 + i as i32),
|
||||
}],
|
||||
));
|
||||
}
|
||||
(events, vec![])
|
||||
});
|
||||
(score, ids[0], ids[1])
|
||||
}
|
||||
|
||||
/// An authored two-tied-quarters decomposition for `target` — a legitimate
|
||||
/// alternative to the inferred single half note (components sum to `1/2`).
|
||||
fn tied_quarters(
|
||||
target: crate::ids::EventId,
|
||||
source: DecompositionSource,
|
||||
) -> DecompositionAttachment {
|
||||
let quarter = |tied_to_next| NotatedComponent {
|
||||
base_value: NoteValue::Quarter,
|
||||
dots: 0,
|
||||
tuplet: None,
|
||||
tied_to_next,
|
||||
};
|
||||
DecompositionAttachment {
|
||||
target,
|
||||
components: vec![quarter(true), quarter(false)],
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
/// Another authored alternative summing to `1/2`: dotted quarter tied to an
|
||||
/// eighth. Distinct from [`tied_quarters`] so rank ties are observable.
|
||||
fn dotted_quarter_eighth(
|
||||
target: crate::ids::EventId,
|
||||
source: DecompositionSource,
|
||||
) -> DecompositionAttachment {
|
||||
DecompositionAttachment {
|
||||
target,
|
||||
components: vec![
|
||||
NotatedComponent {
|
||||
base_value: NoteValue::Quarter,
|
||||
dots: 1,
|
||||
tuplet: None,
|
||||
tied_to_next: true,
|
||||
},
|
||||
NotatedComponent {
|
||||
base_value: NoteValue::Eighth,
|
||||
dots: 0,
|
||||
tuplet: None,
|
||||
tied_to_next: false,
|
||||
},
|
||||
],
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authored_decomposition_outranks_the_inferred_default() {
|
||||
// Chapter 3: the pre-pass produces inferred decompositions only "for events
|
||||
// that lack a higher-precedence attachment" — an authored UserChosen
|
||||
// attachment must not be shadowed by the derived one.
|
||||
let (mut score, e1, e2) = two_half_note_score();
|
||||
let authored = tied_quarters(e1, DecompositionSource::UserChosen);
|
||||
score.decomposition_attachments.push(authored.clone());
|
||||
|
||||
let ann = derive_annotations(&score, &PrePassProfile::default());
|
||||
// The authored attachment is the effective decomposition for its event; no
|
||||
// derived (single-half-note) decomposition shadows it.
|
||||
assert_eq!(ann.decompositions[&e1], authored, "authored override wins");
|
||||
// The un-overridden event keeps the algorithm's inferred half note.
|
||||
let other = &ann.decompositions[&e2];
|
||||
assert_eq!(other.source, DecompositionSource::Inferred);
|
||||
assert_eq!(other.components.len(), 1);
|
||||
assert_eq!(other.components[0].base_value, NoteValue::Half);
|
||||
// Taxonomy counts the two outcomes distinctly, and together they cover the
|
||||
// effective map (the accounting the H harness checks).
|
||||
assert_eq!(ann.taxonomy.decompositions_authored, 1);
|
||||
assert_eq!(ann.taxonomy.decompositions_inferred, 1);
|
||||
assert_eq!(
|
||||
ann.decompositions.len(),
|
||||
ann.taxonomy.decompositions_inferred + ann.taxonomy.decompositions_authored
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inferred_source_attachment_does_not_outrank_the_prepass() {
|
||||
// An attachment whose source is `Inferred` does not outrank the pre-pass's
|
||||
// own output (the rank gate, mirroring `resolve_spelling`): the derived
|
||||
// half note stands.
|
||||
let (mut score, e1, _e2) = two_half_note_score();
|
||||
score
|
||||
.decomposition_attachments
|
||||
.push(tied_quarters(e1, DecompositionSource::Inferred));
|
||||
|
||||
let ann = derive_annotations(&score, &PrePassProfile::default());
|
||||
let dec = &ann.decompositions[&e1];
|
||||
assert_eq!(dec.components.len(), 1, "the pre-pass's half note stands");
|
||||
assert_eq!(dec.components[0].base_value, NoteValue::Half);
|
||||
assert_eq!(dec.source, DecompositionSource::Inferred);
|
||||
assert_eq!(ann.taxonomy.decompositions_authored, 0);
|
||||
assert_eq!(ann.taxonomy.decompositions_inferred, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decomposition_precedence_ranks_sources_then_canonical_order() {
|
||||
// UserChosen outranks Imported regardless of attachment order (the spec's
|
||||
// default precedence over the decomposition sources)...
|
||||
let (mut score, e1, _) = two_half_note_score();
|
||||
let imported = dotted_quarter_eighth(
|
||||
e1,
|
||||
DecompositionSource::Imported {
|
||||
format: crate::pitch::ForeignFormatId::new("musicxml"),
|
||||
},
|
||||
);
|
||||
let user = tied_quarters(e1, DecompositionSource::UserChosen);
|
||||
score.decomposition_attachments.push(imported); // listed first — must lose
|
||||
score.decomposition_attachments.push(user.clone());
|
||||
let ann = derive_annotations(&score, &PrePassProfile::default());
|
||||
assert_eq!(
|
||||
ann.decompositions[&e1], user,
|
||||
"UserChosen outranks Imported regardless of attachment order"
|
||||
);
|
||||
|
||||
// ...and a full rank tie keeps the first attachment in the score's
|
||||
// canonical `decomposition_attachments` order (deterministic across
|
||||
// replicas; the attachment carries no `priority` axis).
|
||||
let (mut score2, f1, _) = two_half_note_score();
|
||||
let first = tied_quarters(f1, DecompositionSource::UserChosen);
|
||||
let second = dotted_quarter_eighth(f1, DecompositionSource::UserChosen);
|
||||
score2.decomposition_attachments.push(first.clone());
|
||||
score2.decomposition_attachments.push(second);
|
||||
let ann2 = derive_annotations(&score2, &PrePassProfile::default());
|
||||
assert_eq!(
|
||||
ann2.decompositions[&f1], first,
|
||||
"rank ties keep the earlier attachment in canonical order"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derivation_with_authored_decomposition_is_deterministic() {
|
||||
// The authored override is reflected deterministically: the same overridden
|
||||
// score derives byte-identically twice, and the fingerprint distinguishes
|
||||
// the overridden derivation from the un-overridden one.
|
||||
let build = || {
|
||||
let (mut score, e1, _) = two_half_note_score();
|
||||
score
|
||||
.decomposition_attachments
|
||||
.push(tied_quarters(e1, DecompositionSource::UserChosen));
|
||||
score
|
||||
};
|
||||
let a = derive_annotations(&build(), &PrePassProfile::default());
|
||||
let b = derive_annotations(&build(), &PrePassProfile::default());
|
||||
assert_eq!(a, b);
|
||||
assert_eq!(a.canonical_fingerprint(), b.canonical_fingerprint());
|
||||
|
||||
let (plain, _, _) = two_half_note_score();
|
||||
let c = derive_annotations(&plain, &PrePassProfile::default());
|
||||
assert_ne!(
|
||||
a.canonical_fingerprint(),
|
||||
c.canonical_fingerprint(),
|
||||
"the override is visible in the derivation fingerprint"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authored_attachment_on_an_ungriddable_event_does_not_surface() {
|
||||
// The resolution step mirrors the spelling side: it layers authored
|
||||
// overrides above the pre-pass's *inferred* output. An event the pre-pass
|
||||
// cannot grid emits nothing to override, so an authored attachment for it
|
||||
// stays in canonical score state without surfacing as a derived annotation,
|
||||
// and the event stays honestly counted as ungriddable. (Whether authored
|
||||
// decompositions should surface for events the algorithm cannot infer for
|
||||
// is a Pass-12 question — see DECISIONS.md.)
|
||||
let mut ids = Vec::new();
|
||||
let mut score = metric_score(|idc, voice| {
|
||||
let eid = idc.mint();
|
||||
let pid = idc.mint::<PitchId>();
|
||||
ids.push(eid);
|
||||
let ev = pitched(
|
||||
eid,
|
||||
voice,
|
||||
r(0, 1),
|
||||
r(1, 128), // finer than a sixty-fourth: ungriddable
|
||||
vec![IdentifiedPitch {
|
||||
id: pid,
|
||||
pitch: integer_pitch(48),
|
||||
}],
|
||||
);
|
||||
(vec![ev], vec![])
|
||||
});
|
||||
score
|
||||
.decomposition_attachments
|
||||
.push(tied_quarters(ids[0], DecompositionSource::UserChosen));
|
||||
|
||||
let ann = derive_annotations(&score, &PrePassProfile::default());
|
||||
assert!(ann.decompositions.is_empty());
|
||||
assert_eq!(ann.taxonomy.decomposition_ungriddable, 1);
|
||||
assert_eq!(ann.taxonomy.decompositions_authored, 0);
|
||||
assert_eq!(ann.taxonomy.decompositions_inferred, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_algorithm_ids_are_not_honored() {
|
||||
// A profile requesting an algorithm the pre-pass does not implement must not
|
||||
|
|
|
|||
|
|
@ -24,9 +24,10 @@
|
|||
//! **Determinism and tolerance.** The inverse [`TempoMap::wallclock_to_musical`]
|
||||
//! uses a deterministic continued-fraction rational approximation with
|
||||
//! documented bounds ([`INVERSION_MAX_ITERATIONS`], [`INVERSION_MAX_DENOMINATOR`],
|
||||
//! [`INVERSION_TOLERANCE_WHOLE_NOTES`]) — the documented tolerance and iteration
|
||||
//! [`inversion_tolerance`]) — the documented tolerance and iteration
|
||||
//! bounds Chapter 3 §"Conversion" requires of any numerical inversion. The
|
||||
//! residual is governed by [`epiphany_determinism::ToleranceClass::TempoIntegration`].
|
||||
//! residual is a typed [`Tolerance`] of class
|
||||
//! [`epiphany_determinism::ToleranceClass::TempoIntegration`].
|
||||
//!
|
||||
//! Conversion here is **advisory** (it uses `f64`), not canonical state:
|
||||
//! musical time is the exact rational and wall-clock is exact nanoseconds
|
||||
|
|
@ -152,11 +153,28 @@ pub const INVERSION_MAX_ITERATIONS: u32 = 64;
|
|||
/// Maximum denominator the inverse will introduce, in whole-note units. Keeps
|
||||
/// recovered rhythms simple (`1/3`, `1/12`, …) instead of spurious large ratios.
|
||||
pub const INVERSION_MAX_DENOMINATOR: u64 = 1_000_000;
|
||||
/// Absolute residual tolerance of the inverse, in whole notes
|
||||
/// ([`epiphany_determinism::ToleranceClass::TempoIntegration`]). Comfortably
|
||||
/// Absolute residual magnitude of the inverse, in whole notes. Comfortably
|
||||
/// larger than the half-nanosecond rounding of the forward direction, so an
|
||||
/// ordinary rhythm round-trips, yet far smaller than any musical distinction.
|
||||
pub const INVERSION_TOLERANCE_WHOLE_NOTES: f64 = 1e-6;
|
||||
/// The typed tolerance built on it is [`inversion_tolerance`]; this raw value
|
||||
/// never leaves the module.
|
||||
const INVERSION_TOLERANCE_WHOLE_NOTES: f64 = 1e-6;
|
||||
|
||||
/// The typed absolute residual tolerance of the inverse conversion
|
||||
/// ([`TempoMap::wallclock_to_musical`]): a [`Tolerance`] of class
|
||||
/// [`ToleranceClass::TempoIntegration`] (Appendix D §"Tolerance Classes" — no
|
||||
/// ad-hoc epsilons), absolute bound [`INVERSION_TOLERANCE_WHOLE_NOTES`] whole
|
||||
/// notes, no relative bound, governing validation (it decides whether a
|
||||
/// continued-fraction candidate is accepted; conversion is advisory, never
|
||||
/// canonical state).
|
||||
pub fn inversion_tolerance() -> Tolerance {
|
||||
Tolerance::absolute(
|
||||
ToleranceClass::TempoIntegration,
|
||||
INVERSION_TOLERANCE_WHOLE_NOTES,
|
||||
ToleranceGovernance::Validation,
|
||||
)
|
||||
.expect("constant inversion tolerance is finite and non-negative")
|
||||
}
|
||||
|
||||
/// Relative tolerance below which two segment speeds count as *equal*, so the
|
||||
/// integration takes the numerically-stable constant-speed limit instead of the
|
||||
|
|
@ -323,7 +341,7 @@ impl TempoMap {
|
|||
/// §"Conversion"), using `region_relative_resolve` for segment boundaries.
|
||||
/// The inverse uses a deterministic continued-fraction rational
|
||||
/// approximation with the documented bounds [`INVERSION_MAX_ITERATIONS`] /
|
||||
/// [`INVERSION_MAX_DENOMINATOR`] / [`INVERSION_TOLERANCE_WHOLE_NOTES`], so an
|
||||
/// [`INVERSION_MAX_DENOMINATOR`] / [`inversion_tolerance`], so an
|
||||
/// ordinary rhythm (a triplet `1/12`, a dotted `3/8`) round-trips exactly
|
||||
/// rather than being quantized to a fixed grid.
|
||||
pub fn wallclock_to_musical(&self, time: WallClockTime) -> Result<MusicalPosition, TempoError> {
|
||||
|
|
@ -343,7 +361,7 @@ impl TempoMap {
|
|||
rational_from_f64(
|
||||
whole_notes,
|
||||
INVERSION_MAX_DENOMINATOR,
|
||||
INVERSION_TOLERANCE_WHOLE_NOTES,
|
||||
inversion_tolerance().absolute.get(),
|
||||
)
|
||||
.map(MusicalPosition)
|
||||
.ok_or(TempoError::ConversionOverflow)
|
||||
|
|
@ -692,6 +710,19 @@ mod tests {
|
|||
assert!(Tempo::new(120.0, MusicalDuration::zero()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inversion_tolerance_is_a_typed_tempo_integration_tolerance() {
|
||||
// The inverse's residual bound is a named `Tolerance` of class
|
||||
// `TempoIntegration` (Appendix D §"Tolerance Classes": no ad-hoc epsilon
|
||||
// constants), pinned to the documented magnitude: absolute 1e-6 whole
|
||||
// notes, no relative bound, validation governance.
|
||||
let t = inversion_tolerance();
|
||||
assert_eq!(t.class, ToleranceClass::TempoIntegration);
|
||||
assert_eq!(t.absolute.get(), 1e-6);
|
||||
assert_eq!(t.relative, None);
|
||||
assert_eq!(t.governance, ToleranceGovernance::Validation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_segment_is_integrated_not_rejected() {
|
||||
// A single linear ramp over [0, 1] whole notes from 60 to 120 q-bpm.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,423 @@
|
|||
//! The edit-barrier gate (Chapter 8 §"Forward Compatibility and Edit Barriers"
|
||||
//! / §"Behavior Under Unknown Extensions").
|
||||
//!
|
||||
//! The spec's MUST: *"Edits MUST be checked against every active edit barrier.
|
||||
//! An edit matching a barrier's scope, affected object kinds, and operation
|
||||
//! kinds is prohibited unless the user explicitly performs an unsafe edit."*
|
||||
//! The session holds the active extensions' decoded barriers
|
||||
//! ([`ActiveExtension`]); before minting an envelope, [`crate::EditorSession`]
|
||||
//! derives the candidate edit's **subjects** — the objects the operation's
|
||||
//! payload names, each with its structural containment ([`EditContext`]) — and
|
||||
//! evaluates every barrier via [`EditBarrier::prohibits_edit`] against a
|
||||
//! [`ScoreOracle`] over the session's materialized score.
|
||||
//!
|
||||
//! Matching policy (documented, deliberate):
|
||||
//!
|
||||
//! * **Object-kind matching is on the payload's named targets** (the object an
|
||||
//! op mints, tombstones, or overwrites — plus, for a pitch insert, the host
|
||||
//! event whose pitch list it mutates). Indirect containers (the voice an
|
||||
//! event sits in, say) are *not* treated as edited objects; protecting a
|
||||
//! container is what the barrier's **scope** is for, and scope is matched
|
||||
//! against the target's real containment, precisely.
|
||||
//! * **Score-level operations** (`SetMetadata`, the transaction descriptor)
|
||||
//! name no graph object: only a score-wide barrier (empty
|
||||
//! `affected_object_kinds`, `WholeScore`/`TuningContext`/`Registered` scope)
|
||||
//! can match them.
|
||||
//! * **Extension-defined operations** (`OperationKind::Registered`) carry a
|
||||
//! payload the core cannot read, so their targets are unknowable: a barrier
|
||||
//! prohibiting that registered kind matches **conservatively** (scope and
|
||||
//! object kinds are treated as matching — Chapter 8's "never silently drop a
|
||||
//! barrier you cannot evaluate").
|
||||
|
||||
use epiphany_core::{
|
||||
EventId, PitchId, PitchSpaceId, RegionId, Score, StaffInstanceId, TypedObjectId, VoiceId,
|
||||
};
|
||||
use epiphany_layout_ir::{BarrierScope, EditBarrier, EditContext, EditOracle, ExtensionRef};
|
||||
use epiphany_ops::{OperationKind, OperationKindTag};
|
||||
|
||||
/// One active extension declaration's barrier view: the declaring extension
|
||||
/// (named when its barrier refuses an edit, and recorded for tombstoning when
|
||||
/// an unsafe edit crosses it) plus its decoded edit barriers.
|
||||
///
|
||||
/// The session opens on a bare [`Score`], not a bundle, so it cannot read the
|
||||
/// manifest itself: whoever opened the bundle decodes each
|
||||
/// `ExtensionDeclaration.edit_barriers` blob
|
||||
/// ([`epiphany_layout_ir::decode_edit_barriers`]) and injects the result via
|
||||
/// [`crate::EditorSession::set_active_extensions`].
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct ActiveExtension {
|
||||
/// The declaring extension (the manifest `ExtensionDeclaration`'s
|
||||
/// `extension_id`, as the barrier layer's opaque 128-bit reference).
|
||||
pub extension: ExtensionRef,
|
||||
/// The extension's decoded edit barriers.
|
||||
pub barriers: Vec<EditBarrier>,
|
||||
}
|
||||
|
||||
/// What a candidate operation edits, for barrier matching.
|
||||
pub(crate) enum BarrierSubjects {
|
||||
/// The graph objects the payload names, each with its containment.
|
||||
Objects(Vec<(TypedObjectId, EditContext)>),
|
||||
/// The operation edits score-level state and names no graph object
|
||||
/// (`SetMetadata`, a transaction descriptor): only a score-wide barrier
|
||||
/// can match.
|
||||
ScoreWide,
|
||||
/// An extension-defined operation whose payload the core cannot read:
|
||||
/// scope and object kinds are treated conservatively as matching.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// The [`EditOracle`] over the session's materialized score: **known** barrier
|
||||
/// conditions are evaluated *precisely* against the graph, so a barrier
|
||||
/// narrowed to `ObjectExists` really deactivates when the object is gone.
|
||||
///
|
||||
/// `has_extension_data` is `false` for every object: the v0 materialized score
|
||||
/// carries no extension payloads on graph objects, so "carries data declared by
|
||||
/// the extension" is precisely (not conservatively) false.
|
||||
pub(crate) struct ScoreOracle<'a>(pub &'a Score);
|
||||
|
||||
impl EditOracle for ScoreOracle<'_> {
|
||||
fn object_exists(&self, object: &TypedObjectId) -> bool {
|
||||
let score = self.0;
|
||||
match object {
|
||||
TypedObjectId::Event(id) => score.events.get(*id).is_some(),
|
||||
TypedObjectId::Pitch(id) => score.live_pitch_ids().contains(id),
|
||||
TypedObjectId::Voice(id) => score.voices().any(|(_, _, v)| v.id == *id),
|
||||
TypedObjectId::Staff(id) => score.staves.iter().any(|s| s.id == *id),
|
||||
TypedObjectId::StaffInstance(id) => score.staff_instances().any(|(_, si)| si.id == *id),
|
||||
TypedObjectId::StaffGroup(id) => score.staff_groups.iter().any(|g| g.id == *id),
|
||||
TypedObjectId::Region(id) => score.canvas.regions.iter().any(|r| r.id == *id),
|
||||
TypedObjectId::Instrument(id) => score.instruments.iter().any(|i| i.id == *id),
|
||||
TypedObjectId::PartDefinition(id) => score.parts.iter().any(|p| p.id == *id),
|
||||
TypedObjectId::BarlineAlignmentGroup(id) => score
|
||||
.canvas
|
||||
.regions
|
||||
.iter()
|
||||
.flat_map(|r| r.content.barline_alignment_groups())
|
||||
.any(|g| g.id == *id),
|
||||
TypedObjectId::Slur(id) => score.cross_cutting.slurs.iter().any(|s| s.id == *id),
|
||||
TypedObjectId::Tie(id) => score.cross_cutting.ties.iter().any(|t| t.id == *id),
|
||||
TypedObjectId::Beam(id) => score.cross_cutting.beams.iter().any(|b| b.id == *id),
|
||||
TypedObjectId::Spanner(id) => score.cross_cutting.spanners.iter().any(|s| s.id == *id),
|
||||
TypedObjectId::Tuplet(id) => score.cross_cutting.tuplets.iter().any(|t| t.id == *id),
|
||||
TypedObjectId::Marker(id) => score.cross_cutting.markers.iter().any(|m| m.id == *id),
|
||||
TypedObjectId::RepeatStructure(id) => {
|
||||
score.cross_cutting.repeats.iter().any(|r| r.id == *id)
|
||||
}
|
||||
TypedObjectId::AnalyticalAnnotation(id) => {
|
||||
score.cross_cutting.analytical.iter().any(|a| a.id == *id)
|
||||
}
|
||||
TypedObjectId::Comment(id) => score.cross_cutting.comments.iter().any(|c| c.id == *id),
|
||||
TypedObjectId::GraphicGesture(id) => score
|
||||
.cross_cutting
|
||||
.graphic_gestures
|
||||
.iter()
|
||||
.any(|g| g.id == *id),
|
||||
TypedObjectId::LyricLine(id) => score.cross_cutting.lyrics.iter().any(|l| l.id == *id),
|
||||
TypedObjectId::ChordSymbol(id) => score
|
||||
.cross_cutting
|
||||
.chord_symbols
|
||||
.iter()
|
||||
.any(|c| c.id == *id),
|
||||
TypedObjectId::GraphicObject(id) => score
|
||||
.canvas
|
||||
.regions
|
||||
.iter()
|
||||
.flat_map(|r| r.content.graphic_objects())
|
||||
.any(|g| g.id == *id),
|
||||
TypedObjectId::TimeSignature(id) => score.time_signatures.iter().any(|t| t.id == *id),
|
||||
TypedObjectId::AnalysisLayer(id) => score.analysis_layers.iter().any(|l| l.id == *id),
|
||||
TypedObjectId::View(id) => score.views.iter().any(|v| v.id == *id),
|
||||
// The v0 graph hosts no measure objects (measures are derived) and
|
||||
// no extension-registered objects, so these precisely do not exist.
|
||||
TypedObjectId::Measure(_) | TypedObjectId::Registered(..) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn has_extension_data(&self, _object: &TypedObjectId, _extension: ExtensionRef) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn ctx(region: Option<RegionId>, staff_instance: Option<StaffInstanceId>) -> EditContext {
|
||||
EditContext {
|
||||
region,
|
||||
staff_instance,
|
||||
analysis_layer: None,
|
||||
pitch_space: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The region a staff instance manifests in, if it is in the graph.
|
||||
fn region_of_staff_instance(score: &Score, instance: StaffInstanceId) -> Option<RegionId> {
|
||||
score
|
||||
.staff_instances()
|
||||
.find(|(_, si)| si.id == instance)
|
||||
.map(|(region, _)| region)
|
||||
}
|
||||
|
||||
/// The (region, staff instance) a voice lives in, if it is in the graph.
|
||||
fn voice_location(score: &Score, voice: VoiceId) -> Option<(RegionId, StaffInstanceId)> {
|
||||
score
|
||||
.voices()
|
||||
.find(|(_, _, v)| v.id == voice)
|
||||
.map(|(region, si, _)| (region, si))
|
||||
}
|
||||
|
||||
/// The (region, staff instance) an event lives in, via the voice listing it.
|
||||
fn event_location(score: &Score, event: EventId) -> Option<(RegionId, StaffInstanceId)> {
|
||||
score
|
||||
.voices()
|
||||
.find(|(_, _, v)| v.events.contains(&event))
|
||||
.map(|(region, si, _)| (region, si))
|
||||
}
|
||||
|
||||
/// The containment and pitch space of a live pitch, via the event embedding it.
|
||||
fn pitch_context(score: &Score, pitch: PitchId) -> EditContext {
|
||||
let mut buf: Vec<&epiphany_core::IdentifiedPitch> = Vec::new();
|
||||
for event in score.events.iter() {
|
||||
buf.clear();
|
||||
event.collect_identified_pitches(&mut buf);
|
||||
if let Some(ip) = buf.iter().find(|ip| ip.id == pitch) {
|
||||
let location = event_location(score, event.id());
|
||||
return EditContext {
|
||||
region: location.map(|(r, _)| r),
|
||||
staff_instance: location.map(|(_, si)| si),
|
||||
analysis_layer: None,
|
||||
pitch_space: Some(ip.pitch.scale_position.space.clone()),
|
||||
};
|
||||
}
|
||||
}
|
||||
EditContext::default()
|
||||
}
|
||||
|
||||
/// A context for a pitch value the operation itself carries (an insert's new
|
||||
/// pitch), whose space is read from the value rather than the graph.
|
||||
fn carried_pitch_context(
|
||||
location: Option<(RegionId, StaffInstanceId)>,
|
||||
space: PitchSpaceId,
|
||||
) -> EditContext {
|
||||
EditContext {
|
||||
region: location.map(|(r, _)| r),
|
||||
staff_instance: location.map(|(_, si)| si),
|
||||
analysis_layer: None,
|
||||
pitch_space: Some(space),
|
||||
}
|
||||
}
|
||||
|
||||
/// The context of a cross-cutting structure: the containment of its first
|
||||
/// locatable endpoint event (a slur/tie/beam/spanner lives where its anchors
|
||||
/// live), or the default context when none is locatable.
|
||||
fn structure_context(score: &Score, endpoints: &[TypedObjectId]) -> EditContext {
|
||||
let location = endpoints.iter().find_map(|endpoint| match endpoint {
|
||||
TypedObjectId::Event(id) => event_location(score, *id),
|
||||
_ => None,
|
||||
});
|
||||
ctx(location.map(|(r, _)| r), location.map(|(_, si)| si))
|
||||
}
|
||||
|
||||
/// The endpoints of a cross-cutting structure already in the graph, by id.
|
||||
fn graph_structure_endpoints(score: &Score, structure: &TypedObjectId) -> Vec<TypedObjectId> {
|
||||
let events = |ids: Vec<EventId>| ids.into_iter().map(TypedObjectId::Event).collect();
|
||||
match structure {
|
||||
TypedObjectId::Slur(id) => score
|
||||
.cross_cutting
|
||||
.slurs
|
||||
.iter()
|
||||
.find(|s| s.id == *id)
|
||||
.map(|s| events(vec![s.start_event, s.end_event]))
|
||||
.unwrap_or_default(),
|
||||
TypedObjectId::Tie(id) => score
|
||||
.cross_cutting
|
||||
.ties
|
||||
.iter()
|
||||
.find(|t| t.id == *id)
|
||||
.map(|t| events(vec![t.start_event, t.end_event]))
|
||||
.unwrap_or_default(),
|
||||
TypedObjectId::Beam(id) => score
|
||||
.cross_cutting
|
||||
.beams
|
||||
.iter()
|
||||
.find(|b| b.id == *id)
|
||||
.map(|b| events(b.events.clone()))
|
||||
.unwrap_or_default(),
|
||||
TypedObjectId::Spanner(id) => score
|
||||
.cross_cutting
|
||||
.spanners
|
||||
.iter()
|
||||
.find(|s| s.id == *id)
|
||||
.map(|s| {
|
||||
[&s.start, &s.end]
|
||||
.into_iter()
|
||||
.filter_map(|anchor| match anchor {
|
||||
epiphany_core::TimeAnchor::Event { id, .. } => {
|
||||
Some(TypedObjectId::Event(*id))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Derives the barrier subjects of `kind`: the objects its payload names, each
|
||||
/// with its containment in `score` (see the module docs for the matching
|
||||
/// policy). Containment that cannot be resolved (the target is not in the
|
||||
/// graph — reduction's invariant preconditions own that case) yields a context
|
||||
/// with the unresolved fields `None`.
|
||||
pub(crate) fn subjects_of(kind: &OperationKind, score: &Score) -> BarrierSubjects {
|
||||
let one = |object: TypedObjectId, context: EditContext| {
|
||||
BarrierSubjects::Objects(vec![(object, context)])
|
||||
};
|
||||
match kind {
|
||||
OperationKind::InsertEvent(op) => {
|
||||
let region = region_of_staff_instance(score, op.staff_instance);
|
||||
one(
|
||||
TypedObjectId::Event(op.event_id()),
|
||||
ctx(region, Some(op.staff_instance)),
|
||||
)
|
||||
}
|
||||
OperationKind::DeleteEvent(op) => {
|
||||
let location = event_location(score, op.event);
|
||||
one(
|
||||
TypedObjectId::Event(op.event),
|
||||
ctx(location.map(|(r, _)| r), location.map(|(_, si)| si)),
|
||||
)
|
||||
}
|
||||
OperationKind::ModifyEvent(op) => {
|
||||
let location = event_location(score, op.event_id());
|
||||
one(
|
||||
TypedObjectId::Event(op.event_id()),
|
||||
ctx(location.map(|(r, _)| r), location.map(|(_, si)| si)),
|
||||
)
|
||||
}
|
||||
OperationKind::RespellPitch(op) => one(
|
||||
TypedObjectId::Pitch(op.pitch),
|
||||
pitch_context(score, op.pitch),
|
||||
),
|
||||
OperationKind::Transpose(op) => BarrierSubjects::Objects(
|
||||
op.targets
|
||||
.iter()
|
||||
.map(|pitch| (TypedObjectId::Pitch(*pitch), pitch_context(score, *pitch)))
|
||||
.collect(),
|
||||
),
|
||||
OperationKind::InsertIdentifiedPitch(op) => {
|
||||
// The op mutates the host event's pitch list *and* mints the pitch:
|
||||
// both are named targets.
|
||||
let location = event_location(score, op.event);
|
||||
let event_ctx = ctx(location.map(|(r, _)| r), location.map(|(_, si)| si));
|
||||
let pitch_ctx =
|
||||
carried_pitch_context(location, op.pitch.pitch.scale_position.space.clone());
|
||||
BarrierSubjects::Objects(vec![
|
||||
(TypedObjectId::Event(op.event), event_ctx),
|
||||
(TypedObjectId::Pitch(op.pitch_id()), pitch_ctx),
|
||||
])
|
||||
}
|
||||
OperationKind::DeleteIdentifiedPitch(op) => one(
|
||||
TypedObjectId::Pitch(op.pitch),
|
||||
pitch_context(score, op.pitch),
|
||||
),
|
||||
OperationKind::ModifyIdentifiedPitch(op) => one(
|
||||
TypedObjectId::Pitch(op.pitch),
|
||||
pitch_context(score, op.pitch),
|
||||
),
|
||||
OperationKind::CreateCrossCutting(op) => one(
|
||||
op.structure.id(),
|
||||
structure_context(score, &op.structure.endpoints()),
|
||||
),
|
||||
OperationKind::ModifyCrossCutting(op) => one(
|
||||
op.structure.id(),
|
||||
structure_context(score, &op.structure.endpoints()),
|
||||
),
|
||||
OperationKind::DeleteCrossCutting(op) => one(
|
||||
op.structure,
|
||||
structure_context(score, &graph_structure_endpoints(score, &op.structure)),
|
||||
),
|
||||
OperationKind::ChangeRegionTimeModel(op) => {
|
||||
one(TypedObjectId::Region(op.region), ctx(Some(op.region), None))
|
||||
}
|
||||
OperationKind::SetUserSystemBreak(op) => {
|
||||
one(TypedObjectId::Region(op.region), ctx(Some(op.region), None))
|
||||
}
|
||||
OperationKind::SetUserPageBreak(op) => {
|
||||
one(TypedObjectId::Region(op.region), ctx(Some(op.region), None))
|
||||
}
|
||||
OperationKind::SetMetricGrid(op) => {
|
||||
one(TypedObjectId::Region(op.region), ctx(Some(op.region), None))
|
||||
}
|
||||
OperationKind::CreateRegion(op) => one(
|
||||
TypedObjectId::Region(op.region_id()),
|
||||
ctx(Some(op.region_id()), None),
|
||||
),
|
||||
OperationKind::DeleteRegion(op) => {
|
||||
one(TypedObjectId::Region(op.region), ctx(Some(op.region), None))
|
||||
}
|
||||
OperationKind::CreateStaffInstance(op) => one(
|
||||
TypedObjectId::StaffInstance(op.instance_id()),
|
||||
ctx(Some(op.region), Some(op.instance_id())),
|
||||
),
|
||||
OperationKind::DeleteStaffInstance(op) => one(
|
||||
TypedObjectId::StaffInstance(op.staff_instance),
|
||||
ctx(
|
||||
region_of_staff_instance(score, op.staff_instance),
|
||||
Some(op.staff_instance),
|
||||
),
|
||||
),
|
||||
OperationKind::CreateVoice(op) => one(
|
||||
TypedObjectId::Voice(op.voice_id()),
|
||||
ctx(
|
||||
region_of_staff_instance(score, op.staff_instance),
|
||||
Some(op.staff_instance),
|
||||
),
|
||||
),
|
||||
OperationKind::DeleteVoice(op) => {
|
||||
let location = voice_location(score, op.voice);
|
||||
one(
|
||||
TypedObjectId::Voice(op.voice),
|
||||
ctx(location.map(|(r, _)| r), location.map(|(_, si)| si)),
|
||||
)
|
||||
}
|
||||
OperationKind::SetMetadata(_) | OperationKind::DeclareTransaction(_) => {
|
||||
BarrierSubjects::ScoreWide
|
||||
}
|
||||
OperationKind::Registered(..) => BarrierSubjects::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every active extension with a barrier prohibiting the candidate edit, in
|
||||
/// declaration order, deduplicated. Empty means the edit is permitted.
|
||||
pub(crate) fn prohibiting_extensions(
|
||||
extensions: &[ActiveExtension],
|
||||
tag: OperationKindTag,
|
||||
subjects: &BarrierSubjects,
|
||||
oracle: &dyn EditOracle,
|
||||
) -> Vec<ExtensionRef> {
|
||||
let mut crossed = Vec::new();
|
||||
for ext in extensions {
|
||||
let hit = ext.barriers.iter().any(|barrier| match subjects {
|
||||
BarrierSubjects::Objects(objects) => objects
|
||||
.iter()
|
||||
.any(|(object, context)| barrier.prohibits_edit(tag, object, context, oracle)),
|
||||
BarrierSubjects::ScoreWide => {
|
||||
barrier.prohibited_operation_kinds.contains(&tag)
|
||||
&& barrier.affected_object_kinds.is_empty()
|
||||
&& matches!(
|
||||
barrier.scope,
|
||||
BarrierScope::WholeScore
|
||||
| BarrierScope::TuningContext
|
||||
| BarrierScope::Registered(_)
|
||||
)
|
||||
&& barrier.condition.is_active(oracle)
|
||||
}
|
||||
BarrierSubjects::Unknown => {
|
||||
barrier.prohibited_operation_kinds.contains(&tag)
|
||||
&& barrier.condition.is_active(oracle)
|
||||
}
|
||||
});
|
||||
if hit && !crossed.contains(&ext.extension) {
|
||||
crossed.push(ext.extension);
|
||||
}
|
||||
}
|
||||
crossed
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -127,6 +127,7 @@ fn payload_label(payload: &OperationPayload) -> &'static str {
|
|||
},
|
||||
OperationPayload::ResolveConflict(_) => "ResolveConflict",
|
||||
OperationPayload::UndoTransaction(_) => "UndoTransaction",
|
||||
OperationPayload::ResolveEquivocation(_) => "ResolveEquivocation",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,11 +14,16 @@
|
|||
//! [`Engraver`] runs a genuine deterministic **horizontal spacing pass** (see
|
||||
//! [`spacing`]) — placing each glyph-bearing slot left-to-right by a
|
||||
//! collision-aware advance (its preferred width floored by the real glyph
|
||||
//! bearings) — and **evaluates the IR's declared hard constraints** against the
|
||||
//! resolved geometry (no-collision, alignment, position-within; a hard break or
|
||||
//! unverifiable extension constraint it cannot honour is reported unsatisfied).
|
||||
//! A solve is [`SolveStatus::Solved`] only when every hard constraint is
|
||||
//! satisfied; otherwise it is a diagnostic layout naming the ones it could not.
|
||||
//! bearings) — and **evaluates the IR's declared constraints** against the
|
||||
//! resolved geometry, routed by [`LayoutConstraint::strength`] (Chapter 9
|
||||
//! §"Strength Levels"): a violated `Required` constraint (no-collision,
|
||||
//! alignment, position-within, a hard break, an unverifiable extension
|
||||
//! constraint) is reported unsatisfied and the solve is
|
||||
//! [`SolveStatus::Unsatisfiable`]; a violated `Preferred` constraint (a soft
|
||||
//! break this single-system solve does not honour) surfaces as a
|
||||
//! [`SolverWarningKind::LargeSoftConstraintViolation`] warning under
|
||||
//! [`SolveStatus::SolvedWithWarnings`], never a failure. A solve is
|
||||
//! [`SolveStatus::Solved`] only when every declared constraint holds.
|
||||
//!
|
||||
//! Having earned it, [`Engraver::tier`] reports [`SolverTier::Minimal`] — which
|
||||
//! (Chapter 9 §"Conformance Tiers" / QUICKSTART) means *hard constraints
|
||||
|
|
@ -44,11 +49,11 @@ mod spacing;
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use epiphany_layout_ir::{
|
||||
all_available, Axis, BravuraCatalog, BreakKind, ConstrainedLayoutIR, ConstraintId,
|
||||
ConstraintSolver, GlyphCatalog, GlyphObject, GlyphObjectId, InvalidationSet, LayoutConstraint,
|
||||
Margins, Point, QualityMetricVector, Rect, ResolvedGlyph, ResolvedLayoutIR, ResolvedPage,
|
||||
ResolvedSystem, Size2D, SolveReport, SolveStatus, SolverBudgetUsed, SolverConfig, SolverState,
|
||||
SolverTier, SolverVersion, SolverWarning, SolverWarningKind, Stroke,
|
||||
all_available, Axis, BravuraCatalog, ConstrainedLayoutIR, ConstraintId, ConstraintSolver,
|
||||
ConstraintStrength, GlyphCatalog, GlyphObject, GlyphObjectId, InvalidationSet,
|
||||
LayoutConstraint, Margins, Point, QualityMetricVector, Rect, ResolvedGlyph, ResolvedLayoutIR,
|
||||
ResolvedPage, ResolvedSystem, Size2D, SolveReport, SolveStatus, SolverBudgetUsed, SolverConfig,
|
||||
SolverState, SolverTier, SolverVersion, SolverWarning, SolverWarningKind, Stroke,
|
||||
};
|
||||
|
||||
/// The glyph a fixed-width stroke (a ledger line) belongs to: the same-source glyph
|
||||
|
|
@ -82,11 +87,13 @@ pub const ENGRAVER_VERSION: SolverVersion = SolverVersion(1);
|
|||
impl Engraver {
|
||||
/// Resolves geometry: a deterministic horizontal spacing pass over the spring
|
||||
/// slots (each glyph to its slot's `x`, baseline `y` preserved), then
|
||||
/// evaluation of the declared hard constraints. A malformed input — an unknown
|
||||
/// glyph, a forged catalog identity, or invalid structure — yields
|
||||
/// [`SolveStatus::InternalError`]; a valid problem whose hard constraints
|
||||
/// cannot all be satisfied yields [`SolveStatus::Unsatisfiable`] (naming the
|
||||
/// unsatisfied constraints). Both are diagnostic-only; neither panics.
|
||||
/// evaluation of the declared constraints by strength. A malformed input — an
|
||||
/// unknown glyph, a forged catalog identity, or invalid structure — yields
|
||||
/// [`SolveStatus::InternalError`]; a valid problem whose `Required`
|
||||
/// constraints cannot all be satisfied yields [`SolveStatus::Unsatisfiable`]
|
||||
/// (naming the unsatisfied constraints). Both are diagnostic-only; neither
|
||||
/// panics. Violated `Preferred` constraints yield soft-violation warnings
|
||||
/// under [`SolveStatus::SolvedWithWarnings`] — a valid, renderable layout.
|
||||
fn resolve(&self, input: &ConstrainedLayoutIR) -> SolveReport {
|
||||
let structural_valid = input.validate().is_ok();
|
||||
|
||||
|
|
@ -110,31 +117,43 @@ impl Engraver {
|
|||
};
|
||||
let resolved_glyphs = glyphs.len();
|
||||
|
||||
// Evaluate every declared hard constraint against the *resolved* geometry.
|
||||
// A Minimal solve is `Solved` only when all are satisfied. A structurally
|
||||
// invalid or bad-catalog input is not evaluated — there is no trustworthy
|
||||
// geometry — so it reports no evaluation work.
|
||||
let (constraints_satisfied, unsatisfied_constraints, constraints_evaluated) =
|
||||
if structural_valid && catalog_valid {
|
||||
let (satisfied, unsatisfied) = evaluate_constraints(&input.constraints, &glyphs);
|
||||
(satisfied, unsatisfied, input.constraints.len() as u64)
|
||||
} else {
|
||||
(false, Vec::new(), 0)
|
||||
};
|
||||
let well_formed = structural_valid && catalog_valid && constraints_satisfied;
|
||||
// Evaluate every declared constraint against the *resolved* geometry,
|
||||
// routed by its strength (Chapter 9 §"Strength Levels"): a violated
|
||||
// `Required` constraint is unsatisfied (the solve is `Unsatisfiable`);
|
||||
// a violated `Preferred` one is a soft-violation *warning*, never a
|
||||
// failure. A structurally invalid or bad-catalog input is not evaluated
|
||||
// — there is no trustworthy geometry — so it reports no evaluation work.
|
||||
let (evaluation, constraints_evaluated) = if structural_valid && catalog_valid {
|
||||
(
|
||||
evaluate_constraints(&input.constraints, &glyphs),
|
||||
input.constraints.len() as u64,
|
||||
)
|
||||
} else {
|
||||
(ConstraintEvaluation::not_evaluated(), 0)
|
||||
};
|
||||
let ConstraintEvaluation {
|
||||
required_satisfied,
|
||||
unsatisfied: unsatisfied_constraints,
|
||||
soft_violations,
|
||||
} = evaluation;
|
||||
let well_formed = structural_valid && catalog_valid && required_satisfied;
|
||||
// Distinguish a *malformed/unusable* input (InternalError — a structural or
|
||||
// catalog defect the solver cannot proceed past) from a *valid problem
|
||||
// whose declared hard constraints cannot all be satisfied* (Unsatisfiable),
|
||||
// per the solver-report contract (Chapter 9 §"The Solver Report").
|
||||
// per the solver-report contract (Chapter 9 §"The Solver Report"). Hard
|
||||
// constraints all satisfied but soft ones violated is a valid layout
|
||||
// worth flagging: SolvedWithWarnings.
|
||||
let status = if !structural_valid || !catalog_valid {
|
||||
SolveStatus::InternalError
|
||||
} else if constraints_satisfied {
|
||||
SolveStatus::Solved
|
||||
} else {
|
||||
} else if !required_satisfied {
|
||||
SolveStatus::Unsatisfiable
|
||||
} else if !soft_violations.is_empty() {
|
||||
SolveStatus::SolvedWithWarnings
|
||||
} else {
|
||||
SolveStatus::Solved
|
||||
};
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
let mut warnings = soft_violations;
|
||||
if structural_valid && catalog_valid && !unsatisfied_constraints.is_empty() {
|
||||
warnings.push(SolverWarning {
|
||||
kind: SolverWarningKind::UnusualLayoutDecision(
|
||||
|
|
@ -313,27 +332,53 @@ fn interp((s0, t0): (f32, f32), (s1, t1): (f32, f32), x: f32) -> f32 {
|
|||
}
|
||||
}
|
||||
|
||||
/// Evaluates the IR's declared hard constraints against the *resolved* geometry,
|
||||
/// returning whether all are satisfied and the ids of those that are not — a
|
||||
/// constraint's id is its index in the IR's constraint list.
|
||||
/// What evaluating the declared constraints found, routed by strength: whether
|
||||
/// every `Required` constraint held, the ids of those that did not, and a
|
||||
/// soft-violation warning per unhonoured `Preferred` constraint.
|
||||
struct ConstraintEvaluation {
|
||||
required_satisfied: bool,
|
||||
unsatisfied: Vec<ConstraintId>,
|
||||
soft_violations: Vec<SolverWarning>,
|
||||
}
|
||||
|
||||
impl ConstraintEvaluation {
|
||||
/// The result for an input that was never evaluated (malformed structure or
|
||||
/// catalog): nothing is claimed satisfied, nothing is named unsatisfied.
|
||||
fn not_evaluated() -> Self {
|
||||
ConstraintEvaluation {
|
||||
required_satisfied: false,
|
||||
unsatisfied: Vec::new(),
|
||||
soft_violations: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluates the IR's declared constraints against the *resolved* geometry — a
|
||||
/// constraint's id is its index in the IR's constraint list — routing each
|
||||
/// violation by [`LayoutConstraint::strength`] (Chapter 9 §"Strength Levels"):
|
||||
/// a violated `Required` constraint is reported unsatisfied, a violated
|
||||
/// `Preferred` one becomes a [`SolverWarningKind::LargeSoftConstraintViolation`]
|
||||
/// warning and never fails the solve.
|
||||
///
|
||||
/// Geometric constraints (no-collision, alignment, position-within) are checked
|
||||
/// against the resolved glyph boxes. A *hard* break is reported unsatisfied — a
|
||||
/// single-system, single-page Minimal solve casts off nothing, so it cannot force
|
||||
/// a break (a *soft* break imposes no obligation). An extension `Registered`
|
||||
/// constraint this solver cannot interpret is likewise not claimed satisfied
|
||||
/// (Chapter 7 §"Behavior Under Unknown Extensions": conservative).
|
||||
/// against the resolved glyph boxes. A break is never *honoured* — a
|
||||
/// single-system, single-page Minimal solve casts off nothing — so a hard break
|
||||
/// (`Required`) is unsatisfied and a soft break (`Preferred`) is a warning. An
|
||||
/// extension `Registered` constraint this solver cannot interpret is likewise
|
||||
/// not claimed satisfied (Chapter 7 §"Behavior Under Unknown Extensions":
|
||||
/// conservative).
|
||||
fn evaluate_constraints(
|
||||
constraints: &[LayoutConstraint],
|
||||
glyphs: &[ResolvedGlyph],
|
||||
) -> (bool, Vec<ConstraintId>) {
|
||||
) -> ConstraintEvaluation {
|
||||
let by_id: BTreeMap<GlyphObjectId, &ResolvedGlyph> = glyphs
|
||||
.iter()
|
||||
.map(|g| (GlyphObjectId(g.provenance.stable_id.0), g))
|
||||
.collect();
|
||||
let mut unsatisfied = Vec::new();
|
||||
let mut soft_violations = Vec::new();
|
||||
for (index, constraint) in constraints.iter().enumerate() {
|
||||
let satisfied = match constraint {
|
||||
let holds = match constraint {
|
||||
LayoutConstraint::NoCollision { a, b } => match (by_id.get(a), by_id.get(b)) {
|
||||
(Some(a), Some(b)) => !overlaps(a, b),
|
||||
// A referenced glyph was dropped (a diagnostic layout): not claimed.
|
||||
|
|
@ -347,15 +392,34 @@ fn evaluate_constraints(
|
|||
Some(g) => within(g, region),
|
||||
None => false,
|
||||
},
|
||||
LayoutConstraint::SystemBreakAt { kind, .. }
|
||||
| LayoutConstraint::PageBreakAt { kind, .. } => matches!(kind, BreakKind::Soft),
|
||||
LayoutConstraint::SystemBreakAt { .. } | LayoutConstraint::PageBreakAt { .. } => false,
|
||||
LayoutConstraint::Registered(_, _) => false,
|
||||
};
|
||||
if !satisfied {
|
||||
unsatisfied.push(ConstraintId(index as u128));
|
||||
if holds {
|
||||
continue;
|
||||
}
|
||||
let id = ConstraintId(index as u128);
|
||||
match constraint.strength() {
|
||||
ConstraintStrength::Required => unsatisfied.push(id),
|
||||
ConstraintStrength::Preferred { .. } => soft_violations.push(SolverWarning {
|
||||
kind: SolverWarningKind::LargeSoftConstraintViolation {
|
||||
constraint: id,
|
||||
// A break preference is binary — honoured or not — so an
|
||||
// unhonoured one is a full (1.0) violation.
|
||||
magnitude: 1.0,
|
||||
},
|
||||
affected_objects: Vec::new(),
|
||||
message: "a preferred (soft) constraint is not honoured by this \
|
||||
single-system Minimal solve"
|
||||
.to_owned(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
(unsatisfied.is_empty(), unsatisfied)
|
||||
ConstraintEvaluation {
|
||||
required_satisfied: unsatisfied.is_empty(),
|
||||
unsatisfied,
|
||||
soft_violations,
|
||||
}
|
||||
}
|
||||
|
||||
/// A resolved glyph's absolute bounding box `[left, bottom, right, top]`.
|
||||
|
|
@ -519,14 +583,23 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_constraint_set_is_vacuously_satisfied() {
|
||||
let report = Engraver.solve(&fixture(), &SolverConfig::default());
|
||||
fn the_pipelines_emitted_constraints_are_satisfied() {
|
||||
// The spacing stage now emits real constraints (no-collision chains,
|
||||
// per-glyph containment) — this is *not* a vacuous empty-set solve. The
|
||||
// collision-aware spacing satisfies every one of them, and the solve
|
||||
// honestly reports the evaluation work it did.
|
||||
let input = fixture();
|
||||
assert!(
|
||||
!input.constraints.is_empty(),
|
||||
"the pipeline declares real constraints"
|
||||
);
|
||||
let report = Engraver.solve(&input, &SolverConfig::default());
|
||||
assert_eq!(report.status, SolveStatus::Solved);
|
||||
assert!(report.satisfied_hard_constraints);
|
||||
assert!(report.unsatisfied_constraints.is_empty());
|
||||
assert_eq!(
|
||||
report.budget_used.constraint_evaluations,
|
||||
fixture().constraints.len() as u64
|
||||
input.constraints.len() as u64
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -583,6 +656,8 @@ mod tests {
|
|||
#[test]
|
||||
fn a_hard_break_cannot_be_honoured_by_single_system_minimal() {
|
||||
use epiphany_layout_ir::{BreakKind, LayoutConstraint};
|
||||
// A hard break maps to ConstraintStrength::Required: a single-system
|
||||
// solve cannot honour it, so the solve is Unsatisfiable.
|
||||
let input = with_constraints(|c| {
|
||||
let slot = c.horizontal_slots[0].id;
|
||||
vec![LayoutConstraint::SystemBreakAt {
|
||||
|
|
@ -594,7 +669,9 @@ mod tests {
|
|||
assert_eq!(report.status, SolveStatus::Unsatisfiable);
|
||||
assert_eq!(report.unsatisfied_constraints.len(), 1);
|
||||
|
||||
// …but a *soft* break imposes no obligation, so it solves.
|
||||
// …but a *soft* break is ConstraintStrength::Preferred: not honouring it
|
||||
// is a soft-violation warning on a valid, renderable layout — a
|
||||
// Preferred violation is a warning, never a failure.
|
||||
let soft = with_constraints(|c| {
|
||||
let slot = c.horizontal_slots[0].id;
|
||||
vec![LayoutConstraint::SystemBreakAt {
|
||||
|
|
@ -602,10 +679,68 @@ mod tests {
|
|||
kind: BreakKind::Soft,
|
||||
}]
|
||||
});
|
||||
assert_eq!(
|
||||
Engraver.solve(&soft, &SolverConfig::default()).status,
|
||||
SolveStatus::Solved
|
||||
let report = Engraver.solve(&soft, &SolverConfig::default());
|
||||
assert_eq!(report.status, SolveStatus::SolvedWithWarnings);
|
||||
assert!(report.status.is_renderable());
|
||||
assert!(
|
||||
report.satisfied_hard_constraints,
|
||||
"a soft-break violation must not flip hard-constraint satisfaction"
|
||||
);
|
||||
assert!(report.unsatisfied_constraints.is_empty());
|
||||
assert!(report.warnings.iter().any(|w| matches!(
|
||||
w.kind,
|
||||
SolverWarningKind::LargeSoftConstraintViolation {
|
||||
constraint: ConstraintId(0),
|
||||
magnitude,
|
||||
} if magnitude == 1.0
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_users_break_flows_to_a_soft_violation_not_a_failure() {
|
||||
// End to end: a user system break on the score graph projects through
|
||||
// the logical stage's break override into a Soft break constraint,
|
||||
// which this single-system solve does not honour — surfacing as a
|
||||
// Preferred-violation warning on a valid, renderable layout.
|
||||
use epiphany_core::generators::valid_score;
|
||||
use epiphany_core::{AnchorOffset, Event, TimeAnchor};
|
||||
let mut score = valid_score(3);
|
||||
let event = score.canvas.regions[0]
|
||||
.staff_instances()
|
||||
.iter()
|
||||
.flat_map(|si| si.voices.iter())
|
||||
.flat_map(|voice| voice.events.iter().copied())
|
||||
.find(|eid| {
|
||||
matches!(score.events.get(*eid), Some(Event::Pitched(p)) if !p.pitches.is_empty())
|
||||
})
|
||||
.expect("valid_score has a pitched event");
|
||||
score.canvas.regions[0]
|
||||
.content
|
||||
.staff_based_mut()
|
||||
.expect("valid_score is staff based")
|
||||
.user_system_breaks
|
||||
.push(TimeAnchor::Event {
|
||||
id: event,
|
||||
offset: AnchorOffset::Zero,
|
||||
});
|
||||
|
||||
let constrained = to_constrained(&to_logical(&score));
|
||||
assert!(
|
||||
constrained
|
||||
.constraints
|
||||
.iter()
|
||||
.any(|c| matches!(c, LayoutConstraint::SystemBreakAt { .. })),
|
||||
"the user break projects into a break constraint"
|
||||
);
|
||||
let report = Engraver.solve(&constrained, &SolverConfig::default());
|
||||
assert_eq!(report.status, SolveStatus::SolvedWithWarnings);
|
||||
assert!(report.status.is_renderable());
|
||||
assert!(report.satisfied_hard_constraints);
|
||||
assert!(report.unsatisfied_constraints.is_empty());
|
||||
assert!(report.warnings.iter().any(|w| matches!(
|
||||
w.kind,
|
||||
SolverWarningKind::LargeSoftConstraintViolation { .. }
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<Self, DecodeError> {
|
||||
let arr: [u8; 2] = bytes
|
||||
.try_into()
|
||||
.map_err(|_| DecodeError::UnexpectedLength {
|
||||
expected: 2,
|
||||
actual: bytes.len(),
|
||||
})?;
|
||||
Ok(ObjectKind(u16::from_le_bytes(arr)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Fixed-width 16-byte little-endian decode shared by the barrier registry-id
|
||||
/// newtypes (mirroring their `to_le_bytes` encode above).
|
||||
macro_rules! barrier_u128_le_decode {
|
||||
($name:ident) => {
|
||||
impl CanonicalDecode for $name {
|
||||
fn decode_canonical(bytes: &[u8]) -> Result<Self, DecodeError> {
|
||||
let arr: [u8; 16] =
|
||||
bytes
|
||||
.try_into()
|
||||
.map_err(|_| DecodeError::UnexpectedLength {
|
||||
expected: 16,
|
||||
actual: bytes.len(),
|
||||
})?;
|
||||
Ok($name(u128::from_le_bytes(arr)))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
barrier_u128_le_decode!(BarrierScopeRegistryId);
|
||||
barrier_u128_le_decode!(BarrierConditionRegistryId);
|
||||
barrier_u128_le_decode!(ExtensionRef);
|
||||
|
||||
type DecodeResult<T> = Result<T, BarrierDecodeError>;
|
||||
|
||||
struct Reader<'a> {
|
||||
bytes: &'a [u8],
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> Reader<'a> {
|
||||
fn new(bytes: &'a [u8]) -> Self {
|
||||
Reader { bytes, pos: 0 }
|
||||
}
|
||||
|
||||
fn take(&mut self, n: usize) -> DecodeResult<&'a [u8]> {
|
||||
let end = self
|
||||
.pos
|
||||
.checked_add(n)
|
||||
.ok_or(BarrierDecodeError::LengthOverflow)?;
|
||||
if end > self.bytes.len() {
|
||||
return Err(BarrierDecodeError::UnexpectedEof);
|
||||
}
|
||||
let out = &self.bytes[self.pos..end];
|
||||
self.pos = end;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn byte(&mut self) -> DecodeResult<u8> {
|
||||
Ok(self.take(1)?[0])
|
||||
}
|
||||
|
||||
/// A `u64` little-endian length/count field (the width `push_u64` writes),
|
||||
/// converted to `usize`.
|
||||
fn len(&mut self) -> DecodeResult<usize> {
|
||||
let raw = u64::from_le_bytes(self.take(8)?.try_into().expect("fixed width"));
|
||||
usize::try_from(raw).map_err(|_| BarrierDecodeError::LengthOverflow)
|
||||
}
|
||||
|
||||
/// A length-prefixed byte field (the form `push_elem`/`push_set` write).
|
||||
fn lp_bytes(&mut self) -> DecodeResult<&'a [u8]> {
|
||||
let len = self.len()?;
|
||||
self.take(len)
|
||||
}
|
||||
|
||||
fn finish(self) -> DecodeResult<()> {
|
||||
if self.pos == self.bytes.len() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(BarrierDecodeError::TrailingBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `decode` over exactly `bytes` — trailing bytes are an error.
|
||||
fn exact<T>(
|
||||
bytes: &[u8],
|
||||
decode: impl FnOnce(&mut Reader<'_>) -> DecodeResult<T>,
|
||||
) -> DecodeResult<T> {
|
||||
let mut reader = Reader::new(bytes);
|
||||
let value = decode(&mut reader)?;
|
||||
reader.finish()?;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// Reads a `push_set`-framed set: a count, then length-prefixed elements that
|
||||
/// MUST be strictly ascending in raw byte order (the sorted, deduplicated form
|
||||
/// the encoder emits). Unsorted or duplicated elements are rejected as
|
||||
/// non-canonical, never re-sorted.
|
||||
fn read_set<'a, T>(
|
||||
reader: &mut Reader<'a>,
|
||||
what: &'static str,
|
||||
mut decode_elem: impl FnMut(&'a [u8]) -> DecodeResult<T>,
|
||||
) -> DecodeResult<Vec<T>> {
|
||||
let count = reader.len()?;
|
||||
let mut values = Vec::with_capacity(count.min(1024));
|
||||
let mut prev: Option<&[u8]> = None;
|
||||
for _ in 0..count {
|
||||
let elem = reader.lp_bytes()?;
|
||||
if let Some(prev) = prev {
|
||||
if prev >= elem {
|
||||
return Err(BarrierDecodeError::NonCanonical(what));
|
||||
}
|
||||
}
|
||||
prev = Some(elem);
|
||||
values.push(decode_elem(elem)?);
|
||||
}
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
/// Streams one `TypedObjectId` (variable width: 2-byte discriminant, then 16
|
||||
/// payload bytes — or 32 for the `Registered` variant, discriminant 27).
|
||||
fn read_typed_object_id(reader: &mut Reader<'_>) -> DecodeResult<TypedObjectId> {
|
||||
let tag_bytes = reader
|
||||
.bytes
|
||||
.get(reader.pos..reader.pos.saturating_add(2))
|
||||
.ok_or(BarrierDecodeError::UnexpectedEof)?;
|
||||
let tag = u16::from_be_bytes(tag_bytes.try_into().expect("two bytes"));
|
||||
let width = if tag == 27 { 34 } else { 18 };
|
||||
TypedObjectId::decode_canonical(reader.take(width)?)
|
||||
.map_err(|_| BarrierDecodeError::InvalidValue("TypedObjectId"))
|
||||
}
|
||||
|
||||
fn read_u128_le(reader: &mut Reader<'_>) -> DecodeResult<u128> {
|
||||
Ok(u128::from_le_bytes(
|
||||
reader.take(16)?.try_into().expect("fixed width"),
|
||||
))
|
||||
}
|
||||
|
||||
/// Streams one 16-byte big-endian graph identifier (the `graph_id!` canonical
|
||||
/// form `RegionId`/`StaffInstanceId`/`AnalysisLayerId` share).
|
||||
fn read_graph_id<T: CanonicalDecode>(
|
||||
reader: &mut Reader<'_>,
|
||||
name: &'static str,
|
||||
) -> DecodeResult<T> {
|
||||
T::decode_canonical(reader.take(16)?).map_err(|_| BarrierDecodeError::InvalidValue(name))
|
||||
}
|
||||
|
||||
fn read_scope(reader: &mut Reader<'_>) -> DecodeResult<BarrierScope> {
|
||||
match reader.byte()? {
|
||||
0 => Ok(BarrierScope::WholeScore),
|
||||
1 => Ok(BarrierScope::Region(read_graph_id(reader, "RegionId")?)),
|
||||
2 => Ok(BarrierScope::StaffInstance(read_graph_id(
|
||||
reader,
|
||||
"StaffInstanceId",
|
||||
)?)),
|
||||
3 => Ok(BarrierScope::AnalysisLayer(read_graph_id(
|
||||
reader,
|
||||
"AnalysisLayerId",
|
||||
)?)),
|
||||
4 => Ok(BarrierScope::ObjectSet(read_set(
|
||||
reader,
|
||||
"object set",
|
||||
|elem| {
|
||||
TypedObjectId::decode_canonical(elem)
|
||||
.map_err(|_| BarrierDecodeError::InvalidValue("TypedObjectId"))
|
||||
},
|
||||
)?)),
|
||||
5 => {
|
||||
let raw = reader.lp_bytes()?;
|
||||
let s = core::str::from_utf8(raw).map_err(|_| BarrierDecodeError::InvalidUtf8)?;
|
||||
// `PitchSpaceId::new` NFC-normalizes; canonical bytes MUST already
|
||||
// be NFC, so a spelling the constructor would change is rejected
|
||||
// rather than silently normalized (re-encoding it would differ).
|
||||
let id = PitchSpaceId::new(s);
|
||||
if id.as_str() != s {
|
||||
return Err(BarrierDecodeError::NotNfc);
|
||||
}
|
||||
Ok(BarrierScope::PitchSpace(id))
|
||||
}
|
||||
6 => Ok(BarrierScope::TuningContext),
|
||||
7 => Ok(BarrierScope::Registered(BarrierScopeRegistryId(
|
||||
read_u128_le(reader)?,
|
||||
))),
|
||||
tag => Err(BarrierDecodeError::InvalidTag {
|
||||
kind: "BarrierScope",
|
||||
tag,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_condition(reader: &mut Reader<'_>, depth: usize) -> DecodeResult<BarrierCondition> {
|
||||
if depth > MAX_CONDITION_DEPTH {
|
||||
return Err(BarrierDecodeError::ConditionTooDeep);
|
||||
}
|
||||
match reader.byte()? {
|
||||
0 => Ok(BarrierCondition::Always),
|
||||
1 => Ok(BarrierCondition::ObjectExists(read_typed_object_id(
|
||||
reader,
|
||||
)?)),
|
||||
2 => Ok(BarrierCondition::ObjectHasExtensionData {
|
||||
object: read_typed_object_id(reader)?,
|
||||
extension: ExtensionRef(read_u128_le(reader)?),
|
||||
}),
|
||||
3 => Ok(BarrierCondition::All(read_condition_list(reader, depth)?)),
|
||||
4 => Ok(BarrierCondition::Any(read_condition_list(reader, depth)?)),
|
||||
5 => {
|
||||
let inner = exact(reader.lp_bytes()?, |r| read_condition(r, depth + 1))?;
|
||||
Ok(BarrierCondition::Not(Box::new(inner)))
|
||||
}
|
||||
6 => Ok(BarrierCondition::Registered(BarrierConditionRegistryId(
|
||||
read_u128_le(reader)?,
|
||||
))),
|
||||
tag => Err(BarrierDecodeError::InvalidTag {
|
||||
kind: "BarrierCondition",
|
||||
tag,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a `push_list`-framed condition list (order-significant — the
|
||||
/// *structure* of an `All`/`Any` tree, not a set).
|
||||
fn read_condition_list(
|
||||
reader: &mut Reader<'_>,
|
||||
depth: usize,
|
||||
) -> DecodeResult<Vec<BarrierCondition>> {
|
||||
let count = reader.len()?;
|
||||
let mut conditions = Vec::with_capacity(count.min(1024));
|
||||
for _ in 0..count {
|
||||
conditions.push(exact(reader.lp_bytes()?, |r| read_condition(r, depth + 1))?);
|
||||
}
|
||||
Ok(conditions)
|
||||
}
|
||||
|
||||
fn read_barrier(reader: &mut Reader<'_>) -> DecodeResult<EditBarrier> {
|
||||
Ok(EditBarrier {
|
||||
scope: read_scope(reader)?,
|
||||
affected_object_kinds: read_set(reader, "affected object kinds", |elem| {
|
||||
ObjectKind::decode_canonical(elem)
|
||||
.map_err(|_| BarrierDecodeError::InvalidValue("ObjectKind"))
|
||||
})?,
|
||||
prohibited_operation_kinds: read_set(reader, "prohibited operation kinds", |elem| {
|
||||
OperationKindTag::decode_canonical(elem).map_err(|err| match (err, elem.first()) {
|
||||
(DecodeError::MalformedDomainTag, Some(&tag)) => BarrierDecodeError::InvalidTag {
|
||||
kind: "OperationKindTag",
|
||||
tag,
|
||||
},
|
||||
_ => BarrierDecodeError::InvalidValue("OperationKindTag"),
|
||||
})
|
||||
})?,
|
||||
condition: read_condition(reader, 1)?,
|
||||
})
|
||||
}
|
||||
|
||||
impl EditBarrier {
|
||||
/// Decodes exactly one barrier from its canonical bytes (the inverse of
|
||||
/// [`CanonicalEncode`]); trailing bytes and every non-canonical form are
|
||||
/// rejected. The manifest blob form is [`decode_edit_barriers`].
|
||||
pub fn decode_canonical_bytes(bytes: &[u8]) -> DecodeResult<EditBarrier> {
|
||||
let barrier = exact(bytes, read_barrier)?;
|
||||
// Belt and braces: canonical decode admits exactly the encoder's image,
|
||||
// so the round-trip must be byte-identical.
|
||||
if barrier.to_canonical_bytes() != bytes {
|
||||
return Err(BarrierDecodeError::NonCanonical("barrier bytes"));
|
||||
}
|
||||
Ok(barrier)
|
||||
}
|
||||
}
|
||||
|
||||
/// Encodes a set of edit barriers to the canonical blob stored in
|
||||
/// `ExtensionDeclaration.edit_barriers`: `push_set` framing (`u64` LE count,
|
||||
/// then per barrier a `u64` LE length prefix and the barrier's canonical
|
||||
/// bytes), sorted ascending by encoded bytes with duplicates removed, so the
|
||||
/// blob is order- and repetition-independent.
|
||||
pub fn encode_edit_barriers(barriers: &[EditBarrier]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
push_set(&mut out, barriers);
|
||||
out
|
||||
}
|
||||
|
||||
/// Decodes an `ExtensionDeclaration.edit_barriers` blob (the inverse of
|
||||
/// [`encode_edit_barriers`]). Rejects unsorted/duplicated barriers, unknown
|
||||
/// discriminants anywhere in the tree, non-NFC pitch-space text, over-deep
|
||||
/// condition trees, and trailing bytes.
|
||||
pub fn decode_edit_barriers(bytes: &[u8]) -> DecodeResult<Vec<EditBarrier>> {
|
||||
let barriers = exact(bytes, |reader| {
|
||||
read_set(
|
||||
reader,
|
||||
"edit-barrier set",
|
||||
EditBarrier::decode_canonical_bytes,
|
||||
)
|
||||
})?;
|
||||
Ok(barriers)
|
||||
}
|
||||
|
||||
/// Encodes a set of object kinds to the canonical blob stored in
|
||||
/// `ExtensionDeclaration.affected_object_kinds` (the same `push_set` framing
|
||||
/// as [`encode_edit_barriers`]; each element is the kind's 2 LE bytes).
|
||||
pub fn encode_affected_object_kinds(kinds: &[ObjectKind]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
push_set(&mut out, kinds);
|
||||
out
|
||||
}
|
||||
|
||||
/// Decodes an `ExtensionDeclaration.affected_object_kinds` blob (the inverse
|
||||
/// of [`encode_affected_object_kinds`]).
|
||||
pub fn decode_affected_object_kinds(bytes: &[u8]) -> DecodeResult<Vec<ObjectKind>> {
|
||||
exact(bytes, |reader| {
|
||||
read_set(reader, "affected-object-kind set", |elem| {
|
||||
ObjectKind::decode_canonical(elem)
|
||||
.map_err(|_| BarrierDecodeError::InvalidValue("ObjectKind"))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -546,6 +945,282 @@ mod tests {
|
|||
assert_eq!(one.to_canonical_bytes(), repeated.to_canonical_bytes());
|
||||
}
|
||||
|
||||
// --- Decode mirrors + the manifest blob codec ---------------------------
|
||||
|
||||
fn u64le(n: u64) -> Vec<u8> {
|
||||
n.to_le_bytes().to_vec()
|
||||
}
|
||||
|
||||
/// `push_set` framing assembled by hand, so the reject tests can produce
|
||||
/// deliberately non-canonical framings the encoder never emits.
|
||||
fn set_blob(elems: &[Vec<u8>]) -> Vec<u8> {
|
||||
let mut out = u64le(elems.len() as u64);
|
||||
for elem in elems {
|
||||
out.extend(u64le(elem.len() as u64));
|
||||
out.extend(elem);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn affected_object_kinds_blob_bytes_are_golden() {
|
||||
// GOLDEN LOCK (provisional canonical form, pending Binary Format
|
||||
// companion ratification): u64 LE count, then per element a u64 LE
|
||||
// length prefix and the ObjectKind's 2 LE bytes, ascending, deduped.
|
||||
let blob = encode_affected_object_kinds(&[ObjectKind(1), ObjectKind(0), ObjectKind(1)]);
|
||||
#[rustfmt::skip]
|
||||
const GOLDEN: [u8; 28] = [
|
||||
2, 0, 0, 0, 0, 0, 0, 0, // count = 2 (the duplicate collapses)
|
||||
2, 0, 0, 0, 0, 0, 0, 0, 0, 0, // len 2, ObjectKind(0)
|
||||
2, 0, 0, 0, 0, 0, 0, 0, 1, 0, // len 2, ObjectKind(1)
|
||||
];
|
||||
assert_eq!(blob, GOLDEN);
|
||||
assert_eq!(
|
||||
decode_affected_object_kinds(&GOLDEN).unwrap(),
|
||||
vec![ObjectKind(0), ObjectKind(1)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_barriers_blob_bytes_are_golden() {
|
||||
// GOLDEN LOCK (provisional canonical form, pending Binary Format
|
||||
// companion ratification): the set framing wraps each barrier's
|
||||
// canonical bytes — scope, affected-kind set, prohibited-tag set,
|
||||
// condition, in that order.
|
||||
let barrier = EditBarrier {
|
||||
scope: BarrierScope::WholeScore,
|
||||
affected_object_kinds: vec![ObjectKind(1)],
|
||||
prohibited_operation_kinds: vec![OperationKindTag::DeleteEvent],
|
||||
condition: BarrierCondition::Always,
|
||||
};
|
||||
let blob = encode_edit_barriers(std::slice::from_ref(&barrier));
|
||||
#[rustfmt::skip]
|
||||
const GOLDEN: [u8; 53] = [
|
||||
1, 0, 0, 0, 0, 0, 0, 0, // set count = 1
|
||||
37, 0, 0, 0, 0, 0, 0, 0, // barrier byte length
|
||||
0, // scope: WholeScore
|
||||
1, 0, 0, 0, 0, 0, 0, 0, // affected kinds: count = 1
|
||||
2, 0, 0, 0, 0, 0, 0, 0, 1, 0, // len 2, ObjectKind(1)
|
||||
1, 0, 0, 0, 0, 0, 0, 0, // prohibited tags: count = 1
|
||||
1, 0, 0, 0, 0, 0, 0, 0, 1, // len 1, DeleteEvent (tag 1)
|
||||
0, // condition: Always
|
||||
];
|
||||
assert_eq!(blob, GOLDEN);
|
||||
assert_eq!(decode_edit_barriers(&GOLDEN).unwrap(), vec![barrier]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_scope_and_condition_variant_round_trips_byte_identically() {
|
||||
use epiphany_core::{AnalysisLayerId, StaffInstanceId};
|
||||
let scopes = vec![
|
||||
BarrierScope::WholeScore,
|
||||
BarrierScope::Region(RegionId::from_raw(7)),
|
||||
BarrierScope::StaffInstance(StaffInstanceId::from_raw(8)),
|
||||
BarrierScope::AnalysisLayer(AnalysisLayerId::from_raw(9)),
|
||||
// In canonical (ascending) order: decode admits only the canonical
|
||||
// image, so the round-trip compares structurally equal.
|
||||
BarrierScope::ObjectSet(vec![ev(1), ev(2)]),
|
||||
BarrierScope::PitchSpace(PitchSpaceId::new("cmn-12")),
|
||||
BarrierScope::TuningContext,
|
||||
BarrierScope::Registered(BarrierScopeRegistryId(10)),
|
||||
];
|
||||
let conditions = vec![
|
||||
BarrierCondition::Always,
|
||||
BarrierCondition::ObjectExists(ev(3)),
|
||||
BarrierCondition::ObjectHasExtensionData {
|
||||
object: ev(4),
|
||||
extension: ExtensionRef(11),
|
||||
},
|
||||
BarrierCondition::All(vec![
|
||||
BarrierCondition::Always,
|
||||
BarrierCondition::ObjectExists(ev(5)),
|
||||
]),
|
||||
BarrierCondition::Any(vec![BarrierCondition::Not(Box::new(
|
||||
BarrierCondition::Always,
|
||||
))]),
|
||||
BarrierCondition::Not(Box::new(BarrierCondition::Registered(
|
||||
BarrierConditionRegistryId(12),
|
||||
))),
|
||||
BarrierCondition::Registered(BarrierConditionRegistryId(13)),
|
||||
];
|
||||
let barriers: Vec<EditBarrier> = scopes
|
||||
.into_iter()
|
||||
.zip(conditions.into_iter().cycle())
|
||||
.map(|(scope, condition)| EditBarrier {
|
||||
scope,
|
||||
affected_object_kinds: vec![ObjectKind(0), ObjectKind(4)],
|
||||
prohibited_operation_kinds: vec![
|
||||
OperationKindTag::DeleteEvent,
|
||||
OperationKindTag::Registered(epiphany_ops::OperationKindRegistryId(99)),
|
||||
],
|
||||
condition,
|
||||
})
|
||||
.collect();
|
||||
// Single-barrier decode mirrors encode exactly.
|
||||
for barrier in &barriers {
|
||||
let bytes = barrier.to_canonical_bytes();
|
||||
let decoded = EditBarrier::decode_canonical_bytes(&bytes).unwrap();
|
||||
assert_eq!(&decoded, barrier);
|
||||
assert_eq!(decoded.to_canonical_bytes(), bytes);
|
||||
}
|
||||
// The blob is a canonical set: order- and repetition-independent, and
|
||||
// decode → re-encode is byte-identical.
|
||||
let blob = encode_edit_barriers(&barriers);
|
||||
let mut reversed: Vec<EditBarrier> = barriers.iter().rev().cloned().collect();
|
||||
reversed.push(barriers[0].clone());
|
||||
assert_eq!(encode_edit_barriers(&reversed), blob);
|
||||
let decoded = decode_edit_barriers(&blob).unwrap();
|
||||
assert_eq!(decoded.len(), barriers.len());
|
||||
assert_eq!(encode_edit_barriers(&decoded), blob);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_rejects_unknown_discriminants() {
|
||||
// Scope tag 8 is one past the vocabulary.
|
||||
let mut bytes = vec![8u8];
|
||||
bytes.extend(set_blob(&[]));
|
||||
bytes.extend(set_blob(&[]));
|
||||
bytes.push(0);
|
||||
assert_eq!(
|
||||
EditBarrier::decode_canonical_bytes(&bytes),
|
||||
Err(BarrierDecodeError::InvalidTag {
|
||||
kind: "BarrierScope",
|
||||
tag: 8
|
||||
})
|
||||
);
|
||||
// Condition tag 7 is one past the vocabulary.
|
||||
let mut bytes = vec![0u8];
|
||||
bytes.extend(set_blob(&[]));
|
||||
bytes.extend(set_blob(&[]));
|
||||
bytes.push(7);
|
||||
assert_eq!(
|
||||
EditBarrier::decode_canonical_bytes(&bytes),
|
||||
Err(BarrierDecodeError::InvalidTag {
|
||||
kind: "BarrierCondition",
|
||||
tag: 7
|
||||
})
|
||||
);
|
||||
// Operation-kind tag 24 is one past the v1 vocabulary.
|
||||
let mut bytes = vec![0u8];
|
||||
bytes.extend(set_blob(&[]));
|
||||
bytes.extend(set_blob(&[vec![24u8]]));
|
||||
bytes.push(0);
|
||||
assert_eq!(
|
||||
EditBarrier::decode_canonical_bytes(&bytes),
|
||||
Err(BarrierDecodeError::InvalidTag {
|
||||
kind: "OperationKindTag",
|
||||
tag: 24
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_rejects_non_nfc_pitch_space_text() {
|
||||
// scope: PitchSpace(tag 5) carrying a decomposed "café" — canonically
|
||||
// equivalent to the NFC form but not byte-canonical. Rejected, never
|
||||
// silently normalized.
|
||||
let decomposed = "cafe\u{0301}";
|
||||
let mut bytes = vec![5u8];
|
||||
bytes.extend(u64le(decomposed.len() as u64));
|
||||
bytes.extend(decomposed.as_bytes());
|
||||
bytes.extend(set_blob(&[]));
|
||||
bytes.extend(set_blob(&[]));
|
||||
bytes.push(0);
|
||||
assert_eq!(
|
||||
EditBarrier::decode_canonical_bytes(&bytes),
|
||||
Err(BarrierDecodeError::NotNfc)
|
||||
);
|
||||
// The NFC spelling of the same name decodes (and re-encodes) fine.
|
||||
let nfc = EditBarrier {
|
||||
scope: BarrierScope::PitchSpace(PitchSpaceId::new("caf\u{00e9}")),
|
||||
affected_object_kinds: vec![],
|
||||
prohibited_operation_kinds: vec![],
|
||||
condition: BarrierCondition::Always,
|
||||
};
|
||||
let round = EditBarrier::decode_canonical_bytes(&nfc.to_canonical_bytes()).unwrap();
|
||||
assert_eq!(round, nfc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_rejects_unsorted_and_duplicated_sets_and_trailing_bytes() {
|
||||
// Kinds out of ascending byte order.
|
||||
let unsorted = {
|
||||
let mut out = vec![0u8];
|
||||
out.extend(set_blob(&[vec![1, 0], vec![0, 0]]));
|
||||
out.extend(set_blob(&[]));
|
||||
out.push(0);
|
||||
out
|
||||
};
|
||||
assert_eq!(
|
||||
EditBarrier::decode_canonical_bytes(&unsorted),
|
||||
Err(BarrierDecodeError::NonCanonical("affected object kinds"))
|
||||
);
|
||||
// A duplicated element (sorted but not strictly ascending).
|
||||
let duplicated = {
|
||||
let mut out = vec![0u8];
|
||||
out.extend(set_blob(&[vec![0, 0], vec![0, 0]]));
|
||||
out.extend(set_blob(&[]));
|
||||
out.push(0);
|
||||
out
|
||||
};
|
||||
assert_eq!(
|
||||
EditBarrier::decode_canonical_bytes(&duplicated),
|
||||
Err(BarrierDecodeError::NonCanonical("affected object kinds"))
|
||||
);
|
||||
// Trailing bytes after a complete blob / a complete barrier.
|
||||
let blob = encode_edit_barriers(&[EditBarrier {
|
||||
scope: BarrierScope::WholeScore,
|
||||
affected_object_kinds: vec![],
|
||||
prohibited_operation_kinds: vec![],
|
||||
condition: BarrierCondition::Always,
|
||||
}]);
|
||||
let mut trailing = blob.clone();
|
||||
trailing.push(0);
|
||||
assert_eq!(
|
||||
decode_edit_barriers(&trailing),
|
||||
Err(BarrierDecodeError::TrailingBytes)
|
||||
);
|
||||
// Truncation is an error, at every prefix length.
|
||||
for cut in 0..blob.len() {
|
||||
assert!(decode_edit_barriers(&blob[..cut]).is_err());
|
||||
}
|
||||
let mut kinds_trailing = encode_affected_object_kinds(&[ObjectKind(3)]);
|
||||
kinds_trailing.push(0);
|
||||
assert_eq!(
|
||||
decode_affected_object_kinds(&kinds_trailing),
|
||||
Err(BarrierDecodeError::TrailingBytes)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_bounds_condition_recursion_depth() {
|
||||
let nested_nots = |n: usize| {
|
||||
let mut condition = BarrierCondition::Always;
|
||||
for _ in 0..n {
|
||||
condition = BarrierCondition::Not(Box::new(condition));
|
||||
}
|
||||
EditBarrier {
|
||||
scope: BarrierScope::WholeScore,
|
||||
affected_object_kinds: vec![],
|
||||
prohibited_operation_kinds: vec![],
|
||||
condition,
|
||||
}
|
||||
};
|
||||
// MAX_CONDITION_DEPTH - 1 wrappers put the innermost leaf exactly at
|
||||
// the bound: accepted.
|
||||
let at_bound = nested_nots(MAX_CONDITION_DEPTH - 1);
|
||||
assert_eq!(
|
||||
EditBarrier::decode_canonical_bytes(&at_bound.to_canonical_bytes()).unwrap(),
|
||||
at_bound
|
||||
);
|
||||
// One wrapper more nests past the bound: rejected.
|
||||
let past_bound = nested_nots(MAX_CONDITION_DEPTH);
|
||||
assert_eq!(
|
||||
EditBarrier::decode_canonical_bytes(&past_bound.to_canonical_bytes()),
|
||||
Err(BarrierDecodeError::ConditionTooDeep)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_encoding_is_set_order_independent() {
|
||||
let mk = |kinds: Vec<OperationKindTag>| EditBarrier {
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ use std::cmp::Ordering;
|
|||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use epiphany_core::{
|
||||
Clef, EventId, KeySignature, MusicalDuration, NoteValue, PitchId, PitchSpelling,
|
||||
SpellingNominal, StaffId, TypedObjectId, WallClockTime,
|
||||
Clef, EventId, KeySignature, MeasureId, MeasurePosition, MusicalDuration, NoteValue, PitchId,
|
||||
PitchSpelling, SpellingNominal, StaffId, TimeAnchor, TypedObjectId, WallClockTime,
|
||||
};
|
||||
use epiphany_determinism::{DomainTag, Preimage};
|
||||
|
||||
|
|
@ -22,18 +22,18 @@ use crate::engrave_theory::{
|
|||
accidental_glyph, clef_glyph, has_stem, key_signature, notehead_glyph, rest_glyph,
|
||||
staff_position, KeyAccidental, StaffStep,
|
||||
};
|
||||
use crate::engraving::EngravingDecision;
|
||||
use crate::engraving::{EngravingDecision, OverrideKind, OverridePriority, OverrideTarget};
|
||||
use crate::glyph::{metrics, BravuraCatalog, GlyphCatalog, GlyphCatalogIdentity, GlyphReference};
|
||||
use crate::logical::{
|
||||
BarlineKind, LayoutContent, LogicalLayoutIR, PlacedClef, PlacedKeySignature, ScoreVersion,
|
||||
StaffContent,
|
||||
apply_offset, BarlineKind, LayoutContent, LogicalLayoutIR, PlacedClef, PlacedKeySignature,
|
||||
ScoreVersion, StaffContent,
|
||||
};
|
||||
use crate::provenance::{
|
||||
manifestation_layout_id, LayoutObjectId, Provenance, SynthesisInstanceKey, SynthesisKind,
|
||||
SynthesisRegistryId,
|
||||
};
|
||||
use crate::solver::SpringSlotId;
|
||||
use crate::spatial::{BoundingBox, Point, Rect, StaffSpace};
|
||||
use crate::solver::{ConstraintStrength, SpringSlotId};
|
||||
use crate::spatial::{BoundingBox, Point, Rect, Size2D, StaffSpace};
|
||||
use crate::time_axis::{time_cmp, SlotPlacement, TimeAxisModel, TimePoint};
|
||||
use crate::vertical_band::{inter_staff_gap_id, VerticalBand, VerticalBandId};
|
||||
|
||||
|
|
@ -208,6 +208,47 @@ pub enum LayoutConstraint {
|
|||
Registered(ConstraintRegistryId, ConstraintParameters),
|
||||
}
|
||||
|
||||
impl LayoutConstraint {
|
||||
/// The strength this constraint binds the solver with (Chapter 9 §"Strength
|
||||
/// Levels": [`ConstraintStrength`]).
|
||||
///
|
||||
/// The spec's `LayoutConstraint` enum carries no strength field, and the
|
||||
/// "normalized form" Chapter 9 says the solver consumes does not specify how
|
||||
/// strength attaches to a constraint instance (a genuine spec gap — see
|
||||
/// DECISIONS.md), so v0 attaches strength **by rule** rather than widening
|
||||
/// the IR shape: a break constraint's own [`BreakKind`] is its strength
|
||||
/// (`Hard` → `Required`, `Soft` → `Preferred` at the default weight), the
|
||||
/// geometric constraints (no-collision, alignment, containment) are hard
|
||||
/// engraving obligations (`Required`), and a `Registered` extension
|
||||
/// constraint is conservatively `Required` — an obligation a solver cannot
|
||||
/// verify must not be silently demoted (Chapter 9: a solver MUST NOT treat
|
||||
/// `Required` as `Preferred`).
|
||||
pub fn strength(&self) -> ConstraintStrength {
|
||||
match self {
|
||||
LayoutConstraint::SystemBreakAt {
|
||||
kind: BreakKind::Soft,
|
||||
..
|
||||
}
|
||||
| LayoutConstraint::PageBreakAt {
|
||||
kind: BreakKind::Soft,
|
||||
..
|
||||
} => ConstraintStrength::Preferred { weight: 1.0 },
|
||||
LayoutConstraint::NoCollision { .. }
|
||||
| LayoutConstraint::Align { .. }
|
||||
| LayoutConstraint::PositionWithin { .. }
|
||||
| LayoutConstraint::SystemBreakAt {
|
||||
kind: BreakKind::Hard,
|
||||
..
|
||||
}
|
||||
| LayoutConstraint::PageBreakAt {
|
||||
kind: BreakKind::Hard,
|
||||
..
|
||||
}
|
||||
| LayoutConstraint::Registered(_, _) => ConstraintStrength::Required,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A structural defect in [`ConstrainedLayoutIR`] that prevents a solver from
|
||||
/// treating the input as a valid constraint problem.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
|
|
@ -455,6 +496,17 @@ const KEY_ACC_X: f32 = 0.9; // x advance per key-signature accidental
|
|||
const TIME_SIG_X: f32 = 0.5; // a time signature sits this far right of its barline
|
||||
const TIME_DIGIT_X: f32 = 0.8; // x advance per time-signature digit
|
||||
|
||||
/// The horizontal half-reach of an emitted `PositionWithin` region, in staff
|
||||
/// spaces. v0 performs no casting-off, so a region imposes no *horizontal*
|
||||
/// bound on its glyphs — a conformant solver may re-space columns freely along
|
||||
/// the open canvas. The containment obligation v0 can honestly state is the
|
||||
/// **vertical** envelope (which the spacing pass computes from the very glyph
|
||||
/// geometry the solvers preserve), so the emitted rect pins that envelope and
|
||||
/// leaves the horizontal span at canvas scale: wide enough for any plausible
|
||||
/// re-spacing, finite because the validator rejects non-finite constraint
|
||||
/// regions.
|
||||
const POSITION_WITHIN_X_REACH: f32 = 1.0e6;
|
||||
|
||||
/// The registry id for the engraver's **structural-line synthesis** (staff
|
||||
/// lines). The normative [`SynthesisKind`] set names *musical* synthesized
|
||||
/// objects (cancellation accidentals, generated rests, …) but no purely visual
|
||||
|
|
@ -530,6 +582,7 @@ pub fn try_to_constrained(
|
|||
let mut diagnostics = Vec::new();
|
||||
let mut vertical_bands = Vec::new();
|
||||
let mut horizontal_slots = Vec::new();
|
||||
let mut constraints = Vec::new();
|
||||
let mut constrained_regions = Vec::new();
|
||||
// Regions tile left-to-right; this advances by each region's width so all
|
||||
// coordinates stay globally monotonic (the solver's coordinate remap relies
|
||||
|
|
@ -807,6 +860,9 @@ pub fn try_to_constrained(
|
|||
)
|
||||
.collect();
|
||||
|
||||
// Where this region's glyphs begin in the global vector, so constraint
|
||||
// emission below can see exactly the glyphs pass 2 produced for it.
|
||||
let region_glyph_start = glyphs.len();
|
||||
let mut emit = Emit {
|
||||
glyphs: &mut glyphs,
|
||||
strokes: &mut strokes,
|
||||
|
|
@ -1156,6 +1212,144 @@ pub fn try_to_constrained(
|
|||
}
|
||||
}
|
||||
|
||||
// --- Constraint emission (Chapter 7 §"Pipeline Overview": the spacing
|
||||
// pass "build[s] collision constraints"). Everything emitted here is
|
||||
// satisfiable on well-formed input by construction — the source layout
|
||||
// separates columns collision-free and a conformant re-spacing keeps
|
||||
// them so — and the order is deterministic: per region, no-collision
|
||||
// pairs (staff emission order, then column x / glyph id), containment
|
||||
// (glyph stable-id order), then projected breaks (override order).
|
||||
let region_glyph_objects = &glyphs[region_glyph_start..];
|
||||
let glyph_by_id: BTreeMap<GlyphObjectId, &GlyphObject> = region_glyph_objects
|
||||
.iter()
|
||||
.map(|glyph| (glyph.id(), glyph))
|
||||
.collect();
|
||||
|
||||
// NoCollision between *successive notehead columns* within each staff:
|
||||
// adjacent pairs in (column x, id) order, one linear chain per staff,
|
||||
// not O(n²). Chord members share a column slot — a second or unison may
|
||||
// genuinely overlap by design — so only cross-column neighbours carry
|
||||
// the obligation.
|
||||
for staff in &staves_in_order {
|
||||
let mut heads: Vec<&GlyphObject> = staff_members
|
||||
.get(staff)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|id| glyph_by_id.get(id).copied())
|
||||
.filter(|glyph| glyph.glyph.as_str().starts_with("notehead"))
|
||||
.collect();
|
||||
heads.sort_by(|a, b| {
|
||||
a.baseline
|
||||
.x
|
||||
.0
|
||||
.total_cmp(&b.baseline.x.0)
|
||||
.then_with(|| a.id().cmp(&b.id()))
|
||||
});
|
||||
for pair in heads.windows(2) {
|
||||
if pair[0].horizontal_slot != pair[1].horizontal_slot {
|
||||
constraints.push(LayoutConstraint::NoCollision {
|
||||
a: pair[0].id(),
|
||||
b: pair[1].id(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PositionWithin: every glyph must stay inside its owning region's
|
||||
// envelope. The vertical extent is the exact envelope of the region's
|
||||
// own glyph boxes (both v0 solvers preserve glyph `y` verbatim, so this
|
||||
// is a real obligation a vertical pass must renegotiate); the
|
||||
// horizontal span is the open v0 canvas (see
|
||||
// [`POSITION_WITHIN_X_REACH`]).
|
||||
if !region_glyph_objects.is_empty() {
|
||||
let mut bottom = f32::INFINITY;
|
||||
let mut top = f32::NEG_INFINITY;
|
||||
for glyph in region_glyph_objects {
|
||||
bottom = bottom.min(glyph.baseline.y.0 + glyph.bounding_box.bottom.0);
|
||||
top = top.max(glyph.baseline.y.0 + glyph.bounding_box.top.0);
|
||||
}
|
||||
let envelope = Rect {
|
||||
origin: Point::new(-POSITION_WITHIN_X_REACH, bottom),
|
||||
size: Size2D {
|
||||
width: StaffSpace(2.0 * POSITION_WITHIN_X_REACH),
|
||||
height: StaffSpace(top - bottom),
|
||||
},
|
||||
};
|
||||
let mut ids: Vec<GlyphObjectId> = glyph_by_id.keys().copied().collect();
|
||||
ids.sort();
|
||||
for glyph in ids {
|
||||
constraints.push(LayoutConstraint::PositionWithin {
|
||||
glyph,
|
||||
region: envelope,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Projected break overrides (the logical stage's `SystemBreak` /
|
||||
// `PageBreak` engraving overrides, Chapter 7 §"Engraving Overrides")
|
||||
// become break constraints on the spring slot that carries the break
|
||||
// anchor's onset — the barline column at that time when one exists
|
||||
// (a break belongs at the boundary), else the note column. An anchor
|
||||
// no realized column represents — an event or measure outside this
|
||||
// region, a measure *end* (Minimal resolves measure starts only), a
|
||||
// region edge, or a column no glyph landed in — is skipped silently:
|
||||
// there is no slot for a solver to break at.
|
||||
let mut event_onsets: BTreeMap<EventId, TimePoint> = BTreeMap::new();
|
||||
let mut measure_starts: BTreeMap<MeasureId, TimePoint> = BTreeMap::new();
|
||||
for object in ®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<EventId, TimePoint>,
|
||||
measure_starts: &BTreeMap<MeasureId, TimePoint>,
|
||||
) -> Option<TimePoint> {
|
||||
match anchor {
|
||||
TimeAnchor::WallClock { time } => Some(TimePoint::WallClock(*time)),
|
||||
TimeAnchor::Event { id, offset } => apply_offset(event_onsets.get(id)?.clone(), offset),
|
||||
TimeAnchor::Measure {
|
||||
id,
|
||||
position: MeasurePosition::Start,
|
||||
offset,
|
||||
} => apply_offset(measure_starts.get(id)?.clone(), offset),
|
||||
TimeAnchor::Measure { .. } | TimeAnchor::Region { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The column a measure's barline occupies: the final barline closes the region
|
||||
/// at the right; an interior/region-end barline sits before its measure's notes.
|
||||
fn measure_column(measure: &crate::logical::MeasureContent) -> ColumnKey {
|
||||
|
|
@ -1700,6 +1918,191 @@ mod tests {
|
|||
use epiphany_core::generators::valid_score_rich;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[test]
|
||||
fn constraint_strength_attaches_by_rule() {
|
||||
// The spec's constraint enum carries no strength field, so strength is
|
||||
// a rule over the constraint's own shape (Chapter 9 §"Strength Levels").
|
||||
let glyph = GlyphObjectId(1);
|
||||
let slot = SpringSlotId(2);
|
||||
let required = [
|
||||
LayoutConstraint::NoCollision { a: glyph, b: glyph },
|
||||
LayoutConstraint::Align {
|
||||
a: glyph,
|
||||
b: glyph,
|
||||
axis: Axis::Vertical,
|
||||
},
|
||||
LayoutConstraint::PositionWithin {
|
||||
glyph,
|
||||
region: Rect {
|
||||
origin: Point::ORIGIN,
|
||||
size: Size2D::default(),
|
||||
},
|
||||
},
|
||||
LayoutConstraint::SystemBreakAt {
|
||||
slot,
|
||||
kind: BreakKind::Hard,
|
||||
},
|
||||
LayoutConstraint::PageBreakAt {
|
||||
slot,
|
||||
kind: BreakKind::Hard,
|
||||
},
|
||||
// Conservative: an unverifiable extension obligation is never demoted.
|
||||
LayoutConstraint::Registered(ConstraintRegistryId(3), ConstraintParameters::default()),
|
||||
];
|
||||
for constraint in required {
|
||||
assert_eq!(constraint.strength(), ConstraintStrength::Required);
|
||||
}
|
||||
// A soft break is a preference at the default weight.
|
||||
for soft in [
|
||||
LayoutConstraint::SystemBreakAt {
|
||||
slot,
|
||||
kind: BreakKind::Soft,
|
||||
},
|
||||
LayoutConstraint::PageBreakAt {
|
||||
slot,
|
||||
kind: BreakKind::Soft,
|
||||
},
|
||||
] {
|
||||
assert_eq!(
|
||||
soft.strength(),
|
||||
ConstraintStrength::Preferred { weight: 1.0 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constraint_emission_is_deterministic_and_principled() {
|
||||
let logical = to_logical(&valid_score_rich(11));
|
||||
let a = try_to_constrained(&logical).expect("well-formed logical IR");
|
||||
let b = try_to_constrained(&logical).expect("well-formed logical IR");
|
||||
assert_eq!(
|
||||
a.constraints, b.constraints,
|
||||
"two runs emit identical constraint vectors"
|
||||
);
|
||||
assert!(!a.constraints.is_empty(), "the pipeline emits constraints");
|
||||
// Every constraint references real glyphs/slots and finite geometry.
|
||||
assert!(a.validate().is_ok());
|
||||
|
||||
// Containment: one PositionWithin per glyph, against its region envelope.
|
||||
let contained = a
|
||||
.constraints
|
||||
.iter()
|
||||
.filter(|c| matches!(c, LayoutConstraint::PositionWithin { .. }))
|
||||
.count();
|
||||
assert_eq!(contained, a.glyphs.len());
|
||||
|
||||
// No-collision: a linear chain over successive notehead columns, never
|
||||
// the O(n²) all-pairs closure.
|
||||
let pairs = a
|
||||
.constraints
|
||||
.iter()
|
||||
.filter(|c| matches!(c, LayoutConstraint::NoCollision { .. }))
|
||||
.count();
|
||||
let noteheads = a
|
||||
.glyphs
|
||||
.iter()
|
||||
.filter(|g| g.glyph.as_str().starts_with("notehead"))
|
||||
.count();
|
||||
assert!(pairs > 0, "successive noteheads earn no-collision pairs");
|
||||
assert!(pairs < noteheads, "the chain is linear in the noteheads");
|
||||
// Every no-collision endpoint is a notehead in a distinct column slot.
|
||||
let by_id: BTreeMap<GlyphObjectId, &GlyphObject> =
|
||||
a.glyphs.iter().map(|g| (g.id(), g)).collect();
|
||||
for constraint in &a.constraints {
|
||||
if let LayoutConstraint::NoCollision {
|
||||
a: first,
|
||||
b: second,
|
||||
} = constraint
|
||||
{
|
||||
let (first, second) = (by_id[first], by_id[second]);
|
||||
assert!(first.glyph.as_str().starts_with("notehead"));
|
||||
assert!(second.glyph.as_str().starts_with("notehead"));
|
||||
assert_ne!(first.horizontal_slot, second.horizontal_slot);
|
||||
}
|
||||
}
|
||||
// No break constraints without projected break overrides.
|
||||
assert!(!a.constraints.iter().any(|c| matches!(
|
||||
c,
|
||||
LayoutConstraint::SystemBreakAt { .. } | LayoutConstraint::PageBreakAt { .. }
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_break_overrides_become_soft_break_constraints() {
|
||||
use epiphany_core::generators::valid_score;
|
||||
use epiphany_core::{AnchorOffset, Event, RegionEdge, TimeAnchor};
|
||||
|
||||
let mut score = valid_score(3);
|
||||
let region_id = score.canvas.regions[0].id;
|
||||
// A pitched event in region 0 whose onset column is realized (it draws
|
||||
// noteheads), so the break has a spring slot to land on.
|
||||
let event = score.canvas.regions[0]
|
||||
.staff_instances()
|
||||
.iter()
|
||||
.flat_map(|si| si.voices.iter())
|
||||
.flat_map(|voice| voice.events.iter().copied())
|
||||
.find(|eid| {
|
||||
matches!(score.events.get(*eid), Some(Event::Pitched(p)) if !p.pitches.is_empty())
|
||||
})
|
||||
.expect("valid_score has a pitched event");
|
||||
let anchor = TimeAnchor::Event {
|
||||
id: event,
|
||||
offset: AnchorOffset::Zero,
|
||||
};
|
||||
let content = score.canvas.regions[0]
|
||||
.content
|
||||
.staff_based_mut()
|
||||
.expect("valid_score is staff based");
|
||||
content.user_system_breaks.push(anchor.clone());
|
||||
content.user_page_breaks.push(anchor);
|
||||
// An anchor no spacing column represents — a region edge — is skipped
|
||||
// silently rather than mis-assigned to some column.
|
||||
content.user_system_breaks.push(TimeAnchor::Region {
|
||||
id: region_id,
|
||||
edge: RegionEdge::Start,
|
||||
offset: AnchorOffset::Zero,
|
||||
});
|
||||
|
||||
let constrained = to_constrained(&to_logical(&score));
|
||||
assert!(constrained.validate().is_ok());
|
||||
let system_breaks: Vec<&LayoutConstraint> = constrained
|
||||
.constraints
|
||||
.iter()
|
||||
.filter(|c| matches!(c, LayoutConstraint::SystemBreakAt { .. }))
|
||||
.collect();
|
||||
let page_breaks: Vec<&LayoutConstraint> = constrained
|
||||
.constraints
|
||||
.iter()
|
||||
.filter(|c| matches!(c, LayoutConstraint::PageBreakAt { .. }))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
system_breaks.len(),
|
||||
1,
|
||||
"the event-anchored break lands; the region-edge one is skipped"
|
||||
);
|
||||
assert_eq!(page_breaks.len(), 1);
|
||||
for projected in system_breaks.iter().chain(&page_breaks) {
|
||||
let (LayoutConstraint::SystemBreakAt { slot, kind }
|
||||
| LayoutConstraint::PageBreakAt { slot, kind }) = projected
|
||||
else {
|
||||
unreachable!("filtered to break constraints");
|
||||
};
|
||||
// A Soft override projects a Soft break — a Preferred obligation.
|
||||
assert_eq!(*kind, BreakKind::Soft);
|
||||
assert_eq!(
|
||||
projected.strength(),
|
||||
ConstraintStrength::Preferred { weight: 1.0 }
|
||||
);
|
||||
// The slot is the event's own (realized) onset column.
|
||||
let slot = constrained
|
||||
.horizontal_slots
|
||||
.iter()
|
||||
.find(|s| s.id == *slot)
|
||||
.expect("break constraints name realized slots");
|
||||
assert!(!slot.members.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ledger_steps_cover_only_lines_outside_the_staff() {
|
||||
// Within the five-line staff (steps 0..=8) and one space just outside: none.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -17,15 +17,17 @@ use std::collections::{BTreeMap, BTreeSet};
|
|||
|
||||
use epiphany_core::prepass::{derive_annotations, DerivedAnnotations, PrePassProfile};
|
||||
use epiphany_core::{
|
||||
AleatoricAnchoringDiscipline, AnchorOffset, AnnotationAnchor, Clef, CoordinateDiscipline,
|
||||
Event, EventId, EventPosition, KeySignature, MeasurePosition, MusicalDuration, MusicalPosition,
|
||||
NotatedComponent, PitchId, PitchSpelling, Region, RegionEdge, RegionId, RegionTimeModel, Score,
|
||||
StaffId, StaffPosition, TimeAnchor, TimeSignatureDisplay, TupletId, TupletRatio, TypedObjectId,
|
||||
WallClockTime,
|
||||
AleatoricAnchoringDiscipline, AnchorOffset, AnnotationAnchor, CanonicalValue, Clef,
|
||||
CoordinateDiscipline, Event, EventId, EventPosition, KeySignature, MeasurePosition,
|
||||
MusicalDuration, MusicalPosition, NotatedComponent, PitchId, PitchSpelling, Region, RegionEdge,
|
||||
RegionId, RegionTimeModel, Score, StaffId, StaffPosition, TimeAnchor, TimeSignatureDisplay,
|
||||
TupletId, TupletRatio, TypedObjectId, WallClockTime,
|
||||
};
|
||||
use epiphany_determinism::{DomainTag, Preimage};
|
||||
|
||||
use crate::engraving::{EngravingDecision, EngravingDecisionKind, EngravingOverride};
|
||||
use crate::engraving::{
|
||||
DecisionSource, EngravingDecision, EngravingDecisionKind, EngravingOverride, OverrideKind,
|
||||
};
|
||||
use crate::provenance::{LayoutObjectId, Provenance};
|
||||
use crate::spatial::Transform2D;
|
||||
use crate::time_axis::{time_axis_of, TimeAxisModel, TimePoint};
|
||||
|
|
@ -367,8 +369,12 @@ pub struct LogicalLayoutIR {
|
|||
/// Engraving decisions made during the engraving pass (Chapter 7
|
||||
/// §"Engraving Decisions"), carried forward through the pipeline.
|
||||
pub engraving_decisions: Vec<EngravingDecision>,
|
||||
/// User engraving overrides projected from the score graph. Agent B's
|
||||
/// current graph exposes no override registry, so the projection is empty.
|
||||
/// User engraving overrides projected from the score graph: each region's
|
||||
/// authoritative `user_system_breaks` / `user_page_breaks` lists (Chapter 5
|
||||
/// §"Staff-Based Content") become Soft, `Internal`-origin break overrides
|
||||
/// targeting the owning region, ordered by (region id, kind, anchor
|
||||
/// canonical bytes). Each carries a paired [`EngravingDecision`] with
|
||||
/// [`DecisionSource::UserOverride`] in `engraving_decisions`.
|
||||
pub overrides: Vec<EngravingOverride>,
|
||||
/// Objects spanning two or more layout regions.
|
||||
pub cross_region: Vec<CrossRegionObject>,
|
||||
|
|
@ -394,6 +400,9 @@ pub struct LogicalLayoutIR {
|
|||
pub fn to_logical(score: &Score) -> LogicalLayoutIR {
|
||||
let mut regions = Vec::new();
|
||||
let mut engraving_decisions = Vec::new();
|
||||
// The projected break overrides, keyed by owning region for the final
|
||||
// deterministic ordering (canvas order need not be region-id order).
|
||||
let mut projected_breaks: Vec<(RegionId, EngravingOverride)> = Vec::new();
|
||||
let mut cross_region = Vec::new();
|
||||
let mut seen: BTreeSet<LayoutObjectId> = BTreeSet::new();
|
||||
// The resolved spellings and decompositions the notation engraving consumes
|
||||
|
|
@ -538,6 +547,34 @@ pub fn to_logical(score: &Score) -> LogicalLayoutIR {
|
|||
region_provenance.stable_id,
|
||||
EngravingDecisionKind::SystemBreak,
|
||||
));
|
||||
// The region's authoritative user break lists project as engraving
|
||||
// overrides (Chapter 7 §"Engraving Overrides": a break override
|
||||
// addresses a *position* — its kind carries the break's `TimeAnchor`,
|
||||
// its `ScoreGraph` target names the owning region). Each applied
|
||||
// override records a paired decision with
|
||||
// `DecisionSource::UserOverride(id)` (Chapter 7 §"Override
|
||||
// Resolution") against the region's stable layout id.
|
||||
if let Some(content) = region.content.staff_based() {
|
||||
for anchor in &content.user_system_breaks {
|
||||
let projected =
|
||||
EngravingOverride::projected_system_break(region_id, anchor.clone());
|
||||
engraving_decisions.push(EngravingDecision::with_source(
|
||||
region_provenance.stable_id,
|
||||
EngravingDecisionKind::SystemBreak,
|
||||
DecisionSource::UserOverride(projected.id),
|
||||
));
|
||||
projected_breaks.push((region_id, projected));
|
||||
}
|
||||
for anchor in &content.user_page_breaks {
|
||||
let projected = EngravingOverride::projected_page_break(region_id, anchor.clone());
|
||||
engraving_decisions.push(EngravingDecision::with_source(
|
||||
region_provenance.stable_id,
|
||||
EngravingDecisionKind::PageBreak,
|
||||
DecisionSource::UserOverride(projected.id),
|
||||
));
|
||||
projected_breaks.push((region_id, projected));
|
||||
}
|
||||
}
|
||||
regions.push(LayoutRegion {
|
||||
provenance: region_provenance,
|
||||
coordinate_system: LocalCoordinateSystem::default(),
|
||||
|
|
@ -609,16 +646,41 @@ pub fn to_logical(score: &Score) -> LogicalLayoutIR {
|
|||
}
|
||||
}
|
||||
|
||||
// Deterministic override order: by (region id, kind discriminant, anchor
|
||||
// canonical bytes) — independent of canvas order and of the break lists'
|
||||
// internal order.
|
||||
projected_breaks.sort_by(|(region_a, a), (region_b, b)| {
|
||||
(region_a, a.kind.discriminant(), break_anchor_bytes(a)).cmp(&(
|
||||
region_b,
|
||||
b.kind.discriminant(),
|
||||
break_anchor_bytes(b),
|
||||
))
|
||||
});
|
||||
|
||||
let source = derive_score_version(score);
|
||||
LogicalLayoutIR {
|
||||
source,
|
||||
regions,
|
||||
engraving_decisions,
|
||||
overrides: Vec::new(),
|
||||
overrides: projected_breaks
|
||||
.into_iter()
|
||||
.map(|(_, projected)| projected)
|
||||
.collect(),
|
||||
cross_region,
|
||||
}
|
||||
}
|
||||
|
||||
/// The canonical bytes of a projected break override's anchor (its ordering
|
||||
/// key alongside the owning region and kind).
|
||||
fn break_anchor_bytes(projected: &EngravingOverride) -> Vec<u8> {
|
||||
match &projected.kind {
|
||||
OverrideKind::SystemBreak { anchor } | OverrideKind::PageBreak { anchor } => {
|
||||
anchor.canonical_bytes()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Derives the [`ScoreVersion`] from the **whole score's canonical content**
|
||||
/// (Agent B's whole-score codec), not merely the layout projection's object
|
||||
/// identities. Any score edit — including one that changes an event's content
|
||||
|
|
@ -790,7 +852,10 @@ fn resolve_time_anchor_inner(score: &Score, anchor: &TimeAnchor, depth: u8) -> O
|
|||
}
|
||||
}
|
||||
|
||||
fn apply_offset(base: TimePoint, offset: &AnchorOffset) -> Option<TimePoint> {
|
||||
/// Applies an [`AnchorOffset`] to a resolved base time; `None` when the
|
||||
/// offset's clock does not match the base. Shared with the constrained stage's
|
||||
/// break-anchor resolution.
|
||||
pub(crate) fn apply_offset(base: TimePoint, offset: &AnchorOffset) -> Option<TimePoint> {
|
||||
match (base, offset) {
|
||||
(base, AnchorOffset::Zero) => Some(base),
|
||||
(TimePoint::Musical(position), AnchorOffset::Musical(duration)) => {
|
||||
|
|
@ -1292,6 +1357,73 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_breaks_project_as_overrides_with_paired_decisions() {
|
||||
use crate::engraving::{
|
||||
DecisionSource, EngravingDecisionKind, OverrideKind, OverrideOrigin, OverridePriority,
|
||||
OverrideTarget,
|
||||
};
|
||||
let mut score = valid_score(5);
|
||||
let region_id = score.canvas.regions[0].id;
|
||||
let anchor = TimeAnchor::WallClock {
|
||||
time: WallClockTime(42),
|
||||
};
|
||||
let content = score.canvas.regions[0]
|
||||
.content
|
||||
.staff_based_mut()
|
||||
.expect("valid_score is staff based");
|
||||
content.user_system_breaks.push(anchor.clone());
|
||||
content.user_page_breaks.push(anchor.clone());
|
||||
|
||||
// Deterministic across two runs.
|
||||
let ir = to_logical(&score);
|
||||
assert_eq!(ir.overrides, to_logical(&score).overrides);
|
||||
|
||||
// One override per break, in the pinned projected shape: the kind
|
||||
// carries the anchor, the ScoreGraph target names the owning region,
|
||||
// Soft binding, Internal origin.
|
||||
assert_eq!(ir.overrides.len(), 2);
|
||||
for projected in &ir.overrides {
|
||||
assert_eq!(
|
||||
projected.target,
|
||||
OverrideTarget::ScoreGraph(TypedObjectId::Region(region_id))
|
||||
);
|
||||
assert_eq!(projected.priority, OverridePriority::Soft);
|
||||
assert_eq!(projected.origin, OverrideOrigin::Internal);
|
||||
}
|
||||
assert!(ir.overrides.iter().any(|o| matches!(
|
||||
&o.kind,
|
||||
OverrideKind::SystemBreak { anchor: got } if *got == anchor
|
||||
)));
|
||||
assert!(ir.overrides.iter().any(|o| matches!(
|
||||
&o.kind,
|
||||
OverrideKind::PageBreak { anchor: got } if *got == anchor
|
||||
)));
|
||||
assert_ne!(ir.overrides[0].id, ir.overrides[1].id);
|
||||
|
||||
// Each applied override records a paired decision sourced to it
|
||||
// (Chapter 7 §"Override Resolution").
|
||||
for projected in &ir.overrides {
|
||||
let kind = match &projected.kind {
|
||||
OverrideKind::SystemBreak { .. } => EngravingDecisionKind::SystemBreak,
|
||||
OverrideKind::PageBreak { .. } => EngravingDecisionKind::PageBreak,
|
||||
other => panic!("unexpected projected override kind {other:?}"),
|
||||
};
|
||||
assert!(
|
||||
ir.engraving_decisions.iter().any(|decision| decision.source
|
||||
== DecisionSource::UserOverride(projected.id)
|
||||
&& decision.kind == kind),
|
||||
"no paired UserOverride decision for {projected:?}"
|
||||
);
|
||||
}
|
||||
// The automatic per-region system-break decision is still present.
|
||||
assert!(ir
|
||||
.engraving_decisions
|
||||
.iter()
|
||||
.any(|decision| decision.source == DecisionSource::Automatic
|
||||
&& decision.kind == EngravingDecisionKind::SystemBreak));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spanning_object_uses_cross_region_collection() {
|
||||
let mut score = valid_score_rich(5);
|
||||
|
|
|
|||
|
|
@ -5,14 +5,16 @@
|
|||
//! > all provenance preserved.
|
||||
//!
|
||||
//! [`round_trip`] runs the whole pipeline and asserts the contract every IR
|
||||
//! stage must satisfy: the stub solver reports [`SolveStatus::Solved`] with all
|
||||
//! hard constraints satisfied; the **complete** [`Provenance`] of every object
|
||||
//! survives every stage unchanged; no two objects share a stable id; the stub
|
||||
//! solver returns the input geometry verbatim; and the *set* of score-graph
|
||||
//! sources recovered from the RenderIR is exactly the set laid out — a surjection
|
||||
//! onto graph identity (one source may back several manifestations, each a
|
||||
//! distinct layout object). This is the contract the testkit's layout harness
|
||||
//! drives, now against the real crate.
|
||||
//! stage must satisfy: the solver reports a renderable status (the stub, which
|
||||
//! evaluates no constraints, honestly claims hard-constraint satisfaction only
|
||||
//! for a constraint-free problem; a conformant tier must claim it outright);
|
||||
//! the **complete** [`Provenance`] of every object survives every stage
|
||||
//! unchanged; no two objects share a stable id; the stub solver returns the
|
||||
//! input geometry verbatim; and the *set* of score-graph sources recovered from
|
||||
//! the RenderIR is exactly the set laid out — a surjection onto graph identity
|
||||
//! (one source may back several manifestations, each a distinct layout object).
|
||||
//! This is the contract the testkit's layout harness drives, now against the
|
||||
//! real crate.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
|
|
@ -97,8 +99,8 @@ fn provenance_map<'a>(
|
|||
/// completes without panic and **without losing provenance back-references**.
|
||||
/// Specifically:
|
||||
///
|
||||
/// * the stub solver returns [`SolveStatus::Solved`] with all hard constraints
|
||||
/// satisfied;
|
||||
/// * the stub solver returns a renderable status, claiming hard-constraint
|
||||
/// satisfaction exactly when no constraint is declared (it evaluates none);
|
||||
/// * the complete [`Provenance`] of every object — `source`, `synthesis`,
|
||||
/// `dependencies`, and `stable_id` — survives every stage unchanged (compared
|
||||
/// as `stable_id -> Provenance` maps, so a dropped dependency or synthesis
|
||||
|
|
@ -175,10 +177,21 @@ pub fn round_trip_with<S: ConstraintSolver>(score: &Score, solver: &S) -> RoundT
|
|||
"the solver must return a renderable layout, got {:?}",
|
||||
report.status
|
||||
);
|
||||
assert!(
|
||||
report.satisfied_hard_constraints,
|
||||
"the solver must satisfy all hard constraints"
|
||||
);
|
||||
if solver.tier() == SolverTier::Stub {
|
||||
// The interface-only stub evaluates no constraints, so it may claim
|
||||
// hard-constraint satisfaction only for a constraint-free problem —
|
||||
// with any declared, an honest report claims none.
|
||||
assert_eq!(
|
||||
report.satisfied_hard_constraints,
|
||||
constrained.constraints.is_empty(),
|
||||
"the stub must claim satisfaction exactly when no constraint is declared"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
report.satisfied_hard_constraints,
|
||||
"a conformant solver must satisfy all hard constraints"
|
||||
);
|
||||
}
|
||||
|
||||
// The Stub tier's geometry contract: it returns the input geometry *verbatim*
|
||||
// — each resolved glyph's position is exactly its constrained baseline (Chapter
|
||||
|
|
@ -404,8 +417,10 @@ mod tests {
|
|||
.collect();
|
||||
assert_eq!(manifestations.len(), 2);
|
||||
assert_ne!(manifestations[0], manifestations[1]);
|
||||
// The pipeline declares real constraints for this score, which the stub
|
||||
// does not evaluate — renderable, but not exactly `Solved`.
|
||||
let report = round_trip(&score);
|
||||
assert_eq!(report.status, SolveStatus::Solved);
|
||||
assert!(report.status.is_renderable());
|
||||
}
|
||||
|
||||
/// Across a multi-region score, `to_logical` never emits two layout objects
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@
|
|||
//! a [`SolveReport`] with its full diagnostic surface (unsatisfied constraints,
|
||||
//! warnings, a [`QualityMetricVector`], budget used, state) — and a
|
||||
//! [`StubSolver`] that, per the QUICKSTART, "returns `SolveStatus::Solved` with
|
||||
//! the input geometry verbatim."
|
||||
//! the input geometry verbatim" — for a constraint-free problem; with
|
||||
//! constraints declared it stays a renderable passthrough but claims no
|
||||
//! satisfaction (see [`StubSolver`]).
|
||||
//!
|
||||
//! **Quality-metric *computation* is deliberately not implemented** (QUICKSTART:
|
||||
//! "only the interface — don't implement quality metrics"): the
|
||||
|
|
@ -273,11 +275,29 @@ pub enum InvalidationScope {
|
|||
pub struct SpringSlotId(pub u128);
|
||||
|
||||
/// A constraint identifier referenced by [`SolveReport::unsatisfied_constraints`]
|
||||
/// (Chapter 9: `ConstraintId`). The stub never reports any because it rejects
|
||||
/// explicit constraints it cannot evaluate.
|
||||
/// (Chapter 9: `ConstraintId`). The stub never reports any: it evaluates no
|
||||
/// constraints, so it neither claims one satisfied nor names one unsatisfied.
|
||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
|
||||
pub struct ConstraintId(pub u128);
|
||||
|
||||
/// The strength a constraint binds the solver with (Chapter 9 §"Strength
|
||||
/// Levels": `ConstraintStrength`). Constraints do not carry this in the IR —
|
||||
/// the spec's [`crate::LayoutConstraint`] enum has no strength field — so it is
|
||||
/// attached by rule via [`crate::LayoutConstraint::strength`].
|
||||
#[derive(Copy, Clone, PartialEq, Debug)]
|
||||
pub enum ConstraintStrength {
|
||||
/// Hard constraint. The solver MUST satisfy it or return
|
||||
/// [`SolveStatus::Unsatisfiable`], and MUST NOT treat it as if it were
|
||||
/// `Preferred` for any reason, including quality optimization (Chapter 9
|
||||
/// §"Strength Levels").
|
||||
Required,
|
||||
/// Soft constraint with an associated weight. The solver minimizes the
|
||||
/// weighted violation when optimizing; an unhonoured preference is a
|
||||
/// warning ([`SolverWarningKind::LargeSoftConstraintViolation`]), never an
|
||||
/// `Unsatisfiable`.
|
||||
Preferred { weight: f64 },
|
||||
}
|
||||
|
||||
/// A declared invalidation (Chapter 9: `InvalidationSet`) over the invalidated
|
||||
/// slots, bands, constraints, and glyphs.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
|
|
@ -384,6 +404,15 @@ pub trait ConstraintSolver: Send + Sync {
|
|||
/// (Chapter 7 §7.3.2). A glyph whose metrics are not bundled, or a catalog hash
|
||||
/// that does not match its glyphs, is a well-formedness failure reported as
|
||||
/// [`SolveStatus::InternalError`] (never a panic).
|
||||
///
|
||||
/// **Declared constraints are not evaluated** ([`SolverTier::Stub`]), and the
|
||||
/// report is honest about it in both directions: the solve stays renderable
|
||||
/// (geometry passes through; unevaluated constraints are not a defect in the
|
||||
/// *input*), but `satisfied_hard_constraints` is `false` and a warning names
|
||||
/// the gap — the stub never claims satisfaction it did not check. Chapter 9
|
||||
/// has no status for "renderable, constraints unevaluated", so the closest
|
||||
/// non-claiming renderable status, [`SolveStatus::SolvedWithWarnings`], is
|
||||
/// used (see DECISIONS.md).
|
||||
pub struct StubSolver;
|
||||
|
||||
impl StubSolver {
|
||||
|
|
@ -398,10 +427,31 @@ impl StubSolver {
|
|||
.collect();
|
||||
let metrics_available = all_available(names.iter().copied());
|
||||
let catalog_valid = metrics_available && input.catalog == BravuraCatalog.identity(&names);
|
||||
let well_formed = structural_valid && catalog_valid;
|
||||
// This interface-only solver can preserve already-resolved geometry but
|
||||
// does not evaluate explicit constraints. It must not claim those are
|
||||
// satisfied merely because the input is structurally well formed.
|
||||
let well_formed = structural_valid && catalog_valid && input.constraints.is_empty();
|
||||
let unevaluated = input.constraints.len();
|
||||
|
||||
let status = if !well_formed {
|
||||
SolveStatus::InternalError
|
||||
} else if unevaluated > 0 {
|
||||
SolveStatus::SolvedWithWarnings
|
||||
} else {
|
||||
SolveStatus::Solved
|
||||
};
|
||||
let warnings = if well_formed && unevaluated > 0 {
|
||||
vec![SolverWarning {
|
||||
kind: SolverWarningKind::UnusualLayoutDecision(format!(
|
||||
"the interface-only stub solver evaluated none of the {unevaluated} \
|
||||
declared constraint(s); satisfaction is not claimed"
|
||||
)),
|
||||
affected_objects: Vec::new(),
|
||||
message: "declared constraints were not evaluated".to_owned(),
|
||||
}]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let glyphs: Vec<ResolvedGlyph> = if structural_valid {
|
||||
input
|
||||
|
|
@ -452,12 +502,10 @@ impl StubSolver {
|
|||
.collect();
|
||||
|
||||
SolveReport {
|
||||
status: if well_formed {
|
||||
SolveStatus::Solved
|
||||
} else {
|
||||
SolveStatus::InternalError
|
||||
},
|
||||
satisfied_hard_constraints: well_formed,
|
||||
status,
|
||||
// Honest in both directions: false when the input is malformed *and*
|
||||
// when constraints were declared but not evaluated.
|
||||
satisfied_hard_constraints: well_formed && unevaluated == 0,
|
||||
layout: ResolvedLayoutIR {
|
||||
source: input.source,
|
||||
pages,
|
||||
|
|
@ -467,7 +515,7 @@ impl StubSolver {
|
|||
catalog: input.catalog.clone(),
|
||||
},
|
||||
unsatisfied_constraints: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
warnings,
|
||||
metric_vector: QualityMetricVector::unmeasured(),
|
||||
// The stub does no iterative work; its deterministic budget use is zero.
|
||||
budget_used: SolverBudgetUsed::default(),
|
||||
|
|
@ -613,14 +661,14 @@ mod tests {
|
|||
))
|
||||
);
|
||||
|
||||
// A well-formed constraint reference validates — even though the stub
|
||||
// solver still refuses to *evaluate* it (it cannot claim it satisfied).
|
||||
// A well-formed constraint reference validates — and the stub solver
|
||||
// still does not *evaluate* it: the solve stays renderable, but it does
|
||||
// not claim the constraint satisfied.
|
||||
input.constraints = vec![LayoutConstraint::NoCollision { a: real, b: real }];
|
||||
assert!(input.validate().is_ok());
|
||||
assert_eq!(
|
||||
StubSolver.solve(&input, &SolverConfig::default()).status,
|
||||
SolveStatus::InternalError
|
||||
);
|
||||
let report = StubSolver.solve(&input, &SolverConfig::default());
|
||||
assert_eq!(report.status, SolveStatus::SolvedWithWarnings);
|
||||
assert!(!report.satisfied_hard_constraints);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -741,8 +789,15 @@ mod tests {
|
|||
.constraints
|
||||
.push(crate::LayoutConstraint::NoCollision { a: glyph, b: glyph });
|
||||
let report = StubSolver.solve(&input, &SolverConfig::default());
|
||||
assert_eq!(report.status, SolveStatus::InternalError);
|
||||
// Unevaluated constraints are not a defect in the input, so the solve is
|
||||
// renderable — but satisfaction is not claimed, and a warning names the gap.
|
||||
assert_eq!(report.status, SolveStatus::SolvedWithWarnings);
|
||||
assert!(report.status.is_renderable());
|
||||
assert!(!report.satisfied_hard_constraints);
|
||||
assert_eq!(report.warnings.len(), 1);
|
||||
assert!(report.unsatisfied_constraints.is_empty());
|
||||
// The geometry still passes through verbatim.
|
||||
assert_eq!(report.layout.glyphs[0].position, input.glyphs[0].baseline);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -194,6 +194,7 @@ fn pending_reason(reader: &mut Reader<'_>) -> Result<PendingReason> {
|
|||
1 => Ok(PendingReason::DependsOnEquivocated { on: blocker }),
|
||||
2 => Ok(PendingReason::DependsOnExcluded { on: blocker }),
|
||||
3 => Ok(PendingReason::DependsOnPending { on: blocker }),
|
||||
4 => Ok(PendingReason::HaltedBySystemCollision { at: blocker }),
|
||||
tag => Err(MaterializedDecodeError::InvalidTag {
|
||||
kind: "PendingReason",
|
||||
tag,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
//! foremost `stamp.id == id`.
|
||||
|
||||
use epiphany_core::{OperationId, TransactionId};
|
||||
use epiphany_determinism::{CanonicalEncode, DomainTag, Preimage};
|
||||
use epiphany_determinism::{CanonicalDecode, CanonicalEncode, DomainTag, Preimage};
|
||||
|
||||
use crate::causal::CausalContext;
|
||||
use crate::encode::{push_canon, push_tag};
|
||||
|
|
@ -106,6 +106,24 @@ impl CanonicalEncode for OperationEnvelope {
|
|||
}
|
||||
}
|
||||
|
||||
/// Reads the [`OperationId`] a canonically encoded envelope declares from its
|
||||
/// **leading 16 bytes** alone, without decoding the rest of the envelope.
|
||||
///
|
||||
/// The canonical envelope encoding leads with the id
|
||||
/// ([`OperationEnvelope::encode_canonical`] pushes `id` first, and an
|
||||
/// `OperationId`'s canonical form is exactly 16 big-endian bytes), so this is a
|
||||
/// total, allocation-free peek. Returns `None` when fewer than 16 bytes are
|
||||
/// present. It deliberately performs **no** validation of the remaining bytes —
|
||||
/// full decoding stays the caller's job — which is exactly the contract the
|
||||
/// bundle's operation index (Chapter 8 §"The Operation Index") needs: the
|
||||
/// bundle layer keys index entries on the raw 16 id bytes without ever
|
||||
/// interpreting an envelope, and this helper is the one place the ops layer
|
||||
/// vouches that those bytes really are the id.
|
||||
pub fn peek_operation_id(envelope_bytes: &[u8]) -> Option<OperationId> {
|
||||
let head = envelope_bytes.get(..16)?;
|
||||
OperationId::decode_canonical(head).ok()
|
||||
}
|
||||
|
||||
/// Why an envelope failed the well-formedness check (Chapter 6 §6.4). A
|
||||
/// rejected envelope is recorded in the local diagnostic log but does not enter
|
||||
/// the canonical operation set.
|
||||
|
|
@ -247,6 +265,21 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peek_operation_id_reads_the_leading_canonical_bytes() {
|
||||
let id = OperationId::new(ReplicaId(0x0102_0304_0506_0708), 0x1122_3344_5566_7788);
|
||||
let e = env(id, id);
|
||||
let bytes = e.to_canonical_bytes();
|
||||
// The canonical envelope encoding truly *leads* with the id's 16
|
||||
// canonical bytes — the invariant `peek_operation_id` (and the bundle's
|
||||
// operation index built on it) depends on.
|
||||
assert_eq!(&bytes[..16], &id.canonical_bytes());
|
||||
assert_eq!(peek_operation_id(&bytes), Some(id));
|
||||
// A short buffer has no id to peek.
|
||||
assert_eq!(peek_operation_id(&bytes[..15]), None);
|
||||
assert_eq!(peek_operation_id(&[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_hash_changes_with_payload_bytes() {
|
||||
let mut a = env(
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ use crate::payload::{
|
|||
use crate::stamp::{HybridLogicalClock, OperationStamp};
|
||||
use crate::support::AuthorId;
|
||||
use crate::valuegen;
|
||||
use crate::IntegrityAnomalyKind;
|
||||
use crate::{EnvelopeHash, IntegrityAnomalyKind, OperationEffect};
|
||||
|
||||
/// Number of replicas the generator draws authors from.
|
||||
const REPLICAS: u64 = 3;
|
||||
|
|
@ -410,6 +410,137 @@ pub fn run_equivocation_fuzz(iters: u64, seed: u64) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Runs `iters` equivocation-*resolution* iterations from `seed` (the
|
||||
/// `ResolveEquivocation` sibling of [`run_equivocation_fuzz`]). Each iteration
|
||||
/// builds two distinct canonical envelopes under one `OperationId` plus a
|
||||
/// `ResolveEquivocation`, embeds them in random noise, and asserts across four
|
||||
/// random acceptance orders:
|
||||
///
|
||||
/// * with a **valid** resolve (`chosen` names a real candidate): the resolved
|
||||
/// slot contributes an effect at its own id, no `OperationSlotEquivocated`
|
||||
/// anomaly is recorded for it, the resolve itself applies, and every
|
||||
/// permutation reduces to byte-identical [`crate::MaterializedState`];
|
||||
/// * with an **invalid** resolve (`chosen` names no candidate): behavior is
|
||||
/// exactly today's unresolved equivocation — the slot contributes nothing,
|
||||
/// the anomaly is recorded, the resolve is a precondition no-op — and the
|
||||
/// permutation invariance still holds.
|
||||
pub fn run_equivocation_resolution_fuzz(iters: u64, seed: u64) {
|
||||
let mut rng = SplitMix64::new(seed);
|
||||
for _ in 0..iters {
|
||||
let id = OperationId::new(ReplicaId(1 + rng.below(REPLICAS)), rng.below(5));
|
||||
|
||||
let mk = |rng: &mut SplitMix64, spelling: u8| OperationEnvelope {
|
||||
id,
|
||||
author: AuthorId(0),
|
||||
stamp: OperationStamp::new(
|
||||
HybridLogicalClock::new(epiphany_core::WallClockTime(rng.below(100) as i64), 0),
|
||||
id,
|
||||
),
|
||||
causal_context: CausalContext::new(),
|
||||
transaction: None,
|
||||
payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
|
||||
pitch: pitch(0),
|
||||
spelling: valuegen::spelling(spelling),
|
||||
})),
|
||||
};
|
||||
let a = mk(&mut rng, 1);
|
||||
let b = mk(&mut rng, 2); // distinct canonical bytes (different spelling)
|
||||
debug_assert_ne!(a.envelope_hash(), b.envelope_hash());
|
||||
|
||||
// Valid two-thirds of the time; otherwise a hash naming no candidate.
|
||||
let valid = !rng.chance(3);
|
||||
let chosen = if !valid {
|
||||
EnvelopeHash([0xEE; 32])
|
||||
} else if rng.chance(2) {
|
||||
a.envelope_hash()
|
||||
} else {
|
||||
b.envelope_hash()
|
||||
};
|
||||
// The resolve lives on a replica the noise generator never draws
|
||||
// (noise uses 1..=REPLICAS), so its own slot can neither equivocate
|
||||
// nor land in a quarantined segment.
|
||||
let resolve_id = OperationId::new(ReplicaId(REPLICAS + 2), 0);
|
||||
let resolve = OperationEnvelope {
|
||||
id: resolve_id,
|
||||
author: AuthorId(0),
|
||||
stamp: OperationStamp::new(
|
||||
HybridLogicalClock::new(epiphany_core::WallClockTime(rng.below(100) as i64), 0),
|
||||
resolve_id,
|
||||
),
|
||||
causal_context: CausalContext::new(),
|
||||
transaction: None,
|
||||
payload: OperationPayload::ResolveEquivocation(
|
||||
crate::payload::ResolveEquivocationPayload { target: id, chosen },
|
||||
),
|
||||
};
|
||||
|
||||
let noise_count = rng.below(4) as usize;
|
||||
let noise = gen_envelope_set(&mut rng, noise_count)
|
||||
.into_iter()
|
||||
.filter(|e| e.id != id)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut items = vec![a.clone(), b.clone(), resolve.clone()];
|
||||
items.extend(noise);
|
||||
|
||||
let mut reference: Option<Vec<u8>> = None;
|
||||
for _ in 0..4 {
|
||||
shuffle(&mut items, &mut rng);
|
||||
let mut set = OperationSet::new();
|
||||
set.accept_all(items.iter().cloned());
|
||||
let state = set.reduce();
|
||||
|
||||
let slot_contributes = state.effects.iter().any(|(e, _)| *e == id);
|
||||
let anomalous = state.anomalies.iter().any(|an| matches!(
|
||||
an.kind,
|
||||
IntegrityAnomalyKind::OperationSlotEquivocated { operation_id } if operation_id == id
|
||||
));
|
||||
let resolve_effect = state
|
||||
.effects
|
||||
.iter()
|
||||
.find(|(e, _)| *e == resolve_id)
|
||||
.map(|(_, eff)| eff);
|
||||
if valid {
|
||||
assert!(
|
||||
slot_contributes,
|
||||
"a resolved slot must contribute its chosen candidate's effect"
|
||||
);
|
||||
assert!(
|
||||
!anomalous,
|
||||
"a resolved slot must record no OperationSlotEquivocated anomaly"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_effect,
|
||||
Some(&OperationEffect::Applied),
|
||||
"the governing resolve must apply"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
!slot_contributes,
|
||||
"an unresolved equivocated slot must contribute nothing"
|
||||
);
|
||||
assert!(
|
||||
anomalous,
|
||||
"an unresolved equivocated slot must record its anomaly"
|
||||
);
|
||||
assert!(
|
||||
matches!(resolve_effect, Some(OperationEffect::NoOp { .. })),
|
||||
"an invalid resolve must be a precondition no-op, got {resolve_effect:?}"
|
||||
);
|
||||
}
|
||||
|
||||
let bytes = state.canonical_bytes();
|
||||
match &reference {
|
||||
None => reference = Some(bytes),
|
||||
Some(reference) => assert_eq!(
|
||||
&bytes, reference,
|
||||
"equivocation resolution is not permutation-invariant"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -424,6 +555,11 @@ mod tests {
|
|||
run_equivocation_fuzz(500, 0x1234_5678);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equivocation_resolution_smoke() {
|
||||
run_equivocation_resolution_fuzz(500, 0x9E50_1AE5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generator_is_deterministic() {
|
||||
let mut a = SplitMix64::new(99);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,18 +33,19 @@
|
|||
|
||||
use epiphany_core::{
|
||||
Beam, CanonicalValue, Event, EventDuration, EventId, EventPosition, IdentifiedPitch,
|
||||
MetricGrid, MusicalDuration, MusicalPosition, Pitch, PitchId, PitchSpelling, Region, RegionId,
|
||||
RegionTimeModel, Rest, ScoreMetadata, Slur, Spanner, StaffInstance, StaffInstanceId, Tie,
|
||||
TimeAnchor, TransactionId, TupletId, TypedObjectId, Voice, VoiceId,
|
||||
MetricGrid, MusicalDuration, MusicalPosition, OperationId, Pitch, PitchId, PitchSpelling,
|
||||
Region, RegionId, RegionTimeModel, Rest, ScoreMetadata, Slur, Spanner, StaffInstance,
|
||||
StaffInstanceId, Tie, TimeAnchor, TransactionId, TupletId, TypedObjectId, Voice, VoiceId,
|
||||
};
|
||||
use epiphany_determinism::{sorted_canonical, CanonicalEncode};
|
||||
use epiphany_determinism::{sorted_canonical, CanonicalDecode, CanonicalEncode, DecodeError};
|
||||
|
||||
use crate::conflict::{ConflictId, ResolutionAction};
|
||||
use crate::encode::{push_canon, push_lp_bytes, push_seq, push_str, push_tag, push_u8_bool};
|
||||
use crate::envelope::EnvelopeHash;
|
||||
use crate::support::OperationKindRegistryId;
|
||||
use crate::undo::UndoTransactionPayload;
|
||||
|
||||
/// The full payload of an operation envelope: a primitive, or one of the two
|
||||
/// The full payload of an operation envelope: a primitive, or one of the
|
||||
/// meta-operations (Chapter 6 §"Operation Envelopes").
|
||||
// v1 payloads carry whole graph values, so the `Primitive` variant is
|
||||
// intentionally larger than the meta-operations — the durability the catalog
|
||||
|
|
@ -60,6 +61,10 @@ pub enum OperationPayload {
|
|||
/// A meta-operation that compensates for a previously-committed
|
||||
/// transaction; the realization of "undo".
|
||||
UndoTransaction(UndoTransactionPayload),
|
||||
/// A meta-operation that resolves an equivocated operation slot by naming
|
||||
/// the chosen candidate envelope (operation_catalog
|
||||
/// §"ResolveEquivocation").
|
||||
ResolveEquivocation(ResolveEquivocationPayload),
|
||||
}
|
||||
|
||||
impl OperationPayload {
|
||||
|
|
@ -68,6 +73,8 @@ impl OperationPayload {
|
|||
OperationPayload::Primitive(_) => 0,
|
||||
OperationPayload::ResolveConflict(_) => 1,
|
||||
OperationPayload::UndoTransaction(_) => 2,
|
||||
// Appended (ResolveEquivocation); the ratified 0..=2 stay stable.
|
||||
OperationPayload::ResolveEquivocation(_) => 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -79,6 +86,7 @@ impl CanonicalEncode for OperationPayload {
|
|||
OperationPayload::Primitive(k) => k.encode_canonical(out),
|
||||
OperationPayload::ResolveConflict(p) => p.encode_canonical(out),
|
||||
OperationPayload::UndoTransaction(p) => p.encode_canonical(out),
|
||||
OperationPayload::ResolveEquivocation(p) => p.encode_canonical(out),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -318,6 +326,63 @@ impl CanonicalEncode for OperationKindTag {
|
|||
}
|
||||
}
|
||||
|
||||
impl CanonicalDecode for OperationKindTag {
|
||||
/// Decodes exactly the canonical form [`CanonicalEncode`] produces: the
|
||||
/// discriminant byte, plus — for [`OperationKindTag::Registered`] only —
|
||||
/// the registry id's 16 big-endian bytes. Variable-width, so the input
|
||||
/// length must match the decoded variant exactly (trailing bytes are an
|
||||
/// error). An unknown discriminant is rejected, never normalized —
|
||||
/// [`DecodeError::MalformedDomainTag`], the same unknown-discriminant
|
||||
/// rejection [`TypedObjectId::decode_canonical`] uses.
|
||||
fn decode_canonical(bytes: &[u8]) -> Result<Self, DecodeError> {
|
||||
let (&tag, rest) = bytes.split_first().ok_or(DecodeError::UnexpectedLength {
|
||||
expected: 1,
|
||||
actual: 0,
|
||||
})?;
|
||||
if tag == 16 {
|
||||
let arr: [u8; 16] = rest.try_into().map_err(|_| DecodeError::UnexpectedLength {
|
||||
expected: 17,
|
||||
actual: bytes.len(),
|
||||
})?;
|
||||
return Ok(OperationKindTag::Registered(OperationKindRegistryId(
|
||||
u128::from_be_bytes(arr),
|
||||
)));
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
return Err(DecodeError::UnexpectedLength {
|
||||
expected: 1,
|
||||
actual: bytes.len(),
|
||||
});
|
||||
}
|
||||
Ok(match tag {
|
||||
0 => OperationKindTag::InsertEvent,
|
||||
1 => OperationKindTag::DeleteEvent,
|
||||
2 => OperationKindTag::ModifyEvent,
|
||||
3 => OperationKindTag::RespellPitch,
|
||||
4 => OperationKindTag::Transpose,
|
||||
5 => OperationKindTag::CreateCrossCutting,
|
||||
6 => OperationKindTag::DeleteCrossCutting,
|
||||
7 => OperationKindTag::ModifyCrossCutting,
|
||||
8 => OperationKindTag::ChangeRegionTimeModel,
|
||||
9 => OperationKindTag::InsertRegion,
|
||||
10 => OperationKindTag::DeleteRegion,
|
||||
11 => OperationKindTag::InsertStaffInstance,
|
||||
12 => OperationKindTag::DeleteStaffInstance,
|
||||
13 => OperationKindTag::SetUserSystemBreak,
|
||||
14 => OperationKindTag::SetUserPageBreak,
|
||||
15 => OperationKindTag::DeclareTransaction,
|
||||
17 => OperationKindTag::InsertIdentifiedPitch,
|
||||
18 => OperationKindTag::DeleteIdentifiedPitch,
|
||||
19 => OperationKindTag::ModifyIdentifiedPitch,
|
||||
20 => OperationKindTag::CreateVoice,
|
||||
21 => OperationKindTag::DeleteVoice,
|
||||
22 => OperationKindTag::SetMetadata,
|
||||
23 => OperationKindTag::SetMetricGrid,
|
||||
_ => return Err(DecodeError::MalformedDomainTag),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Representative operation payloads (Chapter 6 §6.10). --------------------
|
||||
|
||||
/// Insert an event into a voice (Chapter 6 §6.10 InsertEvent). Carries the full
|
||||
|
|
@ -731,6 +796,25 @@ impl CanonicalEncode for ResolveConflictPayload {
|
|||
}
|
||||
}
|
||||
|
||||
/// The payload of an equivocation-resolution meta-operation (operation_catalog
|
||||
/// §"ResolveEquivocation"): the equivocated slot and the candidate envelope (by
|
||||
/// canonical-bytes hash) that shall stand. Value-complete.
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||
pub struct ResolveEquivocationPayload {
|
||||
/// The `OperationId` of the equivocated slot to resolve.
|
||||
pub target: OperationId,
|
||||
/// The [`EnvelopeHash`] of the candidate envelope that shall stand.
|
||||
pub chosen: EnvelopeHash,
|
||||
}
|
||||
|
||||
impl CanonicalEncode for ResolveEquivocationPayload {
|
||||
fn encode_canonical(&self, out: &mut Vec<u8>) {
|
||||
// Catalog: `target` (16 canonical bytes), then `chosen` (32 bytes).
|
||||
push_canon(out, &self.target);
|
||||
push_canon(out, &self.chosen);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Group 1 (M2): event & pitch leaf-field ops (Chapter 6 §6.10). -----------
|
||||
|
||||
/// Overwrite a live event's value (Chapter 6 §6.10 ModifyEvent). Carries the
|
||||
|
|
@ -1053,6 +1137,247 @@ mod tests {
|
|||
use super::*;
|
||||
use epiphany_core::{ReplicaId, SlurId};
|
||||
|
||||
#[test]
|
||||
fn operation_kind_wire_discriminants_are_golden() {
|
||||
// GOLDEN LOCK: the discriminant byte leads every canonically-encoded
|
||||
// primitive payload (operation_catalog §"Value-Typed Payloads"), so the
|
||||
// literal values are normative wire facts. Encodings are append-only:
|
||||
// new kinds append past 23; the values below never change.
|
||||
use crate::valuegen;
|
||||
use epiphany_core::{MusicalDuration, MusicalPosition, StaffId};
|
||||
|
||||
let r = ReplicaId(1);
|
||||
let event_a = EventId::new(r, 1);
|
||||
let event_b = EventId::new(r, 2);
|
||||
let pitch = PitchId::new(r, 3);
|
||||
let region = RegionId::new(r, 4);
|
||||
let staff = StaffId::new(r, 5);
|
||||
let instance = StaffInstanceId::new(r, 6);
|
||||
let voice = VoiceId::new(r, 7);
|
||||
let slur_id = SlurId::new(r, 8);
|
||||
let event_value = || {
|
||||
valuegen::insert_event_value(
|
||||
event_a,
|
||||
voice,
|
||||
MusicalPosition::origin(),
|
||||
MusicalDuration::whole(),
|
||||
&[],
|
||||
)
|
||||
};
|
||||
let slur_value = || CrossCuttingValue::Slur(valuegen::slur(slur_id, event_a, event_b));
|
||||
let anchor = || valuegen::region_start_anchor(region, MusicalPosition::origin());
|
||||
|
||||
let table: [(OperationKind, u8); 24] = [
|
||||
(
|
||||
OperationKind::InsertEvent(InsertEventOp {
|
||||
staff_instance: instance,
|
||||
event: event_value(),
|
||||
}),
|
||||
0,
|
||||
),
|
||||
(
|
||||
OperationKind::DeleteEvent(DeleteEventOp {
|
||||
event: event_a,
|
||||
tuplet_compensation: TupletCompensation::NotInTuplet,
|
||||
}),
|
||||
1,
|
||||
),
|
||||
(
|
||||
OperationKind::RespellPitch(RespellPitchOp {
|
||||
pitch,
|
||||
spelling: valuegen::spelling(1),
|
||||
}),
|
||||
2,
|
||||
),
|
||||
(
|
||||
OperationKind::CreateCrossCutting(CreateCrossCuttingOp {
|
||||
structure: slur_value(),
|
||||
}),
|
||||
3,
|
||||
),
|
||||
(
|
||||
OperationKind::ChangeRegionTimeModel(ChangeRegionTimeModelOp {
|
||||
region,
|
||||
new_time_model: valuegen::metric_model(),
|
||||
declared_incompatible: vec![],
|
||||
remapping: PositionRemapping::PreserveTime,
|
||||
}),
|
||||
4,
|
||||
),
|
||||
(
|
||||
OperationKind::SetUserSystemBreak(SetUserSystemBreakOp {
|
||||
region,
|
||||
anchor: anchor(),
|
||||
present: true,
|
||||
}),
|
||||
5,
|
||||
),
|
||||
(
|
||||
OperationKind::DeclareTransaction(TransactionDescriptor {
|
||||
id: TransactionId::new(r, 9),
|
||||
label: String::new(),
|
||||
category: None,
|
||||
}),
|
||||
6,
|
||||
),
|
||||
(
|
||||
OperationKind::Registered(OperationKindRegistryId(0), vec![]),
|
||||
7,
|
||||
),
|
||||
(
|
||||
OperationKind::ModifyEvent(ModifyEventOp {
|
||||
event: event_value(),
|
||||
}),
|
||||
8,
|
||||
),
|
||||
(
|
||||
OperationKind::Transpose(TransposeOp {
|
||||
targets: vec![pitch],
|
||||
chromatic_steps: 0,
|
||||
}),
|
||||
9,
|
||||
),
|
||||
(
|
||||
OperationKind::InsertIdentifiedPitch(InsertIdentifiedPitchOp {
|
||||
event: event_a,
|
||||
pitch: valuegen::identified_pitch(pitch),
|
||||
}),
|
||||
10,
|
||||
),
|
||||
(
|
||||
OperationKind::DeleteIdentifiedPitch(DeleteIdentifiedPitchOp { pitch }),
|
||||
11,
|
||||
),
|
||||
(
|
||||
OperationKind::ModifyIdentifiedPitch(ModifyIdentifiedPitchOp {
|
||||
pitch,
|
||||
value: valuegen::pitch_value(),
|
||||
}),
|
||||
12,
|
||||
),
|
||||
(
|
||||
OperationKind::DeleteCrossCutting(DeleteCrossCuttingOp {
|
||||
structure: TypedObjectId::Slur(slur_id),
|
||||
}),
|
||||
13,
|
||||
),
|
||||
(
|
||||
OperationKind::ModifyCrossCutting(ModifyCrossCuttingOp {
|
||||
structure: slur_value(),
|
||||
}),
|
||||
14,
|
||||
),
|
||||
(
|
||||
OperationKind::CreateRegion(CreateRegionOp {
|
||||
region: valuegen::region(region),
|
||||
}),
|
||||
15,
|
||||
),
|
||||
(OperationKind::DeleteRegion(DeleteRegionOp { region }), 16),
|
||||
(
|
||||
OperationKind::CreateStaffInstance(CreateStaffInstanceOp {
|
||||
region,
|
||||
instance: valuegen::staff_instance(instance, staff),
|
||||
}),
|
||||
17,
|
||||
),
|
||||
(
|
||||
OperationKind::DeleteStaffInstance(DeleteStaffInstanceOp {
|
||||
staff_instance: instance,
|
||||
}),
|
||||
18,
|
||||
),
|
||||
(
|
||||
OperationKind::CreateVoice(CreateVoiceOp {
|
||||
staff_instance: instance,
|
||||
voice: valuegen::voice(voice),
|
||||
}),
|
||||
19,
|
||||
),
|
||||
(OperationKind::DeleteVoice(DeleteVoiceOp { voice }), 20),
|
||||
(
|
||||
OperationKind::SetMetadata(SetMetadataOp {
|
||||
metadata: valuegen::score_metadata(0),
|
||||
}),
|
||||
21,
|
||||
),
|
||||
(
|
||||
OperationKind::SetMetricGrid(SetMetricGridOp { region, grid: None }),
|
||||
22,
|
||||
),
|
||||
(
|
||||
OperationKind::SetUserPageBreak(SetUserPageBreakOp {
|
||||
region,
|
||||
anchor: anchor(),
|
||||
present: true,
|
||||
}),
|
||||
23,
|
||||
),
|
||||
];
|
||||
for (kind, expected) in &table {
|
||||
assert_eq!(
|
||||
kind.discriminant(),
|
||||
*expected,
|
||||
"wire discriminant for {:?} moved — canonical encodings are append-only",
|
||||
kind.tag(),
|
||||
);
|
||||
// The discriminant byte truly leads the canonical encoding.
|
||||
assert_eq!(kind.to_canonical_bytes()[0], *expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operation_payload_discriminants_are_golden() {
|
||||
// GOLDEN LOCK: the payload-union discriminant byte leads every
|
||||
// canonically-encoded envelope payload. 0..=2 are the ratified v1
|
||||
// values; 3 (ResolveEquivocation) is appended by the catalog entry
|
||||
// §"ResolveEquivocation". Append-only; never renumber.
|
||||
use crate::undo::UndoPolicy;
|
||||
use epiphany_core::OperationId;
|
||||
|
||||
let r = ReplicaId(1);
|
||||
let primitive = OperationPayload::Primitive(OperationKind::DeleteEvent(DeleteEventOp {
|
||||
event: EventId::new(r, 1),
|
||||
tuplet_compensation: TupletCompensation::NotInTuplet,
|
||||
}));
|
||||
let resolve_conflict = OperationPayload::ResolveConflict(ResolveConflictPayload {
|
||||
target: ConflictId(0),
|
||||
action: ResolutionAction::Dismiss,
|
||||
});
|
||||
let undo = OperationPayload::UndoTransaction(UndoTransactionPayload {
|
||||
target: TransactionId::new(r, 2),
|
||||
policy: UndoPolicy::BestEffort,
|
||||
});
|
||||
let resolve_equivocation =
|
||||
OperationPayload::ResolveEquivocation(ResolveEquivocationPayload {
|
||||
target: OperationId::new(r, 3),
|
||||
chosen: EnvelopeHash([0; 32]),
|
||||
});
|
||||
for (payload, expected) in [
|
||||
(&primitive, 0u8),
|
||||
(&resolve_conflict, 1),
|
||||
(&undo, 2),
|
||||
(&resolve_equivocation, 3),
|
||||
] {
|
||||
assert_eq!(payload.discriminant(), expected);
|
||||
assert_eq!(payload.to_canonical_bytes()[0], expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_equivocation_payload_encodes_target_then_hash() {
|
||||
// Catalog §"ResolveEquivocation": the canonical encoding is exactly the
|
||||
// target's 16 canonical bytes followed by the 32 hash bytes.
|
||||
use epiphany_core::OperationId;
|
||||
let target = OperationId::new(ReplicaId(0x0102_0304_0506_0708), 0x1122_3344_5566_7788);
|
||||
let chosen = EnvelopeHash([0xAB; 32]);
|
||||
let p = ResolveEquivocationPayload { target, chosen };
|
||||
let bytes = p.to_canonical_bytes();
|
||||
assert_eq!(bytes.len(), 48);
|
||||
assert_eq!(&bytes[..16], &target.canonical_bytes());
|
||||
assert_eq!(&bytes[16..], &[0xAB; 32]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transaction_category_discriminants_are_golden() {
|
||||
// RATIFIED by Pass 11 (item 2.4, req:semops:transaction-category): the
|
||||
|
|
@ -1113,6 +1438,48 @@ mod tests {
|
|||
assert_eq!(encoded.len(), tags.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operation_kind_tag_decode_mirrors_encode_exactly() {
|
||||
// Every non-registered variant round-trips through its 1-byte form, and
|
||||
// the registered variant through its 17-byte (tag + registry id) form.
|
||||
let mut tags: Vec<OperationKindTag> = (0u8..24)
|
||||
.filter(|d| *d != 16)
|
||||
.map(|d| OperationKindTag::decode_canonical(&[d]).expect("known discriminant"))
|
||||
.collect();
|
||||
tags.push(OperationKindTag::Registered(OperationKindRegistryId(
|
||||
0x0102_0304_0506_0708_090A_0B0C_0D0E_0F10,
|
||||
)));
|
||||
assert_eq!(tags.len(), 24, "the full v1 tag vocabulary");
|
||||
for tag in tags {
|
||||
let bytes = tag.to_canonical_bytes();
|
||||
let decoded = OperationKindTag::decode_canonical(&bytes).expect("round-trips");
|
||||
assert_eq!(decoded, tag);
|
||||
assert_eq!(
|
||||
decoded.to_canonical_bytes(),
|
||||
bytes,
|
||||
"re-encode is byte-identical"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operation_kind_tag_decode_rejects_malformed_bytes() {
|
||||
use epiphany_determinism::DecodeError;
|
||||
// Unknown discriminant (24 is one past the v1 vocabulary): rejected,
|
||||
// never normalized.
|
||||
assert_eq!(
|
||||
OperationKindTag::decode_canonical(&[24]),
|
||||
Err(DecodeError::MalformedDomainTag)
|
||||
);
|
||||
// Empty input.
|
||||
assert!(OperationKindTag::decode_canonical(&[]).is_err());
|
||||
// Trailing byte after a payload-less tag.
|
||||
assert!(OperationKindTag::decode_canonical(&[0, 0]).is_err());
|
||||
// A truncated (and an oversized) Registered payload.
|
||||
assert!(OperationKindTag::decode_canonical(&[16; 16]).is_err());
|
||||
assert!(OperationKindTag::decode_canonical(&[16; 19]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reassign_remapping_is_order_independent() {
|
||||
let e1 = EventId::new(ReplicaId(1), 1);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,451 @@
|
|||
//! Validation modes and advisory-precondition checking (Chapter 6
|
||||
//! §"Validation Modes", label `sec:semops:validation`).
|
||||
//!
|
||||
//! The spec distinguishes two precondition-checking modes:
|
||||
//!
|
||||
//! * **Authoring mode** — interactive edits. *All* preconditions are enforced:
|
||||
//! invariant preconditions (which preserve graph invariants) and advisory
|
||||
//! preconditions (user-intent constraints, range checks, style policy).
|
||||
//! * **Replay mode** — replaying historical operations or applying remote
|
||||
//! operations under reduction. Only invariant preconditions are enforced;
|
||||
//! advisory preconditions MAY fail silently, since they represent the
|
||||
//! authoring replica's local policy at the moment of authoring, not
|
||||
//! invariants of the canonical state.
|
||||
//!
|
||||
//! ## Where each mode lives
|
||||
//!
|
||||
//! [`crate::OperationSet::reduce`] and [`crate::OperationSet::reduce_onto`]
|
||||
//! **are** replay mode: the reducer enforces exactly the invariant
|
||||
//! preconditions and nothing else, in every context. Authoring-mode
|
||||
//! enforcement happens **before an envelope is minted** — the authoring layer
|
||||
//! (epiphany-editor-core) runs [`advisory_violations`] against its current
|
||||
//! materialized score and refuses to mint on any violation. Canonical
|
||||
//! reduction behavior and canonical bytes are therefore **untouched** by the
|
||||
//! mode machinery: an envelope that exists reduces identically whether its
|
||||
//! author checked advisories or not (the spec's replay-parity requirement).
|
||||
//!
|
||||
//! ## The advisory inventory (core spec §6.10)
|
||||
//!
|
||||
//! The spec declares advisory preconditions for two of the implemented K0
|
||||
//! operations (every other implemented kind's precondition bucket is entirely
|
||||
//! invariant):
|
||||
//!
|
||||
//! * **InsertEvent** — (a) for pitched events, every pitch is within the
|
||||
//! instrument's declared range, if any; (b) the event's duration does not
|
||||
//! extend past a region boundary in a way that would require splitting.
|
||||
//! * **CreateCrossCutting (Slur case)** — the slur does not span a region
|
||||
//! boundary, unless explicitly permitted by region configuration.
|
||||
//!
|
||||
//! `ModifyEvent` carries the full replacement event value, so check (b)
|
||||
//! applies to it identically (the replacement's span must not straddle the
|
||||
//! region boundary any more than an inserted one may).
|
||||
//!
|
||||
//! ### Implemented here
|
||||
//!
|
||||
//! * InsertEvent / ModifyEvent duration-not-crossing-region-boundary
|
||||
//! ([`AdvisoryViolation::DurationCrossesRegionBoundary`]), for regions whose
|
||||
//! musical end bound is resolvable (see below).
|
||||
//! * CreateCrossCutting(Slur) not-spanning-a-region-boundary
|
||||
//! ([`AdvisoryViolation::SlurSpansRegionBoundary`]): the slur's endpoint
|
||||
//! events resolve to different regions.
|
||||
//!
|
||||
//! ### Documented gaps (blocked on the truncated data model)
|
||||
//!
|
||||
//! * **InsertEvent pitch-within-instrument-range**: `epiphany_core::Instrument`
|
||||
//! carries only `{ id, name }` — it has no declared range field. The
|
||||
//! data-model completion is staged to the Binary Format companion; until the
|
||||
//! field exists there is nothing to check against ("if any" in the spec text
|
||||
//! makes the absent-range case a vacuous pass, which is exactly what this
|
||||
//! module does by omission).
|
||||
//! * **Slur spanning "explicitly permitted by region configuration"**:
|
||||
//! `epiphany_core::Region` has no such configuration flag. The check treats
|
||||
//! spanning as never permitted; when the flag lands, it suppresses the
|
||||
//! violation.
|
||||
//! * **Region musical end bound**: a region's `TimeExtent` is a pair of
|
||||
//! `TimeAnchor`s. The bound is resolvable in musical time only when the end
|
||||
//! anchor is region-start-anchored with a `Musical` offset (the same
|
||||
//! sound-but-incomplete resolution discipline as
|
||||
//! `epiphany_core::Region::overlaps_in_time`, which resolves only wall-clock
|
||||
//! extents). A wall-clock or symbolic extent yields no musical bound and the
|
||||
//! boundary check passes vacuously — the full tempo/measure resolution
|
||||
//! machinery is deferred (P11-C5).
|
||||
|
||||
use epiphany_core::{
|
||||
AnchorOffset, EventDuration, EventId, EventPosition, MusicalPosition, Region, RegionEdge,
|
||||
RegionId, Score, SlurId, TimeAnchor,
|
||||
};
|
||||
|
||||
use crate::payload::{CrossCuttingValue, OperationKind};
|
||||
use crate::reduce::graph_voice_location;
|
||||
|
||||
/// The two precondition-checking modes of Chapter 6 §"Validation Modes"
|
||||
/// (`sec:semops:validation`).
|
||||
///
|
||||
/// Invariant preconditions hold in **all** modes — the reducer
|
||||
/// ([`crate::OperationSet::reduce`] / [`crate::OperationSet::reduce_onto`])
|
||||
/// enforces them unconditionally, and is thereby exactly
|
||||
/// [`ValidationMode::Replay`]. [`ValidationMode::Authoring`] additionally
|
||||
/// requires [`advisory_violations`] to be empty *before an envelope is
|
||||
/// minted*; it never alters reduction.
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||
pub enum ValidationMode {
|
||||
/// Interactive edits: invariant **and** advisory preconditions are
|
||||
/// enforced. The advisory half is enforced pre-mint by the authoring
|
||||
/// layer via [`advisory_violations`].
|
||||
Authoring,
|
||||
/// Replaying historical operations or applying remote operations under
|
||||
/// reduction: only invariant preconditions are enforced; advisory
|
||||
/// preconditions MAY fail silently.
|
||||
Replay,
|
||||
}
|
||||
|
||||
impl ValidationMode {
|
||||
/// Whether this mode enforces advisory preconditions (only
|
||||
/// [`ValidationMode::Authoring`] does).
|
||||
#[inline]
|
||||
pub fn enforces_advisory(self) -> bool {
|
||||
matches!(self, ValidationMode::Authoring)
|
||||
}
|
||||
}
|
||||
|
||||
/// A failed advisory precondition (Chapter 6 §6.10, the "Advisory
|
||||
/// preconditions (authoring mode only)" buckets).
|
||||
///
|
||||
/// **Deliberately non-canonical**: this type has no canonical encoding and no
|
||||
/// discriminant table because it never enters effects, conflicts, or any other
|
||||
/// canonical state — it exists only on the authoring side, *before* an
|
||||
/// envelope is minted. An operation refused for an advisory violation leaves
|
||||
/// no trace in the operation set; one that slipped past (a remote author's
|
||||
/// different policy) reduces normally in replay mode.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub enum AdvisoryViolation {
|
||||
/// An `InsertEvent`/`ModifyEvent` event's span starts inside its region's
|
||||
/// musical extent but ends past the region's end bound — applying it would
|
||||
/// require splitting the event across the boundary (core spec §6.10
|
||||
/// InsertEvent, advisory bucket).
|
||||
DurationCrossesRegionBoundary {
|
||||
/// The offending event.
|
||||
event: EventId,
|
||||
/// The region whose end bound the event's span crosses.
|
||||
region: RegionId,
|
||||
},
|
||||
/// A `CreateCrossCutting` slur's endpoint events lie in different regions
|
||||
/// (core spec §6.10 CreateCrossCutting, Slur advisory bucket). Region
|
||||
/// configuration cannot yet permit spanning (see the module docs' gap
|
||||
/// list), so a cross-region slur always reports.
|
||||
SlurSpansRegionBoundary {
|
||||
/// The offending slur.
|
||||
slur: SlurId,
|
||||
/// The region containing the start event.
|
||||
start_region: RegionId,
|
||||
/// The (different) region containing the end event.
|
||||
end_region: RegionId,
|
||||
},
|
||||
}
|
||||
|
||||
/// Checks `kind` against every implemented advisory precondition (the
|
||||
/// authoring-mode-only bucket of Chapter 6 §6.10), evaluated against `score`
|
||||
/// — the authoring replica's current materialized graph. Returns every
|
||||
/// violation found (empty = the operation may be minted in authoring mode).
|
||||
///
|
||||
/// Replay mode never calls this: the reducer applies invariant preconditions
|
||||
/// only, so an envelope violating an advisory check still reduces cleanly
|
||||
/// (spec: advisory preconditions MAY fail silently in replay mode). The check
|
||||
/// is deliberately *conservative*: anything it cannot resolve against the
|
||||
/// graph (a missing voice, a non-musical placement, an unresolvable region
|
||||
/// bound) passes vacuously and is left to the invariant preconditions under
|
||||
/// reduction.
|
||||
pub fn advisory_violations(kind: &OperationKind, score: &Score) -> Vec<AdvisoryViolation> {
|
||||
let mut violations = Vec::new();
|
||||
match kind {
|
||||
OperationKind::InsertEvent(op) => {
|
||||
check_event_span(&op.event, score, &mut violations);
|
||||
}
|
||||
OperationKind::ModifyEvent(op) => {
|
||||
check_event_span(&op.event, score, &mut violations);
|
||||
}
|
||||
OperationKind::CreateCrossCutting(op) => {
|
||||
if let CrossCuttingValue::Slur(slur) = &op.structure {
|
||||
let start = event_region(score, slur.start_event);
|
||||
let end = event_region(score, slur.end_event);
|
||||
if let (Some(start_region), Some(end_region)) = (start, end) {
|
||||
if start_region != end_region {
|
||||
violations.push(AdvisoryViolation::SlurSpansRegionBoundary {
|
||||
slur: slur.id,
|
||||
start_region,
|
||||
end_region,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every other implemented kind's spec precondition bucket is entirely
|
||||
// invariant (see the module docs); nothing to check here.
|
||||
_ => {}
|
||||
}
|
||||
violations
|
||||
}
|
||||
|
||||
/// Reports a violation when `event`'s musical span straddles its region's
|
||||
/// musical end bound (starts strictly before it, ends strictly past it — the
|
||||
/// "would require splitting" shape). A non-musical placement, an unlocatable
|
||||
/// voice, or an unresolvable bound passes vacuously.
|
||||
fn check_event_span(
|
||||
event: &epiphany_core::Event,
|
||||
score: &Score,
|
||||
violations: &mut Vec<AdvisoryViolation>,
|
||||
) {
|
||||
let (EventPosition::Musical(position), EventDuration::Musical(duration)) =
|
||||
(event.position(), event.duration())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some((region_index, _, _)) = graph_voice_location(score, event.voice()) else {
|
||||
return;
|
||||
};
|
||||
let region = &score.canvas.regions[region_index];
|
||||
let Some(bound) = region_musical_end_bound(region) else {
|
||||
return;
|
||||
};
|
||||
let end = position.clone() + duration.clone();
|
||||
if position < &bound && end > bound {
|
||||
violations.push(AdvisoryViolation::DurationCrossesRegionBoundary {
|
||||
event: event.id(),
|
||||
region: region.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The region's end bound as a region-local musical position, when its
|
||||
/// `TimeExtent`'s end anchor expresses one: anchored to this region's own
|
||||
/// start edge with a `Musical` offset. Any other shape (wall-clock, symbolic,
|
||||
/// another region's edge) is not resolvable without the deferred tempo/measure
|
||||
/// machinery and yields `None` (the advisory check then passes vacuously).
|
||||
fn region_musical_end_bound(region: &Region) -> Option<MusicalPosition> {
|
||||
match ®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<RegionId> {
|
||||
let ev = score.events.get(event)?;
|
||||
let (region_index, _, _) = graph_voice_location(score, ev.voice())?;
|
||||
Some(score.canvas.regions[region_index].id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::payload::{CreateCrossCuttingOp, InsertEventOp, ModifyEventOp};
|
||||
use crate::valuegen;
|
||||
use epiphany_core::generators::valid_score;
|
||||
use epiphany_core::{
|
||||
EventId, MusicalDuration, MusicalPosition, PitchId, RationalTime, ReplicaId, SlurId,
|
||||
VoiceId,
|
||||
};
|
||||
|
||||
/// A fixture score whose (single) region declares a musical end bound of
|
||||
/// `bound` whole units, plus the ids needed to aim operations at it.
|
||||
fn bounded_score(bound: i32) -> (Score, RegionId, epiphany_core::StaffInstanceId, VoiceId) {
|
||||
let mut score = valid_score(7);
|
||||
let region = &mut score.canvas.regions[0];
|
||||
let region_id = region.id;
|
||||
region.time_extent.end = TimeAnchor::Region {
|
||||
id: region_id,
|
||||
edge: RegionEdge::Start,
|
||||
offset: AnchorOffset::Musical(MusicalDuration(RationalTime::from_int(bound))),
|
||||
};
|
||||
let instance = region.staff_instances()[0].id;
|
||||
let voice = region.staff_instances()[0].voices[0].id;
|
||||
(score, region_id, instance, voice)
|
||||
}
|
||||
|
||||
fn insert_kind(
|
||||
instance: epiphany_core::StaffInstanceId,
|
||||
voice: VoiceId,
|
||||
position: i32,
|
||||
duration: i32,
|
||||
) -> OperationKind {
|
||||
OperationKind::InsertEvent(InsertEventOp {
|
||||
staff_instance: instance,
|
||||
event: valuegen::insert_event_value(
|
||||
EventId::new(ReplicaId(50), 999),
|
||||
voice,
|
||||
MusicalPosition(RationalTime::from_int(position)),
|
||||
MusicalDuration(RationalTime::from_int(duration)),
|
||||
&[PitchId::new(ReplicaId(50), 998)],
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_event_crossing_the_region_end_bound_is_an_advisory_violation() {
|
||||
let (score, region, instance, voice) = bounded_score(12);
|
||||
// Starts inside the extent (10 < 12), ends past it (14 > 12).
|
||||
let kind = insert_kind(instance, voice, 10, 4);
|
||||
let violations = advisory_violations(&kind, &score);
|
||||
assert_eq!(
|
||||
violations,
|
||||
vec![AdvisoryViolation::DurationCrossesRegionBoundary {
|
||||
event: EventId::new(ReplicaId(50), 999),
|
||||
region,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_event_within_the_region_end_bound_passes() {
|
||||
let (score, _, instance, voice) = bounded_score(12);
|
||||
// Ends exactly at the bound: nothing to split, no violation.
|
||||
let kind = insert_kind(instance, voice, 10, 2);
|
||||
assert!(advisory_violations(&kind, &score).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_event_against_an_unresolvable_extent_passes_vacuously() {
|
||||
// The unmodified fixture's extent is wall-clock — no musical bound is
|
||||
// resolvable, so the boundary check cannot fire (module docs, gap 3).
|
||||
let score = valid_score(7);
|
||||
let instance = score.canvas.regions[0].staff_instances()[0].id;
|
||||
let voice = score.canvas.regions[0].staff_instances()[0].voices[0].id;
|
||||
let kind = insert_kind(instance, voice, 10, 1_000);
|
||||
assert!(advisory_violations(&kind, &score).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modify_event_crossing_the_region_end_bound_is_an_advisory_violation() {
|
||||
let (score, region, _, voice) = bounded_score(12);
|
||||
// A replacement value for an existing event, moved to straddle the
|
||||
// bound. (The advisory check reads the replacement's span; liveness of
|
||||
// the target is the reducer's invariant precondition.)
|
||||
let target = score.canvas.regions[0].staff_instances()[0].voices[0].events[0];
|
||||
let kind = OperationKind::ModifyEvent(ModifyEventOp {
|
||||
event: valuegen::insert_event_value(
|
||||
target,
|
||||
voice,
|
||||
MusicalPosition(RationalTime::from_int(11)),
|
||||
MusicalDuration(RationalTime::from_int(3)),
|
||||
&[PitchId::new(ReplicaId(50), 998)],
|
||||
),
|
||||
});
|
||||
let violations = advisory_violations(&kind, &score);
|
||||
assert_eq!(
|
||||
violations,
|
||||
vec![AdvisoryViolation::DurationCrossesRegionBoundary {
|
||||
event: target,
|
||||
region,
|
||||
}]
|
||||
);
|
||||
// The same replacement kept inside the bound passes.
|
||||
let kind = OperationKind::ModifyEvent(ModifyEventOp {
|
||||
event: valuegen::insert_event_value(
|
||||
target,
|
||||
voice,
|
||||
MusicalPosition(RationalTime::from_int(11)),
|
||||
MusicalDuration(RationalTime::from_int(1)),
|
||||
&[PitchId::new(ReplicaId(50), 998)],
|
||||
),
|
||||
});
|
||||
assert!(advisory_violations(&kind, &score).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slur_spanning_two_regions_is_an_advisory_violation() {
|
||||
// Two single-region fixture scores merged: distinct regions, each with
|
||||
// its own events.
|
||||
let mut score = valid_score(7);
|
||||
let other = valid_score(8);
|
||||
let start_region = score.canvas.regions[0].id;
|
||||
let end_region = other.canvas.regions[0].id;
|
||||
let start_event = score.canvas.regions[0].staff_instances()[0].voices[0].events[0];
|
||||
let end_event = other.canvas.regions[0].staff_instances()[0].voices[0].events[0];
|
||||
score.canvas.regions.push(other.canvas.regions[0].clone());
|
||||
for event in other.events.iter_canonical() {
|
||||
score
|
||||
.events
|
||||
.insert(event.clone())
|
||||
.expect("distinct seeds mint distinct event ids");
|
||||
}
|
||||
|
||||
let slur_id = SlurId::new(ReplicaId(50), 1);
|
||||
let cross = OperationKind::CreateCrossCutting(CreateCrossCuttingOp {
|
||||
structure: CrossCuttingValue::Slur(valuegen::slur(slur_id, start_event, end_event)),
|
||||
});
|
||||
assert_eq!(
|
||||
advisory_violations(&cross, &score),
|
||||
vec![AdvisoryViolation::SlurSpansRegionBoundary {
|
||||
slur: slur_id,
|
||||
start_region,
|
||||
end_region,
|
||||
}]
|
||||
);
|
||||
|
||||
// A slur within one region passes.
|
||||
let second = score.canvas.regions[0].staff_instances()[0].voices[0].events[1];
|
||||
let within = OperationKind::CreateCrossCutting(CreateCrossCuttingOp {
|
||||
structure: CrossCuttingValue::Slur(valuegen::slur(slur_id, start_event, second)),
|
||||
});
|
||||
assert!(advisory_violations(&within, &score).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_reduction_ignores_advisory_violations_and_is_unchanged_by_the_mode_machinery() {
|
||||
// Spec §"Validation Modes": advisory preconditions MAY fail silently
|
||||
// in replay mode. An envelope carrying an advisory-violating insert
|
||||
// reduces cleanly (Applied) through the ordinary reduction path, and
|
||||
// the reduction's canonical bytes are a pure function of the operation
|
||||
// set — consulting `advisory_violations` beforehand (as an authoring
|
||||
// layer would) changes nothing.
|
||||
use crate::causal::CausalContext;
|
||||
use crate::stamp::{HybridLogicalClock, OperationStamp};
|
||||
use crate::support::AuthorId;
|
||||
use crate::{OperationEnvelope, OperationPayload, OperationSet};
|
||||
use epiphany_core::{OperationId, WallClockTime};
|
||||
|
||||
let (score, _, instance, voice) = bounded_score(12);
|
||||
let kind = insert_kind(instance, voice, 10, 4);
|
||||
assert!(
|
||||
!advisory_violations(&kind, &score).is_empty(),
|
||||
"the scenario must actually violate an advisory precondition"
|
||||
);
|
||||
|
||||
let id = OperationId::new(ReplicaId(50), 0);
|
||||
let env = OperationEnvelope {
|
||||
id,
|
||||
author: AuthorId(0),
|
||||
stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(1), 0), id),
|
||||
causal_context: CausalContext::new(),
|
||||
transaction: None,
|
||||
payload: OperationPayload::Primitive(kind),
|
||||
};
|
||||
let mut set = OperationSet::new();
|
||||
set.accept(env);
|
||||
let first = set.reduce_onto(&score);
|
||||
assert!(first.state.is_clean(), "replay applies the insert cleanly");
|
||||
assert!(matches!(
|
||||
first.state.effects.as_slice(),
|
||||
[(applied, crate::OperationEffect::Applied)] if *applied == id
|
||||
));
|
||||
// Byte-identity across repeated reductions of the same set — the mode
|
||||
// machinery has no channel into reduction.
|
||||
let second = set.reduce_onto(&score);
|
||||
assert_eq!(
|
||||
first.state.canonical_bytes(),
|
||||
second.state.canonical_bytes()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_mode_advisory_enforcement_is_authoring_only() {
|
||||
assert!(ValidationMode::Authoring.enforces_advisory());
|
||||
assert!(!ValidationMode::Replay.enforces_advisory());
|
||||
}
|
||||
}
|
||||
|
|
@ -2,18 +2,20 @@
|
|||
//! graph rather than only the Chapter 6 bookkeeping projection.
|
||||
|
||||
use epiphany_core::{
|
||||
check_invariants, derive_promoted_voice_id, AnchorOffset, EventId, MusicalDuration,
|
||||
MusicalPosition, OperationId, PitchId, RationalTime, RegionEdge, RegionTimeModel, ReplicaId,
|
||||
Score, SlurId, StaffInstanceId, TimeAnchor, TransactionId, TypedObjectId, VoiceId, VoiceOrigin,
|
||||
WallClockTime,
|
||||
check_invariants, derive_promoted_voice_id, AnalyticalAnnotation, AnalyticalAnnotationId,
|
||||
AnchorOffset, AnnotationAnchor, Comment, CommentId, CueEvent, CueRendering, Event,
|
||||
EventDuration, EventId, EventPosition, GestureAnchoring, GraphicGesture, GraphicGestureId,
|
||||
Marker, MarkerId, MusicalDuration, MusicalPosition, OperationId, PitchId, RationalTime,
|
||||
RegionEdge, RegionTimeModel, ReplicaId, Score, SlurId, StaffInstanceId, TimeAnchor,
|
||||
TransactionId, TypedObjectId, VoiceId, VoiceOrigin, WallClockTime,
|
||||
};
|
||||
use epiphany_ops::{
|
||||
valuegen, AuthorId, CausalContext, ChangeRegionTimeModelOp, ConflictKind, CreateCrossCuttingOp,
|
||||
CrossCuttingValue, DeleteEventOp, HybridLogicalClock, InsertEventOp, NoOpReason,
|
||||
OperationEffect, OperationEnvelope, OperationKind, OperationPayload, OperationSet,
|
||||
OperationStamp, PositionRemapping, PreconditionFailureReason, SetUserSystemBreakOp,
|
||||
TransactionCategory, TransactionDescriptor, TupletCompensation, UndoPolicy,
|
||||
UndoTransactionPayload,
|
||||
OperationStamp, PositionRemapping, PreconditionFailureReason, ReanchorReason, RepairKind,
|
||||
RepairRecord, SetUserSystemBreakOp, TransactionCategory, TransactionDescriptor,
|
||||
TupletCompensation, UndoPolicy, UndoTransactionPayload,
|
||||
};
|
||||
|
||||
fn envelope(
|
||||
|
|
@ -1031,6 +1033,76 @@ fn deleting_both_slur_endpoints_cascades_in_both_graph_and_ledger() {
|
|||
assert!(check_invariants(&result.score).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_delete_tuplet_prunes_dangling_decompositions() {
|
||||
// `valid_score_rich`'s metric region is a 3:2 triplet whose first member carries an
|
||||
// in-tuplet decomposition. Cascade-deleting the tuplet — member 0 carries the
|
||||
// `CascadeDeleteTuplets`, the rest delete as ordinary (no-longer-tuplet) events —
|
||||
// must also drop that decomposition; otherwise its tuplet reference would dangle
|
||||
// (invariant 6, cross-cutting refs resolve).
|
||||
let base = epiphany_core::generators::valid_score_rich(0x5EED);
|
||||
let tuplet = base.cross_cutting.tuplets[0].id;
|
||||
let members = base.cross_cutting.tuplets[0].members.clone();
|
||||
assert_eq!(members.len(), 3, "the fixture triplet has three members");
|
||||
assert!(
|
||||
base.decomposition_attachments
|
||||
.iter()
|
||||
.any(|d| d.components.iter().any(|c| c.tuplet == Some(tuplet))),
|
||||
"the fixture has an in-tuplet decomposition referencing the triplet"
|
||||
);
|
||||
|
||||
// Three deletes in causal order: the structure-removing cascade first, so the
|
||||
// remaining members then delete as ordinary events.
|
||||
let mut ops = Vec::new();
|
||||
for (i, &member) in members.iter().enumerate() {
|
||||
let counter = (i + 1) as u64;
|
||||
let compensation = if i == 0 {
|
||||
TupletCompensation::CascadeDeleteTuplets {
|
||||
tuplets: vec![tuplet],
|
||||
}
|
||||
} else {
|
||||
TupletCompensation::NotInTuplet
|
||||
};
|
||||
ops.push(envelope(
|
||||
70,
|
||||
counter,
|
||||
10 + i as i64,
|
||||
CausalContext::new(),
|
||||
None,
|
||||
OperationPayload::Primitive(OperationKind::DeleteEvent(DeleteEventOp {
|
||||
event: member,
|
||||
tuplet_compensation: compensation,
|
||||
})),
|
||||
));
|
||||
}
|
||||
let mut set = OperationSet::new();
|
||||
set.accept_all(ops);
|
||||
let result = set.reduce_onto(&base);
|
||||
|
||||
assert!(
|
||||
result.score.cross_cutting.tuplets.is_empty(),
|
||||
"the tuplet structure is removed"
|
||||
);
|
||||
assert!(
|
||||
!result
|
||||
.score
|
||||
.decomposition_attachments
|
||||
.iter()
|
||||
.any(|d| d.components.iter().any(|c| c.tuplet == Some(tuplet))),
|
||||
"the now-orphaned decomposition is pruned"
|
||||
);
|
||||
for &member in &members {
|
||||
assert!(
|
||||
matches!(
|
||||
result.state.objects.get(&TypedObjectId::Event(member)),
|
||||
Some(epiphany_ops::ObjectState::Tombstoned { .. })
|
||||
),
|
||||
"each member is tombstoned"
|
||||
);
|
||||
}
|
||||
assert!(check_invariants(&result.score).is_empty());
|
||||
}
|
||||
|
||||
/// Helper: the effect recorded for `id` in a reduction.
|
||||
fn effect_of(result: &epiphany_ops::GraphMaterialization, id: OperationId) -> OperationEffect {
|
||||
result
|
||||
|
|
@ -1828,3 +1900,854 @@ fn create_rejects_carried_non_hierarchy_children() {
|
|||
);
|
||||
assert!(check_invariants(&result.score).is_empty());
|
||||
}
|
||||
|
||||
// === Re-anchoring rule-table coverage: markers, cue events, comments,
|
||||
// analytical annotations, graphic gestures (core_spec §"The Re-Anchoring Rule
|
||||
// Table", §"Total Ordering for Nearest"). All five kinds exist only via seeded
|
||||
// base graphs (no operation creates them), so every scenario reduces onto a
|
||||
// base. ========================================================================
|
||||
|
||||
/// The repairs of an `AppliedWithRepair` effect.
|
||||
fn repairs_of(result: &epiphany_ops::GraphMaterialization, id: OperationId) -> Vec<RepairRecord> {
|
||||
match effect_of(result, id) {
|
||||
OperationEffect::AppliedWithRepair { repairs } => repairs,
|
||||
other => panic!("expected AppliedWithRepair, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A plain (non-tuplet) DeleteEvent envelope.
|
||||
fn delete_event(
|
||||
replica: u64,
|
||||
counter: u64,
|
||||
physical: i64,
|
||||
ctx: CausalContext,
|
||||
event: EventId,
|
||||
) -> OperationEnvelope {
|
||||
envelope(
|
||||
replica,
|
||||
counter,
|
||||
physical,
|
||||
ctx,
|
||||
None,
|
||||
OperationPayload::Primitive(OperationKind::DeleteEvent(DeleteEventOp {
|
||||
event,
|
||||
tuplet_compensation: TupletCompensation::NotInTuplet,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
/// The first voice's event list (the fixture voice all these scenarios edit).
|
||||
fn first_voice_events(base: &Score) -> Vec<EventId> {
|
||||
base.canvas.regions[0].staff_instances()[0].voices[0]
|
||||
.events
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Adds a cue event sourcing `sources` to `base`'s first voice at whole-note
|
||||
/// `position` (clear of the fixture's quarter-note content in `[0, 1)`).
|
||||
fn push_cue(base: &mut Score, id: EventId, sources: Vec<EventId>, position: i32) {
|
||||
let (_, voice) = target(base);
|
||||
base.events
|
||||
.insert(Event::Cue(CueEvent {
|
||||
id,
|
||||
voice,
|
||||
position: EventPosition::Musical(MusicalPosition(RationalTime::from_int(position))),
|
||||
duration: EventDuration::Musical(MusicalDuration::whole()),
|
||||
source: sources,
|
||||
rendering: CueRendering,
|
||||
}))
|
||||
.expect("fresh cue id");
|
||||
base.canvas.regions[0]
|
||||
.content
|
||||
.staff_instances_mut()
|
||||
.expect("fixture is staff based")[0]
|
||||
.voices[0]
|
||||
.events
|
||||
.push(id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_cue_source_cascade_deletes_the_cue() {
|
||||
// Rule table, "Cue event / Source event": cascade-delete ("a cue with no
|
||||
// source is meaningless") — ledger tombstone, graph removal, and a
|
||||
// CascadeDeleted repair in the triggering delete's effect, all in the same
|
||||
// reduction step.
|
||||
let mut base = epiphany_core::generators::valid_score(100);
|
||||
let source = first_voice_events(&base)[0];
|
||||
let cue = EventId::new(ReplicaId(90), 0);
|
||||
push_cue(&mut base, cue, vec![source], 40);
|
||||
|
||||
let del = delete_event(91, 0, 10, CausalContext::new(), source);
|
||||
let mut set = OperationSet::new();
|
||||
set.accept(del.clone());
|
||||
let result = set.reduce_onto(&base);
|
||||
|
||||
assert!(
|
||||
repairs_of(&result, del.id)
|
||||
.iter()
|
||||
.any(|r| r.kind == RepairKind::CascadeDeleted && r.target == TypedObjectId::Event(cue)),
|
||||
"the cue cascade is recorded in the triggering delete's effect"
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
result.state.objects.get(&TypedObjectId::Event(cue)),
|
||||
Some(epiphany_ops::ObjectState::Tombstoned { .. })
|
||||
),
|
||||
"the cue's event id is tombstoned in the ledger"
|
||||
);
|
||||
assert!(
|
||||
!result.score.events.contains(cue),
|
||||
"the cue is removed from the event arena"
|
||||
);
|
||||
assert!(
|
||||
result.score.tombstoned_events.contains(&cue),
|
||||
"the cue is a graph tombstone"
|
||||
);
|
||||
assert!(
|
||||
!first_voice_events(&result.score).contains(&cue),
|
||||
"the cue is removed from its voice"
|
||||
);
|
||||
assert!(check_invariants(&result.score).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cue_with_multiple_sources_cascades_on_any_source_deletion() {
|
||||
// The table's action is the plain "cascade-delete" on a source deletion —
|
||||
// not truncate-while-any-source-survives (the rationale-vs-action tension
|
||||
// for multi-source cues is a proposed Pass-12 row). Either source's
|
||||
// deletion cascades the cue.
|
||||
for victim_index in [0usize, 1] {
|
||||
let mut base = epiphany_core::generators::valid_score(100);
|
||||
let events = first_voice_events(&base);
|
||||
let cue = EventId::new(ReplicaId(90), 1);
|
||||
push_cue(&mut base, cue, vec![events[0], events[1]], 40);
|
||||
|
||||
let del = delete_event(91, 0, 10, CausalContext::new(), events[victim_index]);
|
||||
let mut set = OperationSet::new();
|
||||
set.accept(del.clone());
|
||||
let result = set.reduce_onto(&base);
|
||||
|
||||
assert!(
|
||||
repairs_of(&result, del.id)
|
||||
.iter()
|
||||
.any(|r| r.kind == RepairKind::CascadeDeleted
|
||||
&& r.target == TypedObjectId::Event(cue)),
|
||||
"deleting source #{victim_index} cascades the two-source cue"
|
||||
);
|
||||
assert!(!result.score.events.contains(cue));
|
||||
assert!(check_invariants(&result.score).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cascaded_cue_reanchors_its_own_referents_transitively() {
|
||||
// A cascaded cue is itself a tombstoned event, so the same re-anchoring
|
||||
// pass runs over *its* referents in the same reduction step: a cue-of-a-cue
|
||||
// cascades along.
|
||||
let mut base = epiphany_core::generators::valid_score(100);
|
||||
let source = first_voice_events(&base)[0];
|
||||
let cue1 = EventId::new(ReplicaId(90), 2);
|
||||
let cue2 = EventId::new(ReplicaId(90), 3);
|
||||
push_cue(&mut base, cue1, vec![source], 40);
|
||||
push_cue(&mut base, cue2, vec![cue1], 44);
|
||||
|
||||
let del = delete_event(91, 0, 10, CausalContext::new(), source);
|
||||
let mut set = OperationSet::new();
|
||||
set.accept(del.clone());
|
||||
let result = set.reduce_onto(&base);
|
||||
|
||||
let repairs = repairs_of(&result, del.id);
|
||||
for cue in [cue1, cue2] {
|
||||
assert!(
|
||||
repairs
|
||||
.iter()
|
||||
.any(|r| r.kind == RepairKind::CascadeDeleted
|
||||
&& r.target == TypedObjectId::Event(cue)),
|
||||
"cue {cue:?} cascades in the same reduction step"
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
result.state.objects.get(&TypedObjectId::Event(cue)),
|
||||
Some(epiphany_ops::ObjectState::Tombstoned { .. })
|
||||
),
|
||||
"cue {cue:?} is tombstoned in the ledger"
|
||||
);
|
||||
assert!(!result.score.events.contains(cue));
|
||||
}
|
||||
assert!(check_invariants(&result.score).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_comment_anchor_orphans_the_comment() {
|
||||
// Rule table, "Comment / Anchor": orphan — user content never silently
|
||||
// deleted. The comment survives (ledger Live, graph present); its anchor
|
||||
// degrades to the containing region so invariant 10 keeps holding.
|
||||
let mut base = epiphany_core::generators::valid_score(100);
|
||||
let region = base.canvas.regions[0].id;
|
||||
let anchor_event = first_voice_events(&base)[0];
|
||||
let comment_id = CommentId::new(ReplicaId(90), 4);
|
||||
base.cross_cutting.comments.push(Comment {
|
||||
id: comment_id,
|
||||
anchor: AnnotationAnchor::Event(anchor_event),
|
||||
resolved: false,
|
||||
});
|
||||
|
||||
let del = delete_event(91, 0, 10, CausalContext::new(), anchor_event);
|
||||
let mut set = OperationSet::new();
|
||||
set.accept(del.clone());
|
||||
let result = set.reduce_onto(&base);
|
||||
|
||||
assert!(
|
||||
repairs_of(&result, del.id)
|
||||
.iter()
|
||||
.any(|r| r.kind == RepairKind::Orphaned
|
||||
&& r.target == TypedObjectId::Comment(comment_id)),
|
||||
"the orphaning is a recorded repair"
|
||||
);
|
||||
assert_eq!(
|
||||
result
|
||||
.state
|
||||
.objects
|
||||
.get(&TypedObjectId::Comment(comment_id)),
|
||||
Some(&epiphany_ops::ObjectState::Live),
|
||||
"the orphaned comment stays live in the ledger"
|
||||
);
|
||||
let comment = result
|
||||
.score
|
||||
.cross_cutting
|
||||
.comments
|
||||
.iter()
|
||||
.find(|c| c.id == comment_id)
|
||||
.expect("the orphaned comment survives in the graph");
|
||||
assert_eq!(
|
||||
comment.anchor,
|
||||
AnnotationAnchor::Region(region),
|
||||
"the dangling event anchor degrades to the containing region"
|
||||
);
|
||||
assert!(check_invariants(&result.score).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotation_reanchors_to_a_range_preserving_the_events_extent() {
|
||||
// Rule table, "Analytical annotation / Anchor": re-anchor to a time range
|
||||
// preserving the original extent. The fixture's second event spans
|
||||
// [1/4, 1/2), so the reconstructed range is region-start + 1/4 .. + 1/2.
|
||||
let mut base = epiphany_core::generators::valid_score(100);
|
||||
let region = base.canvas.regions[0].id;
|
||||
let anchor_event = first_voice_events(&base)[1];
|
||||
let annotation_id = AnalyticalAnnotationId::new(ReplicaId(90), 5);
|
||||
base.cross_cutting.analytical.push(AnalyticalAnnotation {
|
||||
id: annotation_id,
|
||||
anchor: AnnotationAnchor::Event(anchor_event),
|
||||
layer: None,
|
||||
});
|
||||
|
||||
let del = delete_event(91, 0, 10, CausalContext::new(), anchor_event);
|
||||
let mut set = OperationSet::new();
|
||||
set.accept(del.clone());
|
||||
let result = set.reduce_onto(&base);
|
||||
|
||||
assert!(
|
||||
repairs_of(&result, del.id).iter().any(|r| {
|
||||
r.target == TypedObjectId::AnalyticalAnnotation(annotation_id)
|
||||
&& r.kind
|
||||
== RepairKind::Reanchored {
|
||||
from: TypedObjectId::Event(anchor_event),
|
||||
to: TypedObjectId::Region(region),
|
||||
reason: ReanchorReason::ExplicitFallback,
|
||||
}
|
||||
}),
|
||||
"the range reconstruction is a recorded repair"
|
||||
);
|
||||
let annotation = result
|
||||
.score
|
||||
.cross_cutting
|
||||
.analytical
|
||||
.iter()
|
||||
.find(|a| a.id == annotation_id)
|
||||
.expect("the annotation survives");
|
||||
let offset_at = |num: i64, den: i64| {
|
||||
AnchorOffset::Musical(MusicalDuration(RationalTime::new(num, den).unwrap()))
|
||||
};
|
||||
assert_eq!(
|
||||
annotation.anchor,
|
||||
AnnotationAnchor::Range {
|
||||
start: TimeAnchor::Region {
|
||||
id: region,
|
||||
edge: RegionEdge::Start,
|
||||
offset: offset_at(1, 4),
|
||||
},
|
||||
end: TimeAnchor::Region {
|
||||
id: region,
|
||||
edge: RegionEdge::Start,
|
||||
offset: offset_at(1, 2),
|
||||
},
|
||||
},
|
||||
"the reconstructed range covers the deleted event's exact span"
|
||||
);
|
||||
assert!(check_invariants(&result.score).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotation_orphans_when_the_range_cannot_be_reconstructed() {
|
||||
// The extent of a wall-clock event is not expressible as a stored
|
||||
// region-relative range in this prototype (the expressibility gap is a
|
||||
// proposed Pass-12 row), so the annotation orphans: kept, anchor degraded
|
||||
// to the containing region.
|
||||
let mut base = epiphany_core::generators::valid_score_rich(0x5EED);
|
||||
let (region, wall_clock_event) = base
|
||||
.voices()
|
||||
.find_map(|(region, _, v)| {
|
||||
v.events
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|e| {
|
||||
matches!(
|
||||
base.events.get(*e).map(Event::position),
|
||||
Some(EventPosition::WallClock(_))
|
||||
)
|
||||
})
|
||||
.map(|e| (region, e))
|
||||
})
|
||||
.expect("the rich fixture has a proportional region with wall-clock events");
|
||||
let annotation_id = AnalyticalAnnotationId::new(ReplicaId(90), 6);
|
||||
base.cross_cutting.analytical.push(AnalyticalAnnotation {
|
||||
id: annotation_id,
|
||||
anchor: AnnotationAnchor::Event(wall_clock_event),
|
||||
layer: None,
|
||||
});
|
||||
|
||||
let del = delete_event(91, 0, 10, CausalContext::new(), wall_clock_event);
|
||||
let mut set = OperationSet::new();
|
||||
set.accept(del.clone());
|
||||
let result = set.reduce_onto(&base);
|
||||
|
||||
assert!(
|
||||
repairs_of(&result, del.id)
|
||||
.iter()
|
||||
.any(|r| r.kind == RepairKind::Orphaned
|
||||
&& r.target == TypedObjectId::AnalyticalAnnotation(annotation_id)),
|
||||
"an unreconstructable range orphans the annotation"
|
||||
);
|
||||
let annotation = result
|
||||
.score
|
||||
.cross_cutting
|
||||
.analytical
|
||||
.iter()
|
||||
.find(|a| a.id == annotation_id)
|
||||
.expect("the orphaned annotation survives");
|
||||
assert_eq!(annotation.anchor, AnnotationAnchor::Region(region));
|
||||
assert!(check_invariants(&result.score).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gesture_event_references_retarget_to_the_nearest_survivor() {
|
||||
// Rule table, "Graphic gesture / Anchor event": re-anchor to the nearest
|
||||
// surviving event of the same staff instance.
|
||||
let mut base = epiphany_core::generators::valid_score(100);
|
||||
let events = first_voice_events(&base);
|
||||
let (dead, survivor) = (events[0], events[1]);
|
||||
let gesture_id = GraphicGestureId::new(ReplicaId(90), 7);
|
||||
base.cross_cutting.graphic_gestures.push(GraphicGesture {
|
||||
id: gesture_id,
|
||||
objects: Vec::new(),
|
||||
anchoring: GestureAnchoring::Events(vec![dead]),
|
||||
});
|
||||
|
||||
let del = delete_event(91, 0, 10, CausalContext::new(), dead);
|
||||
let mut set = OperationSet::new();
|
||||
set.accept(del.clone());
|
||||
let result = set.reduce_onto(&base);
|
||||
|
||||
assert!(
|
||||
repairs_of(&result, del.id).iter().any(|r| {
|
||||
r.target == TypedObjectId::GraphicGesture(gesture_id)
|
||||
&& r.kind
|
||||
== RepairKind::Reanchored {
|
||||
from: TypedObjectId::Event(dead),
|
||||
to: TypedObjectId::Event(survivor),
|
||||
reason: ReanchorReason::SameVoiceNearer,
|
||||
}
|
||||
}),
|
||||
"the gesture re-target is a recorded repair"
|
||||
);
|
||||
let gesture = result
|
||||
.score
|
||||
.cross_cutting
|
||||
.graphic_gestures
|
||||
.iter()
|
||||
.find(|g| g.id == gesture_id)
|
||||
.expect("the gesture survives");
|
||||
assert_eq!(
|
||||
gesture.anchoring,
|
||||
GestureAnchoring::Events(vec![survivor]),
|
||||
"the graph reference list agrees with the recorded repair"
|
||||
);
|
||||
assert!(check_invariants(&result.score).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_anchored_gestures_ignore_event_deletion() {
|
||||
// Rule table: "for Free anchoring, no action" — a free gesture follows no
|
||||
// score content, so the delete reduces with no gesture repair.
|
||||
let mut base = epiphany_core::generators::valid_score(100);
|
||||
let dead = first_voice_events(&base)[0];
|
||||
let gesture_id = GraphicGestureId::new(ReplicaId(90), 8);
|
||||
base.cross_cutting.graphic_gestures.push(GraphicGesture {
|
||||
id: gesture_id,
|
||||
objects: Vec::new(),
|
||||
anchoring: GestureAnchoring::Free,
|
||||
});
|
||||
|
||||
let del = delete_event(91, 0, 10, CausalContext::new(), dead);
|
||||
let mut set = OperationSet::new();
|
||||
set.accept(del.clone());
|
||||
let result = set.reduce_onto(&base);
|
||||
|
||||
assert_eq!(
|
||||
effect_of(&result, del.id),
|
||||
OperationEffect::Applied,
|
||||
"no repair is recorded for a free-anchored gesture"
|
||||
);
|
||||
let gesture = result
|
||||
.score
|
||||
.cross_cutting
|
||||
.graphic_gestures
|
||||
.iter()
|
||||
.find(|g| g.id == gesture_id)
|
||||
.expect("the gesture survives");
|
||||
assert_eq!(gesture.anchoring, GestureAnchoring::Free);
|
||||
assert!(check_invariants(&result.score).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gesture_range_anchoring_truncates_to_the_region_edge() {
|
||||
// Rule table: "for Range anchoring, truncate" — the deterministic reading:
|
||||
// a dead start endpoint moves to its region's start edge (an end endpoint
|
||||
// would move to the end edge); the underdetermined "truncate" semantics is
|
||||
// a proposed Pass-12 row.
|
||||
let mut base = epiphany_core::generators::valid_score(100);
|
||||
let region = base.canvas.regions[0].id;
|
||||
let dead = first_voice_events(&base)[0];
|
||||
let gesture_id = GraphicGestureId::new(ReplicaId(90), 9);
|
||||
let end_anchor = TimeAnchor::Region {
|
||||
id: region,
|
||||
edge: RegionEdge::End,
|
||||
offset: AnchorOffset::Zero,
|
||||
};
|
||||
base.cross_cutting.graphic_gestures.push(GraphicGesture {
|
||||
id: gesture_id,
|
||||
objects: Vec::new(),
|
||||
anchoring: GestureAnchoring::Range {
|
||||
start: TimeAnchor::Event {
|
||||
id: dead,
|
||||
offset: AnchorOffset::Zero,
|
||||
},
|
||||
end: end_anchor.clone(),
|
||||
staves: Vec::new(),
|
||||
},
|
||||
});
|
||||
|
||||
let del = delete_event(91, 0, 10, CausalContext::new(), dead);
|
||||
let mut set = OperationSet::new();
|
||||
set.accept(del.clone());
|
||||
let result = set.reduce_onto(&base);
|
||||
|
||||
assert!(
|
||||
repairs_of(&result, del.id).iter().any(|r| {
|
||||
r.target == TypedObjectId::GraphicGesture(gesture_id)
|
||||
&& r.kind
|
||||
== RepairKind::Reanchored {
|
||||
from: TypedObjectId::Event(dead),
|
||||
to: TypedObjectId::Region(region),
|
||||
reason: ReanchorReason::ExplicitFallback,
|
||||
}
|
||||
}),
|
||||
"the range truncation is a recorded repair"
|
||||
);
|
||||
let gesture = result
|
||||
.score
|
||||
.cross_cutting
|
||||
.graphic_gestures
|
||||
.iter()
|
||||
.find(|g| g.id == gesture_id)
|
||||
.expect("the gesture survives");
|
||||
assert_eq!(
|
||||
gesture.anchoring,
|
||||
GestureAnchoring::Range {
|
||||
start: TimeAnchor::Region {
|
||||
id: region,
|
||||
edge: RegionEdge::Start,
|
||||
offset: AnchorOffset::Zero,
|
||||
},
|
||||
end: end_anchor,
|
||||
staves: Vec::new(),
|
||||
},
|
||||
"the dead start endpoint moved to the region's start edge"
|
||||
);
|
||||
assert!(check_invariants(&result.score).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_reanchor_breaks_full_ties_by_ascending_event_id() {
|
||||
// Four-key ordering, key 4: with equal proximity rank, distance, and
|
||||
// direction, the ascending typed-id byte order decides. The referent sits
|
||||
// alone in its own voice; two candidates in two sibling voices share its
|
||||
// exact position (rank 1, distance 0, forward) — the smaller EventId wins
|
||||
// even though it was authored later and lives in the higher-id voice.
|
||||
let mut base = epiphany_core::generators::valid_score(100);
|
||||
let (staff_instance, _) = target(&base);
|
||||
let referent_voice = VoiceId::new(ReplicaId(9), 77);
|
||||
let voice_b = VoiceId::new(ReplicaId(9), 78);
|
||||
let voice_c = VoiceId::new(ReplicaId(9), 79);
|
||||
{
|
||||
let instances = base.canvas.regions[0]
|
||||
.content
|
||||
.staff_instances_mut()
|
||||
.expect("fixture is staff based");
|
||||
instances[0].voices.push(valuegen::voice(referent_voice));
|
||||
instances[0].voices.push(valuegen::voice(voice_b));
|
||||
instances[0].voices.push(valuegen::voice(voice_c));
|
||||
}
|
||||
let referent = EventId::new(ReplicaId(95), 50);
|
||||
let larger_id = EventId::new(ReplicaId(95), 9);
|
||||
let smaller_id = EventId::new(ReplicaId(95), 3);
|
||||
let marker_id = MarkerId::new(ReplicaId(90), 10);
|
||||
base.cross_cutting.markers.push(Marker {
|
||||
id: marker_id,
|
||||
anchor: TimeAnchor::Event {
|
||||
id: referent,
|
||||
offset: AnchorOffset::Zero,
|
||||
},
|
||||
});
|
||||
|
||||
let ins = |counter: u64, event: EventId, voice: VoiceId, pitch: u64| {
|
||||
let ctx = if counter == 0 {
|
||||
CausalContext::new()
|
||||
} else {
|
||||
CausalContext::new().with_seen(ReplicaId(95), counter - 1)
|
||||
};
|
||||
envelope(
|
||||
95,
|
||||
counter,
|
||||
10 + counter as i64,
|
||||
ctx,
|
||||
None,
|
||||
insert(
|
||||
staff_instance,
|
||||
voice,
|
||||
event,
|
||||
PitchId::new(ReplicaId(95), 100 + pitch),
|
||||
100,
|
||||
),
|
||||
)
|
||||
};
|
||||
let del = delete_event(
|
||||
95,
|
||||
3,
|
||||
20,
|
||||
CausalContext::new().with_seen(ReplicaId(95), 2),
|
||||
referent,
|
||||
);
|
||||
let mut set = OperationSet::new();
|
||||
set.accept_all(vec![
|
||||
ins(0, referent, referent_voice, 0),
|
||||
ins(1, larger_id, voice_b, 1),
|
||||
ins(2, smaller_id, voice_c, 2),
|
||||
del.clone(),
|
||||
]);
|
||||
let result = set.reduce_onto(&base);
|
||||
|
||||
assert!(
|
||||
repairs_of(&result, del.id).iter().any(|r| {
|
||||
r.target == TypedObjectId::Marker(marker_id)
|
||||
&& r.kind
|
||||
== RepairKind::Reanchored {
|
||||
from: TypedObjectId::Event(referent),
|
||||
to: TypedObjectId::Event(smaller_id),
|
||||
reason: ReanchorReason::SameStaffInstanceNearer,
|
||||
}
|
||||
}),
|
||||
"a full tie falls to the ascending typed-id byte order"
|
||||
);
|
||||
assert!(check_invariants(&result.score).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_orphans_when_the_staff_instance_has_no_other_live_event() {
|
||||
// Rule table, "Marker / Anchor": proximity max is the same staff instance,
|
||||
// orphan on failure. Every event of the marker's staff instance is deleted
|
||||
// (the anchored one last), so no candidate survives within the bound; the
|
||||
// marker is kept and its anchor degrades to the region start.
|
||||
let mut base = epiphany_core::generators::valid_score(100);
|
||||
let region = base.canvas.regions[0].id;
|
||||
let instance_events: Vec<EventId> = base.canvas.regions[0].staff_instances()[0]
|
||||
.voices
|
||||
.iter()
|
||||
.flat_map(|v| v.events.clone())
|
||||
.collect();
|
||||
let marked = instance_events[0];
|
||||
let marker_id = MarkerId::new(ReplicaId(90), 11);
|
||||
base.cross_cutting.markers.push(Marker {
|
||||
id: marker_id,
|
||||
anchor: TimeAnchor::Event {
|
||||
id: marked,
|
||||
offset: AnchorOffset::Zero,
|
||||
},
|
||||
});
|
||||
|
||||
let mut order: Vec<EventId> = instance_events
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|e| *e != marked)
|
||||
.collect();
|
||||
order.push(marked);
|
||||
let ops: Vec<OperationEnvelope> = order
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &event)| {
|
||||
let ctx = if i == 0 {
|
||||
CausalContext::new()
|
||||
} else {
|
||||
CausalContext::new().with_seen(ReplicaId(96), i as u64 - 1)
|
||||
};
|
||||
delete_event(96, i as u64, 10 + i as i64, ctx, event)
|
||||
})
|
||||
.collect();
|
||||
let last = ops.last().expect("at least one delete").clone();
|
||||
let mut set = OperationSet::new();
|
||||
set.accept_all(ops);
|
||||
let result = set.reduce_onto(&base);
|
||||
|
||||
assert!(
|
||||
repairs_of(&result, last.id).iter().any(|r| r.kind == RepairKind::Orphaned
|
||||
&& r.target == TypedObjectId::Marker(marker_id)),
|
||||
"the marker orphans when its staff instance has no other live event"
|
||||
);
|
||||
assert_eq!(
|
||||
result.state.objects.get(&TypedObjectId::Marker(marker_id)),
|
||||
Some(&epiphany_ops::ObjectState::Live),
|
||||
"the orphaned marker stays live in the ledger"
|
||||
);
|
||||
let marker = result
|
||||
.score
|
||||
.cross_cutting
|
||||
.markers
|
||||
.iter()
|
||||
.find(|m| m.id == marker_id)
|
||||
.expect("the orphaned marker survives in the graph");
|
||||
assert_eq!(
|
||||
marker.anchor,
|
||||
TimeAnchor::Region {
|
||||
id: region,
|
||||
edge: RegionEdge::Start,
|
||||
offset: AnchorOffset::Zero,
|
||||
},
|
||||
"the dangling anchor degrades to the region start"
|
||||
);
|
||||
assert!(check_invariants(&result.score).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slur_reanchor_reason_names_the_survivors_containment_rank() {
|
||||
// The Reanchored reason on a slur's surviving-endpoint collapse names the
|
||||
// survivor's actual containment proximity to the tombstoned endpoint —
|
||||
// same voice → SameVoiceNearer, sibling voice in the same staff instance →
|
||||
// SameStaffInstanceNearer — instead of a hardcoded same-voice claim.
|
||||
let mut base = epiphany_core::generators::valid_score(100);
|
||||
let (staff_instance, voice_a) = target(&base);
|
||||
let voice_b = VoiceId::new(ReplicaId(9), 80);
|
||||
base.canvas.regions[0]
|
||||
.content
|
||||
.staff_instances_mut()
|
||||
.expect("fixture is staff based")[0]
|
||||
.voices
|
||||
.push(valuegen::voice(voice_b));
|
||||
|
||||
let r = 97;
|
||||
let e1 = EventId::new(ReplicaId(r), 0);
|
||||
let e2 = EventId::new(ReplicaId(r), 1);
|
||||
let e3 = EventId::new(ReplicaId(r), 2);
|
||||
let cross_slur = SlurId::new(ReplicaId(r), 10);
|
||||
let same_slur = SlurId::new(ReplicaId(r), 11);
|
||||
let step = |counter: u64, payload: OperationPayload| {
|
||||
let ctx = if counter == 0 {
|
||||
CausalContext::new()
|
||||
} else {
|
||||
CausalContext::new().with_seen(ReplicaId(r), counter - 1)
|
||||
};
|
||||
envelope(r, counter, 10 + counter as i64, ctx, None, payload)
|
||||
};
|
||||
let create = |slur: SlurId, a: EventId, b: EventId| {
|
||||
OperationPayload::Primitive(OperationKind::CreateCrossCutting(CreateCrossCuttingOp {
|
||||
structure: CrossCuttingValue::Slur(valuegen::slur(slur, a, b)),
|
||||
}))
|
||||
};
|
||||
let del = step(
|
||||
5,
|
||||
OperationPayload::Primitive(OperationKind::DeleteEvent(DeleteEventOp {
|
||||
event: e1,
|
||||
tuplet_compensation: TupletCompensation::NotInTuplet,
|
||||
})),
|
||||
);
|
||||
let mut set = OperationSet::new();
|
||||
set.accept_all(vec![
|
||||
step(
|
||||
0,
|
||||
insert(
|
||||
staff_instance,
|
||||
voice_a,
|
||||
e1,
|
||||
PitchId::new(ReplicaId(r), 100),
|
||||
100,
|
||||
),
|
||||
),
|
||||
step(
|
||||
1,
|
||||
insert(
|
||||
staff_instance,
|
||||
voice_b,
|
||||
e2,
|
||||
PitchId::new(ReplicaId(r), 101),
|
||||
101,
|
||||
),
|
||||
),
|
||||
step(
|
||||
2,
|
||||
insert(
|
||||
staff_instance,
|
||||
voice_a,
|
||||
e3,
|
||||
PitchId::new(ReplicaId(r), 102),
|
||||
102,
|
||||
),
|
||||
),
|
||||
step(3, create(cross_slur, e1, e2)),
|
||||
step(4, create(same_slur, e3, e1)),
|
||||
del.clone(),
|
||||
]);
|
||||
let result = set.reduce_onto(&base);
|
||||
|
||||
let repairs = repairs_of(&result, del.id);
|
||||
assert!(
|
||||
repairs.iter().any(|rec| {
|
||||
rec.target == TypedObjectId::Slur(cross_slur)
|
||||
&& rec.kind
|
||||
== RepairKind::Reanchored {
|
||||
from: TypedObjectId::Event(e1),
|
||||
to: TypedObjectId::Event(e2),
|
||||
reason: ReanchorReason::SameStaffInstanceNearer,
|
||||
}
|
||||
}),
|
||||
"a sibling-voice survivor is SameStaffInstanceNearer: {repairs:?}"
|
||||
);
|
||||
assert!(
|
||||
repairs.iter().any(|rec| {
|
||||
rec.target == TypedObjectId::Slur(same_slur)
|
||||
&& rec.kind
|
||||
== RepairKind::Reanchored {
|
||||
from: TypedObjectId::Event(e1),
|
||||
to: TypedObjectId::Event(e3),
|
||||
reason: ReanchorReason::SameVoiceNearer,
|
||||
}
|
||||
}),
|
||||
"a same-voice survivor is SameVoiceNearer: {repairs:?}"
|
||||
);
|
||||
assert!(check_invariants(&result.score).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn referent_reanchoring_is_permutation_invariant() {
|
||||
// The new rule-table rows are functions of canonical order and canonical
|
||||
// state: a marker re-anchor (with distance and direction tie-breaks), a
|
||||
// cue cascade, and a comment orphan reduce to byte-identical materialized
|
||||
// state under any delivery permutation.
|
||||
let mut base = epiphany_core::generators::valid_score(100);
|
||||
let (staff_instance, voice_a) = target(&base);
|
||||
let source = first_voice_events(&base)[0];
|
||||
let cue = EventId::new(ReplicaId(90), 20);
|
||||
push_cue(&mut base, cue, vec![source], 40);
|
||||
let comment_id = CommentId::new(ReplicaId(90), 21);
|
||||
base.cross_cutting.comments.push(Comment {
|
||||
id: comment_id,
|
||||
anchor: AnnotationAnchor::Event(source),
|
||||
resolved: false,
|
||||
});
|
||||
let referent = EventId::new(ReplicaId(98), 1);
|
||||
let marker_id = MarkerId::new(ReplicaId(90), 22);
|
||||
base.cross_cutting.markers.push(Marker {
|
||||
id: marker_id,
|
||||
anchor: TimeAnchor::Event {
|
||||
id: referent,
|
||||
offset: AnchorOffset::Zero,
|
||||
},
|
||||
});
|
||||
|
||||
let ins = |counter: u64, event: u64, position: i32| {
|
||||
let ctx = if counter == 0 {
|
||||
CausalContext::new()
|
||||
} else {
|
||||
CausalContext::new().with_seen(ReplicaId(98), counter - 1)
|
||||
};
|
||||
envelope(
|
||||
98,
|
||||
counter,
|
||||
10 + counter as i64,
|
||||
ctx,
|
||||
None,
|
||||
insert(
|
||||
staff_instance,
|
||||
voice_a,
|
||||
EventId::new(ReplicaId(98), event),
|
||||
PitchId::new(ReplicaId(98), 100 + event),
|
||||
position,
|
||||
),
|
||||
)
|
||||
};
|
||||
let envelopes = vec![
|
||||
ins(0, 0, 10),
|
||||
ins(1, 1, 12),
|
||||
ins(2, 2, 14),
|
||||
delete_event(
|
||||
98,
|
||||
3,
|
||||
20,
|
||||
CausalContext::new().with_seen(ReplicaId(98), 2),
|
||||
referent,
|
||||
),
|
||||
delete_event(
|
||||
98,
|
||||
4,
|
||||
21,
|
||||
CausalContext::new().with_seen(ReplicaId(98), 3),
|
||||
source,
|
||||
),
|
||||
];
|
||||
|
||||
let mut reference_set = OperationSet::new();
|
||||
reference_set.accept_all(envelopes.clone());
|
||||
let reference = reference_set.reduce_onto(&base);
|
||||
assert!(check_invariants(&reference.score).is_empty());
|
||||
// Non-vacuity: all three rows actually fired.
|
||||
assert!(!reference.score.events.contains(cue), "the cue cascaded");
|
||||
let permutations: [[usize; 5]; 4] = [
|
||||
[4, 3, 2, 1, 0],
|
||||
[2, 4, 0, 3, 1],
|
||||
[3, 0, 4, 1, 2],
|
||||
[1, 2, 3, 4, 0],
|
||||
];
|
||||
for (k, permutation) in permutations.iter().enumerate() {
|
||||
let mut set = OperationSet::new();
|
||||
set.accept_all(permutation.iter().map(|&i| envelopes[i].clone()));
|
||||
let got = set.reduce_onto(&base);
|
||||
assert_eq!(
|
||||
got, reference,
|
||||
"delivery permutation #{k} changed the materialized graph"
|
||||
);
|
||||
assert_eq!(
|
||||
got.state.canonical_bytes(),
|
||||
reference.state.canonical_bytes(),
|
||||
"delivery permutation #{k} changed the canonical bytes"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -14,10 +14,14 @@
|
|||
//! > generation when the crash precedes the durable flush.
|
||||
|
||||
use epiphany_bundle::{
|
||||
pack_operation_blocks, Bundle, CommitContext, CrashPoint, DocumentId, FaultStore, FileUuid,
|
||||
Manifest, MemStore, Slot, StagedChunk, Tear,
|
||||
encode_block, envelope_offsets, pack_operation_blocks, BlockStore, Bundle, BundleError,
|
||||
CommitContext, CrashPoint, DocumentId, ExtensionDeclaration, ExtensionId, FaultStore, FileUuid,
|
||||
IndexedBlock, Manifest, MemStore, OperationIndex, SemVer, Slot, StagedChunk, Tear,
|
||||
};
|
||||
use epiphany_determinism::CanonicalEncode;
|
||||
use epiphany_ops::{peek_operation_id, OperationEnvelope};
|
||||
|
||||
use crate::generators;
|
||||
use crate::rng::Rng;
|
||||
|
||||
// --- Re-exported authoritative gates from Agent D --------------------------
|
||||
|
|
@ -233,10 +237,419 @@ pub fn run_manifest_selection(seed: u64) {
|
|||
assert_selection_through_commits(seed);
|
||||
}
|
||||
|
||||
// --- The operation-index harness (Chapter 8 §"The Operation Index") --------
|
||||
//
|
||||
// The C/D seam: `epiphany-ops` vouches that a canonical envelope *leads* with
|
||||
// its 16 operation-id bytes (`peek_operation_id`); the bundle records those
|
||||
// raw bytes against `(block ChunkRef, offset-in-decoded-payload)` coordinates
|
||||
// (`envelope_offsets` + `OperationIndex`) without ever interpreting an
|
||||
// envelope. The index is an acceleration structure, not canonical: absent →
|
||||
// rebuild by scanning blocks; present but stale or corrupt → reject and
|
||||
// rebuild, never bundle corruption.
|
||||
|
||||
/// Stages real canonical envelope encodings into operation blocks of at most
|
||||
/// `per_block` envelopes each (forcing a multi-block layout regardless of the
|
||||
/// 1 MiB soft target, so index ordinals are actually exercised).
|
||||
fn staged_envelope_blocks(envelopes: &[OperationEnvelope], per_block: usize) -> Vec<StagedChunk> {
|
||||
envelopes
|
||||
.chunks(per_block)
|
||||
.map(|group| {
|
||||
let payloads: Vec<Vec<u8>> = group.iter().map(|e| e.to_canonical_bytes()).collect();
|
||||
StagedChunk::operation_block(encode_block(&payloads))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The reader-side rebuild the spec mandates when no usable index exists:
|
||||
/// scan every operation block the manifest references, recover each
|
||||
/// envelope's `(offset, bytes)` with [`envelope_offsets`], peek the leading
|
||||
/// operation-id bytes with ops' [`peek_operation_id`], and build a fresh
|
||||
/// [`OperationIndex`]. The same procedure serves the writer at commit time
|
||||
/// (the spec's SHOULD-rebuild-on-commit), from the committed block refs.
|
||||
pub fn scan_rebuild_operation_index<S: BlockStore>(bundle: &Bundle<S>) -> OperationIndex {
|
||||
let blocks: Vec<IndexedBlock> = bundle
|
||||
.manifest()
|
||||
.operation_roots
|
||||
.iter()
|
||||
.map(|root| {
|
||||
let payload = bundle.read_chunk(root).expect("operation block reads");
|
||||
let entries = envelope_offsets(&payload)
|
||||
.expect("a committed block payload decodes")
|
||||
.into_iter()
|
||||
.map(|(offset, bytes)| {
|
||||
let id = peek_operation_id(bytes)
|
||||
.expect("a canonical envelope leads with its 16 id bytes");
|
||||
(id.canonical_bytes(), offset)
|
||||
})
|
||||
.collect();
|
||||
(*root, entries)
|
||||
})
|
||||
.collect();
|
||||
OperationIndex::build(&blocks).expect("scanned blocks build a valid index")
|
||||
}
|
||||
|
||||
/// Commits `index` as an operation-index chunk and wires it into the
|
||||
/// manifest's `operation_index_root` — the writer's commit-time update.
|
||||
fn commit_index<S: BlockStore>(bundle: &mut Bundle<S>, index: &OperationIndex) {
|
||||
bundle
|
||||
.commit(&[StagedChunk::operation_index(index.encode())], |ctx| {
|
||||
let mut m = ctx.previous_manifest.clone();
|
||||
m.operation_index_root = Some(ctx.new_chunks[0]);
|
||||
m
|
||||
})
|
||||
.expect("commit operation index");
|
||||
}
|
||||
|
||||
/// Locates every envelope through the index and verifies each `(block,
|
||||
/// offset)` coordinate addresses exactly that envelope's canonical bytes in
|
||||
/// the block's decoded payload — plus a miss for an id no envelope uses.
|
||||
fn assert_index_locates_all<S: BlockStore>(
|
||||
bundle: &Bundle<S>,
|
||||
index: &OperationIndex,
|
||||
envelopes: &[OperationEnvelope],
|
||||
) {
|
||||
for env in envelopes {
|
||||
let id = env.id.canonical_bytes();
|
||||
let (block_ref, offset) = index
|
||||
.locate(&id)
|
||||
.unwrap_or_else(|| panic!("operation {:?} missing from the index", env.id));
|
||||
let payload = bundle.read_chunk(block_ref).expect("indexed block reads");
|
||||
let pairs = envelope_offsets(&payload).expect("indexed block payload decodes");
|
||||
let (_, bytes) = pairs
|
||||
.iter()
|
||||
.find(|(off, _)| *off == offset)
|
||||
.expect("the indexed offset lands on an envelope boundary");
|
||||
assert_eq!(
|
||||
*bytes,
|
||||
env.to_canonical_bytes().as_slice(),
|
||||
"the (block, offset) coordinate must address exactly this envelope's bytes"
|
||||
);
|
||||
// The bundle-opaque bytes really lead with this operation's id — the
|
||||
// ops-side half of the seam.
|
||||
assert_eq!(peek_operation_id(bytes), Some(env.id));
|
||||
// The kind-checked block read path sees the same envelope.
|
||||
let envs = bundle
|
||||
.read_operation_block(block_ref)
|
||||
.expect("kind-checked block read");
|
||||
assert!(envs.iter().any(|e| e.as_slice() == *bytes));
|
||||
}
|
||||
assert_eq!(
|
||||
index.locate(&[0xFF; 16]),
|
||||
None,
|
||||
"an id no envelope uses must miss"
|
||||
);
|
||||
}
|
||||
|
||||
/// End-to-end: generate real envelopes, pack multi-block, commit, build the
|
||||
/// index at commit time and wire it into `operation_index_root`, reopen, and
|
||||
/// verify the index is usable and locates every operation at its exact bytes.
|
||||
pub fn assert_operation_index_end_to_end(seed: u64) {
|
||||
let mut rng = Rng::new(seed);
|
||||
let envelopes = generators::operation_envelopes(&mut rng, 36, 3, 8, 8);
|
||||
let uuid = FileUuid(rng.array16());
|
||||
let doc = DocumentId(rng.array16());
|
||||
let mut bundle =
|
||||
Bundle::create(MemStore::new(), uuid, Manifest::empty(doc)).expect("create bundle");
|
||||
bundle
|
||||
.commit(&staged_envelope_blocks(&envelopes, 12), append_roots)
|
||||
.expect("commit operation blocks");
|
||||
|
||||
// Writer side: rebuild the index at commit time from the committed block
|
||||
// refs (the spec's SHOULD) and reference it from the manifest.
|
||||
let index = scan_rebuild_operation_index(&bundle);
|
||||
assert!(
|
||||
index.blocks().len() >= 2,
|
||||
"the fixture must span multiple blocks to exercise ordinals"
|
||||
);
|
||||
assert_eq!(index.entries().len(), envelopes.len());
|
||||
commit_index(&mut bundle, &index);
|
||||
|
||||
// Reader side: reopen from the durable image; the fresh index is usable
|
||||
// and locates every operation.
|
||||
let image = bundle.into_store().into_bytes();
|
||||
let reopened = Bundle::open(MemStore::from_bytes(image)).expect("reopen");
|
||||
let usable = reopened
|
||||
.usable_operation_index()
|
||||
.expect("a fresh, covering index is usable");
|
||||
assert_eq!(usable, index, "the index round-trips through storage");
|
||||
assert_index_locates_all(&reopened, &usable, &envelopes);
|
||||
}
|
||||
|
||||
/// A commit that grows the operation set *without* updating the index leaves
|
||||
/// a stale index: intact as a chunk, but no longer covering the operation
|
||||
/// roots. Readers must reject it (`usable_operation_index` → `None`) and a
|
||||
/// scan-rebuild must produce a fresh valid index (spec §"The Operation
|
||||
/// Index": present but stale → reject and rebuild from blocks).
|
||||
pub fn assert_stale_operation_index_rejected_and_rebuilt(seed: u64) {
|
||||
let mut rng = Rng::new(seed);
|
||||
// One authoring session, so ids are unique across both phases.
|
||||
let envelopes = generators::operation_envelopes(&mut rng, 48, 3, 8, 8);
|
||||
let (first, later) = envelopes.split_at(36);
|
||||
|
||||
let uuid = FileUuid(rng.array16());
|
||||
let doc = DocumentId(rng.array16());
|
||||
let mut bundle =
|
||||
Bundle::create(MemStore::new(), uuid, Manifest::empty(doc)).expect("create bundle");
|
||||
bundle
|
||||
.commit(&staged_envelope_blocks(first, 12), append_roots)
|
||||
.expect("commit first blocks");
|
||||
let index = scan_rebuild_operation_index(&bundle);
|
||||
commit_index(&mut bundle, &index);
|
||||
assert!(bundle.usable_operation_index().is_some());
|
||||
|
||||
// Grow the operation set WITHOUT updating the index (the closure carries
|
||||
// the old `operation_index_root` forward).
|
||||
bundle
|
||||
.commit(&staged_envelope_blocks(later, 6), append_roots)
|
||||
.expect("commit later blocks");
|
||||
let root = bundle
|
||||
.manifest()
|
||||
.operation_index_root
|
||||
.expect("the stale index is still referenced");
|
||||
// The chunk itself is intact — readable and well-formed —
|
||||
let stale = bundle
|
||||
.read_operation_index(&root)
|
||||
.expect("the stale index chunk still reads and decodes");
|
||||
// — but STALE: its block set no longer equals the operation roots.
|
||||
assert!(!stale.covers(&bundle.manifest().operation_roots));
|
||||
assert!(
|
||||
bundle.usable_operation_index().is_none(),
|
||||
"a stale index must be rejected"
|
||||
);
|
||||
|
||||
// The same verdict from a cold reopen; then rebuild from blocks.
|
||||
let image = bundle.into_store().into_bytes();
|
||||
let mut reopened = Bundle::open(MemStore::from_bytes(image)).expect("reopen");
|
||||
assert!(reopened.usable_operation_index().is_none());
|
||||
let rebuilt = scan_rebuild_operation_index(&reopened);
|
||||
commit_index(&mut reopened, &rebuilt);
|
||||
let usable = reopened
|
||||
.usable_operation_index()
|
||||
.expect("the rebuilt index is usable");
|
||||
assert_eq!(usable, rebuilt);
|
||||
assert_index_locates_all(&reopened, &usable, &envelopes);
|
||||
}
|
||||
|
||||
/// A defective operation index — a garbage payload staged as the index chunk,
|
||||
/// or on-disk corruption of a valid index chunk's bytes — must be rejected
|
||||
/// *without* being treated as bundle corruption (Chapter 8 §"Canonical and
|
||||
/// Non-Canonical Manifest Roots"): the bundle still opens cleanly, canonical
|
||||
/// chunks verify, canonical reads work, and only `usable_operation_index`
|
||||
/// says `None` (rebuild from blocks).
|
||||
pub fn assert_corrupt_operation_index_is_not_bundle_corruption(seed: u64) {
|
||||
let mut rng = Rng::new(seed);
|
||||
let envelopes = generators::operation_envelopes(&mut rng, 24, 3, 8, 8);
|
||||
let uuid = FileUuid(rng.array16());
|
||||
let doc = DocumentId(rng.array16());
|
||||
let mut bundle =
|
||||
Bundle::create(MemStore::new(), uuid, Manifest::empty(doc)).expect("create bundle");
|
||||
bundle
|
||||
.commit(&staged_envelope_blocks(&envelopes, 8), append_roots)
|
||||
.expect("commit operation blocks");
|
||||
|
||||
// (a) A garbage index chunk, staged and referenced like a real one. The
|
||||
// commit succeeds (the bundle does not interpret non-canonical chunks);
|
||||
// readers must reject it and rebuild.
|
||||
bundle
|
||||
.commit(
|
||||
&[StagedChunk::operation_index(b"not an index".to_vec())],
|
||||
|ctx| {
|
||||
let mut m = ctx.previous_manifest.clone();
|
||||
m.operation_index_root = Some(ctx.new_chunks[0]);
|
||||
m
|
||||
},
|
||||
)
|
||||
.expect("commit garbage index chunk");
|
||||
let image = bundle.into_store().into_bytes();
|
||||
let mut reopened = Bundle::open(MemStore::from_bytes(image))
|
||||
.expect("a defective index must not prevent opening");
|
||||
assert!(reopened.anomalies().is_empty());
|
||||
assert!(!reopened.is_read_only());
|
||||
reopened
|
||||
.verify_canonical_chunks()
|
||||
.expect("canonical chunks are intact");
|
||||
assert!(
|
||||
reopened.usable_operation_index().is_none(),
|
||||
"a malformed index is rejected: rebuild, not corruption"
|
||||
);
|
||||
for root in &reopened.manifest().operation_roots.clone() {
|
||||
reopened
|
||||
.read_operation_block(root)
|
||||
.expect("canonical block reads are unaffected");
|
||||
}
|
||||
// Rebuild-from-blocks restores a usable index.
|
||||
let rebuilt = scan_rebuild_operation_index(&reopened);
|
||||
commit_index(&mut reopened, &rebuilt);
|
||||
let usable = reopened
|
||||
.usable_operation_index()
|
||||
.expect("the rebuilt index is usable");
|
||||
assert_index_locates_all(&reopened, &usable, &envelopes);
|
||||
|
||||
// (b) On-disk corruption of the now-valid index chunk's payload region.
|
||||
let valid_image = reopened.into_store().into_bytes();
|
||||
let probe = Bundle::open(MemStore::from_bytes(valid_image.clone())).expect("reopen");
|
||||
let root = probe
|
||||
.manifest()
|
||||
.operation_index_root
|
||||
.expect("the index is referenced");
|
||||
let mut corrupt = valid_image;
|
||||
corrupt[(root.offset + 3) as usize] ^= 0xFF;
|
||||
let reopened = Bundle::open(MemStore::from_bytes(corrupt))
|
||||
.expect("index-region corruption must not prevent opening");
|
||||
assert!(reopened.anomalies().is_empty());
|
||||
reopened
|
||||
.verify_canonical_chunks()
|
||||
.expect("canonical chunks are intact");
|
||||
// The raw read surfaces the hash defect for diagnostics …
|
||||
assert!(
|
||||
matches!(
|
||||
reopened.read_operation_index(&root),
|
||||
Err(BundleError::ChunkHashMismatch { .. })
|
||||
),
|
||||
"the raw index read reports the hash mismatch"
|
||||
);
|
||||
// … while the reject-and-rebuild gate simply declares it unusable.
|
||||
assert!(reopened.usable_operation_index().is_none());
|
||||
for root in &reopened.manifest().operation_roots {
|
||||
reopened
|
||||
.read_operation_block(root)
|
||||
.expect("canonical block reads are unaffected");
|
||||
}
|
||||
}
|
||||
|
||||
// --- The edit-barrier declaration harness (Chapter 8 §"Forward Compatibility
|
||||
// and Edit Barriers" / §"Behavior Under Unknown Extensions") ------------------
|
||||
|
||||
/// The end-to-end edit-barrier round-trip: a manifest [`ExtensionDeclaration`]
|
||||
/// carrying *really-encoded* barrier blobs (the provisional canonical byte
|
||||
/// form owned by `epiphany-layout-ir`) commits, reopens byte-verbatim — the
|
||||
/// bundle preserves the blobs opaquely, exactly as it preserves unknown
|
||||
/// extension chunks — decodes through the owning layer's codec, and
|
||||
/// *evaluates*: the reopened barrier prohibits exactly the edits the authored
|
||||
/// one did.
|
||||
pub fn run_barrier_declaration_roundtrip(seed: u64) {
|
||||
use epiphany_core::{EventId, TypedObjectId};
|
||||
use epiphany_layout_ir::{
|
||||
decode_affected_object_kinds, decode_edit_barriers, encode_affected_object_kinds,
|
||||
encode_edit_barriers, AlwaysLiveOracle, BarrierCondition, BarrierScope, EditBarrier,
|
||||
EditContext, ObjectKind, OperationKindTag,
|
||||
};
|
||||
|
||||
let mut rng = Rng::new(seed);
|
||||
let protected = TypedObjectId::Event(EventId::from_raw(rng.next_u64() as u128));
|
||||
// One deterministic barrier the evaluation half asserts against, plus one
|
||||
// generated barrier so arbitrary shapes ride the same declaration.
|
||||
let locked_barrier = EditBarrier {
|
||||
scope: BarrierScope::ObjectSet(vec![protected]),
|
||||
affected_object_kinds: vec![ObjectKind::of(&protected)],
|
||||
prohibited_operation_kinds: vec![OperationKindTag::DeleteEvent],
|
||||
condition: BarrierCondition::Always,
|
||||
};
|
||||
let authored = vec![
|
||||
locked_barrier.clone(),
|
||||
crate::layout_stub::gen_edit_barrier(&mut rng),
|
||||
];
|
||||
let kinds = vec![ObjectKind::of(&protected)];
|
||||
|
||||
let declaration = ExtensionDeclaration {
|
||||
extension_id: ExtensionId(rng.array16()),
|
||||
version: SemVer {
|
||||
major: 1,
|
||||
minor: 0,
|
||||
patch: 0,
|
||||
},
|
||||
required: false,
|
||||
preserved_chunk_roots: Vec::new(),
|
||||
affected_object_kinds: encode_affected_object_kinds(&kinds),
|
||||
edit_barriers: encode_edit_barriers(&authored),
|
||||
};
|
||||
let extension_id = declaration.extension_id;
|
||||
let barrier_bytes = declaration.edit_barriers.clone();
|
||||
let kind_bytes = declaration.affected_object_kinds.clone();
|
||||
|
||||
// Commit a manifest carrying the declaration; reopen from the raw image.
|
||||
let uuid = FileUuid(rng.array16());
|
||||
let doc = DocumentId(rng.array16());
|
||||
let mut bundle =
|
||||
Bundle::create(MemStore::new(), uuid, Manifest::empty(doc)).expect("create bundle");
|
||||
bundle
|
||||
.commit(&staged(&[rng.byte_vec(1, 40)]), |ctx| {
|
||||
let mut m = append_roots(ctx);
|
||||
m.extension_declarations.push(declaration.clone());
|
||||
m
|
||||
})
|
||||
.expect("commit the declaration");
|
||||
let image = bundle.into_store().into_bytes();
|
||||
let reopened = Bundle::open(MemStore::from_bytes(image)).expect("reopen");
|
||||
|
||||
// The bundle preserved the opaque blobs verbatim ...
|
||||
let decl = reopened
|
||||
.manifest()
|
||||
.extension_declarations
|
||||
.iter()
|
||||
.find(|d| d.extension_id == extension_id)
|
||||
.expect("the declaration survives the commit");
|
||||
assert_eq!(
|
||||
decl.edit_barriers, barrier_bytes,
|
||||
"barrier blob is verbatim"
|
||||
);
|
||||
assert_eq!(
|
||||
decl.affected_object_kinds, kind_bytes,
|
||||
"object-kind blob is verbatim"
|
||||
);
|
||||
|
||||
// ... the owning layer's codec decodes them, re-encoding byte-identically ...
|
||||
let decoded = decode_edit_barriers(&decl.edit_barriers).expect("the canonical blob decodes");
|
||||
assert_eq!(
|
||||
encode_edit_barriers(&decoded),
|
||||
barrier_bytes,
|
||||
"decode → re-encode is byte-identical"
|
||||
);
|
||||
assert_eq!(
|
||||
decode_affected_object_kinds(&decl.affected_object_kinds).expect("kinds decode"),
|
||||
kinds
|
||||
);
|
||||
|
||||
// ... and the reopened barrier evaluates as authored: deleting the
|
||||
// protected event is prohibited; a different operation class and a
|
||||
// different object are not.
|
||||
let reopened_barrier = decoded
|
||||
.iter()
|
||||
.find(|b| **b == locked_barrier)
|
||||
.expect("the locked barrier survives the round-trip");
|
||||
let ctx = EditContext::default();
|
||||
assert!(reopened_barrier.prohibits_edit(
|
||||
OperationKindTag::DeleteEvent,
|
||||
&protected,
|
||||
&ctx,
|
||||
&AlwaysLiveOracle
|
||||
));
|
||||
assert!(!reopened_barrier.prohibits_edit(
|
||||
OperationKindTag::InsertEvent,
|
||||
&protected,
|
||||
&ctx,
|
||||
&AlwaysLiveOracle
|
||||
));
|
||||
let other = TypedObjectId::Event(EventId::from_raw(u128::MAX));
|
||||
assert!(!reopened_barrier.prohibits_edit(
|
||||
OperationKindTag::DeleteEvent,
|
||||
&other,
|
||||
&ctx,
|
||||
&AlwaysLiveOracle
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn barrier_declaration_roundtrip() {
|
||||
for seed in 0..8u64 {
|
||||
run_barrier_declaration_roundtrip(0xBA22_1E20_0000 + seed);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crash_recovery_smoke() {
|
||||
run_crash_recovery(400, 0xF00D_BEEF_1234_5678);
|
||||
|
|
@ -253,4 +666,19 @@ mod tests {
|
|||
fn manifest_selection_passes() {
|
||||
run_manifest_selection(7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operation_index_end_to_end() {
|
||||
assert_operation_index_end_to_end(0x0091_D0EC_5EED_0001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_operation_index_is_rejected_and_rebuilt() {
|
||||
assert_stale_operation_index_rejected_and_rebuilt(0x0091_D0EC_5EED_0002);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_operation_index_is_not_bundle_corruption() {
|
||||
assert_corrupt_operation_index_is_not_bundle_corruption(0x0091_D0EC_5EED_0003);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -727,6 +727,17 @@ pub fn gen_layout_constraint(rng: &mut Rng) -> LayoutConstraint {
|
|||
}
|
||||
}
|
||||
|
||||
/// A constraint strength (both variants).
|
||||
pub fn gen_constraint_strength(rng: &mut Rng) -> ConstraintStrength {
|
||||
if rng.boolean() {
|
||||
ConstraintStrength::Required
|
||||
} else {
|
||||
ConstraintStrength::Preferred {
|
||||
weight: (rng.range(1, 1_000) as f64) / 100.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A bundled glyph metric (drawn from the in-tree Bravura table).
|
||||
pub fn gen_glyph_metric(rng: &mut Rng) -> GlyphMetric {
|
||||
BRAVURA_METRICS[rng.below(BRAVURA_METRICS.len() as u64) as usize].clone()
|
||||
|
|
@ -790,6 +801,21 @@ pub fn gen_override_target(rng: &mut Rng) -> OverrideTarget {
|
|||
}
|
||||
}
|
||||
|
||||
/// A break anchor for a generated break override (a representative subset: a
|
||||
/// wall-clock instant or an event anchor without offset).
|
||||
pub fn gen_break_anchor(rng: &mut Rng) -> epiphany_core::TimeAnchor {
|
||||
if rng.boolean() {
|
||||
epiphany_core::TimeAnchor::WallClock {
|
||||
time: epiphany_core::WallClockTime(rng.range(0, 10_000) as i64),
|
||||
}
|
||||
} else {
|
||||
epiphany_core::TimeAnchor::Event {
|
||||
id: epiphany_core::EventId::from_raw(rng.next_u64() as u128),
|
||||
offset: epiphany_core::AnchorOffset::Zero,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn gen_override_kind(rng: &mut Rng) -> OverrideKind {
|
||||
match rng.below(9) {
|
||||
0 => OverrideKind::StemDirection(if rng.boolean() {
|
||||
|
|
@ -799,8 +825,12 @@ pub fn gen_override_kind(rng: &mut Rng) -> OverrideKind {
|
|||
}),
|
||||
1 => OverrideKind::AccidentalParenthesized(rng.boolean()),
|
||||
2 => OverrideKind::AccidentalVisible(rng.boolean()),
|
||||
3 => OverrideKind::SystemBreak,
|
||||
4 => OverrideKind::PageBreak,
|
||||
3 => OverrideKind::SystemBreak {
|
||||
anchor: gen_break_anchor(rng),
|
||||
},
|
||||
4 => OverrideKind::PageBreak {
|
||||
anchor: gen_break_anchor(rng),
|
||||
},
|
||||
5 => OverrideKind::HiddenObject,
|
||||
6 => OverrideKind::CustomPosition(gen_point(rng)),
|
||||
7 => OverrideKind::LedgerLineSuppression,
|
||||
|
|
@ -1286,6 +1316,43 @@ mod tests {
|
|||
use epiphany_core::TypedObjectId;
|
||||
use epiphany_determinism::CanonicalEncode;
|
||||
|
||||
#[test]
|
||||
fn edit_barrier_blob_codec_round_trips_generated_barriers() {
|
||||
// Property gate for the provisional manifest-blob byte form
|
||||
// (`ExtensionDeclaration.edit_barriers` / `.affected_object_kinds`):
|
||||
// any generated barrier set encodes to a blob that decodes and
|
||||
// re-encodes byte-identically, and each decoded barrier's own canonical
|
||||
// bytes round-trip through the single-barrier decoder.
|
||||
let mut rng = Rng::new(0xBA22_1E2C_0DEC);
|
||||
for _ in 0..256 {
|
||||
let barriers: Vec<EditBarrier> = (0..rng.range_usize(0, 4))
|
||||
.map(|_| gen_edit_barrier(&mut rng))
|
||||
.collect();
|
||||
let blob = encode_edit_barriers(&barriers);
|
||||
let decoded = decode_edit_barriers(&blob).expect("a canonical blob decodes");
|
||||
assert_eq!(
|
||||
encode_edit_barriers(&decoded),
|
||||
blob,
|
||||
"decode → re-encode is byte-identical"
|
||||
);
|
||||
for barrier in &decoded {
|
||||
let bytes = barrier.to_canonical_bytes();
|
||||
assert_eq!(
|
||||
EditBarrier::decode_canonical_bytes(&bytes).as_ref(),
|
||||
Ok(barrier)
|
||||
);
|
||||
}
|
||||
|
||||
let kinds: Vec<ObjectKind> = (0..rng.range_usize(0, 6))
|
||||
.map(|_| gen_object_kind(&mut rng))
|
||||
.collect();
|
||||
let kind_blob = encode_affected_object_kinds(&kinds);
|
||||
let decoded_kinds =
|
||||
decode_affected_object_kinds(&kind_blob).expect("a canonical blob decodes");
|
||||
assert_eq!(encode_affected_object_kinds(&decoded_kinds), kind_blob);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ir_generators_are_deterministic_and_well_formed() {
|
||||
let mut a = Rng::new(13);
|
||||
|
|
@ -1295,11 +1362,14 @@ mod tests {
|
|||
let generated = gen_logical_layout_ir(&mut Rng::new(14));
|
||||
let constrained = try_to_constrained(&generated)
|
||||
.expect("generated logical IR must be structurally transformable");
|
||||
// The spacing stage emits real constraints, which the stub does not
|
||||
// evaluate: the solve is renderable either way, and satisfaction is
|
||||
// claimed exactly when the problem is constraint-free.
|
||||
let report = StubSolver.solve(&constrained, &SolverConfig::default());
|
||||
assert!(report.status.is_renderable());
|
||||
assert_eq!(
|
||||
StubSolver
|
||||
.solve(&constrained, &SolverConfig::default())
|
||||
.status,
|
||||
SolveStatus::Solved
|
||||
report.satisfied_hard_constraints,
|
||||
constrained.constraints.is_empty()
|
||||
);
|
||||
// Provenance ids are consistent with their synthesis: a projected one
|
||||
// (no synthesis) carries the source-only id; a synthesized one does not.
|
||||
|
|
@ -1404,6 +1474,7 @@ mod tests {
|
|||
let _ = gen_spring_slot(&mut rng);
|
||||
let _ = gen_constrained_layout_region(&mut rng);
|
||||
let _ = gen_layout_constraint(&mut rng);
|
||||
let _ = gen_constraint_strength(&mut rng);
|
||||
let _ = gen_engraving_override(&mut rng);
|
||||
let _ = gen_resolved_staff(&mut rng);
|
||||
let _ = gen_resolved_measure(&mut rng);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -30,6 +30,17 @@ P12-H/K rows await G's ratification alongside Phase 2's open questions and are
|
|||
reduction-rule behavior text). Core spec: revision-history row "Pass 12
|
||||
tranche 1 (audit spec-alignment)" appended.
|
||||
|
||||
## Tranche 1 addendum (2026-07-02, Push-3 enablers)
|
||||
|
||||
Two further ratifications landed the same day as spec-side *enablers* for the
|
||||
Push-3 wiring work — each defines normative text the implementation then built
|
||||
against (spec-first, per the batch discipline):
|
||||
|
||||
| Item | Disposition | Spec locus | Consumer |
|
||||
|---|---|---|---|
|
||||
| `ResolveEquivocation` meta-operation | **fixed (dangling prose reference)** — the core spec named the operation only in prose (§Equivocation, resolution path 2) with no payload schema, no catalog entry, no K1 slot. Ratified: catalog §ResolveEquivocation pins `ResolveEquivocationPayload { target: OperationId, chosen: EnvelopeHash }`, order-independent earliest-resolve-governs promotion, `AlreadyApplied` idempotence, `StructuralFieldCollision` on `equivocation_resolution` for differing resolves, precondition no-ops, and the equivocated-resolve exclusion; core spec `OperationPayload` gains the variant. Catalog 0.3.0 → 0.4.0. The profile-policy path stays open (P12-K5) | operation_catalog §ResolveEquivocation; core_spec §Operation Envelope | `epiphany-ops` (payload discriminant 3 appended; set-level promotion pre-pass) |
|
||||
| Anchored break overrides | **fixed (representational gap)** — `OverrideKind::SystemBreak`/`PageBreak` were nullary, so a break's *position* was unrepresentable and the logical-stage projection required by §Engraving Overrides could not exist. Ratified: both kinds carry `anchor: TimeAnchor`; the `ScoreGraph` target names the owning region; projected break overrides carry `Internal` origin (authorship lives in the op log, P11-C8) | core_spec Ch7 §Engraving Overrides listing + note | `epiphany-layout-ir` (`to_logical` projection + paired `UserOverride` decisions) |
|
||||
|
||||
**Deliberately not touched here** (still open in `PASS12_BATCH.md`): every
|
||||
P12-H/K row (algorithm-id ratification, decomposition precedence
|
||||
configurability, authored decompositions for ineligible events, system-pitch
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -5678,6 +5678,12 @@ pub enum OperationPayload {
|
|||
/// transaction; the realization of "undo" (see
|
||||
/// Section~\ref{sec:semops:undo}).
|
||||
UndoTransaction(UndoTransactionPayload),
|
||||
|
||||
/// A meta-operation that resolves an equivocated operation slot
|
||||
/// by naming the chosen candidate envelope (see
|
||||
/// Section~\ref{sec:semops:equivocation}; payload schema in the
|
||||
/// Operation Catalog companion).
|
||||
ResolveEquivocation(ResolveEquivocationPayload),
|
||||
}
|
||||
|
||||
/// The catalog of primitive operation kinds. The variants listed
|
||||
|
|
@ -8407,8 +8413,8 @@ pub enum OverrideKind {
|
|||
AccidentalVisible(bool),
|
||||
NoteheadShape(NoteheadShape),
|
||||
BeamGeometry(BeamGeometryOverride),
|
||||
SystemBreak,
|
||||
PageBreak,
|
||||
SystemBreak { anchor: TimeAnchor },
|
||||
PageBreak { anchor: TimeAnchor },
|
||||
HiddenObject,
|
||||
CustomPosition(Point2D),
|
||||
EnharmonicSpelling(PitchSpelling),
|
||||
|
|
@ -8417,6 +8423,16 @@ pub enum OverrideKind {
|
|||
// ... extensible
|
||||
}
|
||||
|
||||
// System- and page-break overrides address a *position*, not an
|
||||
// object: the break kind carries the break's TimeAnchor, while the
|
||||
// override's ScoreGraph target names the owning region. They are
|
||||
// projected during the logical stage from the score graph's
|
||||
// authoritative user_system_breaks / user_page_breaks lists
|
||||
// (Chapter 5). Break authorship (author, timestamp) lives in the
|
||||
// operation log, not the materialized break lists, so projected
|
||||
// break overrides carry OverrideOrigin::Internal until the
|
||||
// snapshot-undo refinement (P11-C8) surfaces authorship.
|
||||
|
||||
pub enum OverridePriority {
|
||||
/// The engraver MUST honor this override. Failure produces an
|
||||
/// error rather than a silent override-of-the-override.
|
||||
|
|
@ -14518,6 +14534,25 @@ layouts they own versus inherit:
|
|||
\texttt{spec/PASS12\_RATIFICATION\_LOG.md}; the Pass 12 batch
|
||||
itself remains open.
|
||||
\\
|
||||
\today & Pass 12 tranche 1 addendum (Push-3 enablers) &
|
||||
Two enabler ratifications for the machinery-wiring work.
|
||||
\texttt{ResolveEquivocation}, previously a dangling prose
|
||||
reference in the equivocation section, is now a defined
|
||||
meta-operation: \texttt{OperationPayload} gains the variant here,
|
||||
and the Operation Catalog (0.3.0 $\rightarrow$ 0.4.0) pins its
|
||||
payload schema (\texttt{\{ target: OperationId, chosen:
|
||||
EnvelopeHash \}}), the order-independent
|
||||
earliest-resolve-governs promotion, idempotence, differing-resolve
|
||||
meta-conflict, and precondition no-ops; the profile-declared
|
||||
selection-policy path remains open (P12-K5). Engraving break
|
||||
overrides became position-addressable:
|
||||
\texttt{OverrideKind::SystemBreak}/\texttt{PageBreak} carry the
|
||||
break's \texttt{TimeAnchor} (the \texttt{ScoreGraph} target names
|
||||
the owning region; projected break overrides carry
|
||||
\texttt{Internal} origin pending P11-C8 authorship surfacing),
|
||||
closing the representational gap that blocked the logical-stage
|
||||
override projection §Engraving Overrides requires.
|
||||
\\
|
||||
\bottomrule
|
||||
\end{longtable}
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue