diff --git a/.gitignore b/.gitignore index 8a48a46..43a5c52 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,11 @@ # These are tracked once they exist (kept for reproducible builds): # Cargo.lock for the workspace + +# LaTeX build intermediates (the spec PDF itself is tracked; these are not). +spec/*.aux +spec/*.fdb_latexmk +spec/*.fls +spec/*.log +spec/*.out +spec/*.toc diff --git a/crates/epiphany-bundle/DECISIONS.md b/crates/epiphany-bundle/DECISIONS.md index 59f094e..9dc4a23 100644 --- a/crates/epiphany-bundle/DECISIONS.md +++ b/crates/epiphany-bundle/DECISIONS.md @@ -7,6 +7,16 @@ than improvised in code (QUICKSTART, Process notes: *"Ambiguities go into a batch, not into code … Don't open Pass 11 until you have at least three such items batched."*). +> **RATIFIED (Pass 11, 2026-06-21).** The bundle-layer Pass 11 candidates have +> been ratified into `core_spec.tex` — see `spec/PASS11_RATIFICATION_LOG.md`. +> Highlights: D4 adopted (ChunkKind/ProfileId/CompressionAlgorithm discriminants); +> D5 adopted (`ManifestId` preimage, with `manifest_id` excluded); D1 fixed +> (equal-generation superblock rule → `DivergentSameGeneration`); D3 fixed (blob +> hashing is bare `MUSCBLOB‖payload`, spec contradiction removed); D6 fixed +> (`ProfileConstraints` defined with the required `RetentionPolicy`, +> first-declared multi-profile precedence). D2 (Binary Format companion) stays +> Track B, with the convention baseline ratified by core item 1.8. + ## Implementation decisions (QUICKSTART "Decisions you'll need to make") 1. **Replica ID entropy source** — N/A to this crate (Agent B/`epiphany-core`). diff --git a/crates/epiphany-bundle/src/chunk.rs b/crates/epiphany-bundle/src/chunk.rs index 72ce557..09a0e38 100644 --- a/crates/epiphany-bundle/src/chunk.rs +++ b/crates/epiphany-bundle/src/chunk.rs @@ -40,7 +40,9 @@ pub enum ChunkKind { impl ChunkKind { /// The stable 1-byte discriminant, assigned by the spec's declaration order. /// Part of the hash preimage and of the chunk-reference canonical order, so - /// the assignment is normative for this format version. + /// the assignment is normative for this format version. RATIFIED by Pass 11 + /// (item 1.5, P11-D4): core_spec §"Chunks", + /// Requirement `req:format:chunkkind-discriminants` (0..=8). #[inline] pub const fn discriminant(self) -> u8 { match self { diff --git a/crates/epiphany-bundle/src/ids.rs b/crates/epiphany-bundle/src/ids.rs index 1fc9ddc..7a83e26 100644 --- a/crates/epiphany-bundle/src/ids.rs +++ b/crates/epiphany-bundle/src/ids.rs @@ -97,7 +97,10 @@ impl ManifestId { /// the document id, generation, and the canonical bytes of the manifest /// body (with the `manifest_id` field itself excluded to avoid a circular /// dependency). Deterministic — two writers encoding the same manifest - /// content derive the same id. + /// content derive the same id. RATIFIED by Pass 11 (item 1.6, P11-D5): + /// core_spec §"Manifest Encoding", Requirement `req:format:manifest-id` + /// (`trunc128(BLAKE3("MUSCMNIF" || document_id || generation || body))`, + /// body excluding `manifest_id`). pub(crate) fn derive(document_id: DocumentId, generation: u64, body_preimage: &[u8]) -> Self { let mut p = Preimage::new(DomainTag::MANIFEST_ID); p.push_bytes(document_id.as_bytes()); diff --git a/crates/epiphany-bundle/src/superblock.rs b/crates/epiphany-bundle/src/superblock.rs index 07dc4c3..2cad7ec 100644 --- a/crates/epiphany-bundle/src/superblock.rs +++ b/crates/epiphany-bundle/src/superblock.rs @@ -290,7 +290,10 @@ impl Superblock { /// version, same profile. The advisory `commit_timestamp` (and the physical /// manifest offset/length, which a matching `manifest_hash` already pins) are /// deliberately excluded — a difference in those does not make the states -/// divergent. +/// divergent. RATIFIED by Pass 11 (item 3.2, P11-D1, a spec-gap fix): core_spec +/// §"Superblock Selection" now states the equal-generation rule (this +/// load-bearing field set → equivalent, pick A; otherwise +/// `DivergentSameGeneration`, read-only). fn selection_equivalent(a: &Superblock, b: &Superblock) -> bool { a.manifest_hash == b.manifest_hash && a.manifest_schema_version == b.manifest_schema_version diff --git a/crates/epiphany-core/DECISIONS.md b/crates/epiphany-core/DECISIONS.md index 7dc5e0c..6c98fae 100644 --- a/crates/epiphany-core/DECISIONS.md +++ b/crates/epiphany-core/DECISIONS.md @@ -38,6 +38,17 @@ operation so demotion is never observable. ## Pass 11 candidates (ambiguities for the spec, not resolved in code) +> **RATIFIED (Pass 11, 2026-06-21).** The spec-internal items below have been +> ratified into normative `core_spec.tex` text — see +> `spec/PASS11_RATIFICATION_LOG.md`. Disposition summary: P11-1/3/6 adopted as +> golden (TypedObjectId discriminants, promoted-voice + synthetic-pitch +> derivations); P11-2 fixed (count = 19; three construction-time MUSTs named, and +> `TupletRatio` now rejects degenerate ratios at construction); P11-4 adopted +> (RationalTime/scalar layouts + codec convention baseline the Binary Format +> companion inherits); P11-7 decided (tempo `Linear` interpolates speed). P11-5 +> remains a scope boundary (Track C). The byte-layout golden tests now cite their +> ratified requirements. + ### P11-1 — `TypedObjectId` discriminant values are unspecified Chapter 5 fixes the *shape* of `TypedObjectId::canonical_bytes` ("a 16-bit diff --git a/crates/epiphany-core/src/codec.rs b/crates/epiphany-core/src/codec.rs index 9dc7cde..1e82438 100644 --- a/crates/epiphany-core/src/codec.rs +++ b/crates/epiphany-core/src/codec.rs @@ -28,7 +28,11 @@ //! //! This is a concrete, reversible canonical form that predates the Binary Format //! companion specification; when that lands, reconcile the two (see -//! `DECISIONS.md`, P11-4). +//! `DECISIONS.md`, P11-4). The convention set above is RATIFIED by Pass 11 +//! (item 1.8): core_spec §"Binary Format Companion", +//! Requirement `req:format:codec-conventions` blesses these conventions as the +//! companion's inherited baseline, so core/ops/bundle stay mutually consistent +//! until Agent J formalizes the full companion. use core::num::NonZeroU16; use std::collections::{BTreeMap, BTreeSet}; @@ -1474,7 +1478,22 @@ struct_codec!(Slur { end_event }); struct_codec!(Beam { id, events, level }); -struct_codec!(TupletRatio { actual, notated }); +// TupletRatio is not a plain struct_codec!: it has private fields and a checked +// constructor that rejects degenerate ratios, so decode must validate too +// (a malformed bundle cannot inject a degenerate ratio). +impl Codec for TupletRatio { + fn enc(&self, out: &mut Vec) { + self.actual().enc(out); + self.notated().enc(out); + } + fn dec(r: &mut Reader<'_>) -> Result { + let actual = Codec::dec(r)?; + let notated = Codec::dec(r)?; + TupletRatio::new(actual, notated).ok_or(ScoreDecodeError::Reconstruct( + "TupletRatio: degenerate ratio", + )) + } +} struct_codec!(Tuplet { id, ratio, diff --git a/crates/epiphany-core/src/generators.rs b/crates/epiphany-core/src/generators.rs index 6e613d0..47c5bd3 100644 --- a/crates/epiphany-core/src/generators.rs +++ b/crates/epiphany-core/src/generators.rs @@ -286,10 +286,7 @@ pub fn valid_score_rich(seed: u64) -> Score { let triplet_id: TupletId = idc.mint(); cross_cutting.tuplets.push(Tuplet { id: triplet_id, - ratio: TupletRatio { - actual: 3, - notated: 2, - }, + ratio: TupletRatio::new(3, 2).expect("3:2 is a valid tuplet ratio"), members: triplet_members.clone(), parent: None, required_total: MusicalDuration(RationalTime::new(1, 4).unwrap()), @@ -678,10 +675,7 @@ pub fn violating_score(inv: GraphInvariant, seed: u64) -> Score { let (e0, e1) = first_two_event_ids(&s); s.cross_cutting.tuplets.push(Tuplet { id: TupletId::new(replica, 1), - ratio: TupletRatio { - actual: 3, - notated: 2, - }, + ratio: TupletRatio::new(3, 2).expect("3:2 is a valid tuplet ratio"), members: vec![e0, e1], parent: None, required_total: MusicalDuration::whole(), diff --git a/crates/epiphany-core/src/graph.rs b/crates/epiphany-core/src/graph.rs index 55ef997..52d4e7f 100644 --- a/crates/epiphany-core/src/graph.rs +++ b/crates/epiphany-core/src/graph.rs @@ -751,11 +751,43 @@ pub struct Tie { pub class: TieClass, } -/// The actual:notated ratio of a tuplet (Chapter 3 §"Tuplets"). +/// The actual:notated ratio of a tuplet (Chapter 3 §"Tuplets"). Built only +/// through [`TupletRatio::new`], which rejects degenerate ratios at +/// construction (Chapter 3 §"Tuplets", `req:time:tuplet-ratio-construction`): +/// both terms must be nonzero and `actual != notated`, so a degenerate ratio is +/// never a representable graph state. The fields are private to keep that +/// guarantee unbypassable. #[derive(Copy, Clone, PartialEq, Eq, Debug)] pub struct TupletRatio { - pub actual: u32, - pub notated: u32, + actual: u32, + notated: u32, +} + +impl TupletRatio { + /// Builds an `actual:notated` ratio, returning `None` for a *degenerate* + /// ratio — either term zero, or `actual == notated` (a ratio that expresses + /// no augmentation or diminution). This is the construction-time MUST of + /// Chapter 3 §"Tuplets": degeneracy is rejected here, not by a runtime + /// invariant. + pub fn new(actual: u32, notated: u32) -> Option { + if actual == 0 || notated == 0 || actual == notated { + None + } else { + Some(TupletRatio { actual, notated }) + } + } + + /// The `actual` term: how many notes are played. + #[inline] + pub const fn actual(&self) -> u32 { + self.actual + } + + /// The `notated` term: in the time of how many. + #[inline] + pub const fn notated(&self) -> u32 { + self.notated + } } /// A tuplet grouping object (Chapter 3 §"Tuplets as Grouping Objects"). @@ -959,8 +991,8 @@ impl NotatedComponent { pub fn sounding_duration(&self, tuplet_ratio: Option) -> MusicalDuration { let notated = self.notated_duration(); match tuplet_ratio { - Some(r) if r.actual != 0 && r.notated != 0 => { - let scale = crate::time::RationalTime::new(r.notated as i64, r.actual as i64) + Some(r) if r.actual() != 0 && r.notated() != 0 => { + let scale = crate::time::RationalTime::new(r.notated() as i64, r.actual() as i64) .expect("validated nonzero"); MusicalDuration(notated.rational().mul(&scale)) } @@ -1217,9 +1249,10 @@ mod tests { fn promoted_voice_id_byte_form_is_locked() { // Golden: locks the MUSCSVCE 64-byte preimage layout (staff instance || // original voice || winning op || losing op, each 16 big-endian bytes) - // and the hash output. A change to the input order, byte layout, or - // domain tag breaks this deliberately, forcing the derivation change to - // be acknowledged (DECISIONS P11-3). + // and the hash output. RATIFIED by Pass 11 (item 1.2, P11-3 / ops C4): + // this is the spec's golden, normative in core_spec + // §"System-Promoted Voices" (`derive_promoted_voice_id`). A change to the + // input order, byte layout, or domain tag breaks this deliberately. let id = derive_promoted_voice_id( StaffInstanceId::new(ReplicaId(3), 1), VoiceId::new(ReplicaId(3), 2), diff --git a/crates/epiphany-core/src/ids.rs b/crates/epiphany-core/src/ids.rs index 3ea197b..892f534 100644 --- a/crates/epiphany-core/src/ids.rs +++ b/crates/epiphany-core/src/ids.rs @@ -889,9 +889,13 @@ mod tests { #[test] fn typed_object_id_byte_form_is_locked() { - // Golden: locks the 16-bit big-endian discriminant table (DECISIONS P11-1) - // and the payload layout. A reorder or discriminant reassignment breaks - // this deliberately — these bytes are normative (ordering/hashing/equality). + // Golden: locks the 16-bit big-endian discriminant table and the payload + // layout. RATIFIED by Pass 11 (item 1.1, P11-1): this is now the spec's + // golden, normative in core_spec §"Identifiers", + // Requirement `req:graph:typed-object-id-discriminants` (discriminants + // 0..=27, Registered=27 as disc(2)||reg(16)||raw_be(16)). A reorder or + // reassignment breaks this deliberately — these bytes drive + // ordering/hashing/equality for every object. let r = ReplicaId(9); // Event = discriminant 0, then the 16-byte big-endian id payload. let event = TypedObjectId::Event(EventId::new(r, 1)).canonical_bytes(); diff --git a/crates/epiphany-core/src/invariants.rs b/crates/epiphany-core/src/invariants.rs index 776a99a..e9ea58a 100644 --- a/crates/epiphany-core/src/invariants.rs +++ b/crates/epiphany-core/src/invariants.rs @@ -1938,17 +1938,11 @@ impl<'a> GraphIndex<'a> { // --- 16. Tuplet member durations sum to required total. ----------------- fn check_tuplet_sum(&self, out: &mut Vec) { for t in &self.score.cross_cutting.tuplets { - // The actual:notated ratio must be well-formed: both terms positive - // (a 0:0 or n:0 ratio is meaningless, Chapter 3 §"Tuplets"). - if t.ratio.actual == 0 || t.ratio.notated == 0 { - out.push(InvariantViolation::new( - GraphInvariant::TupletSum, - format!( - "tuplet {:?} has a degenerate ratio {}:{}", - t.id, t.ratio.actual, t.ratio.notated - ), - )); - } + // Degenerate ratios (a zero term or actual == notated) are rejected + // at construction by `TupletRatio::new` (Chapter 3 §"Tuplets", + // `req:time:tuplet-ratio-construction`), so they are never a + // representable graph state and are not re-checked here. + // // Sum the members' musical durations; skip members that are absent // (invariant 10) or non-musical (cannot contribute to a rational // total). @@ -1979,10 +1973,12 @@ impl<'a> GraphIndex<'a> { // so a wrong ratio (e.g. 3:2 changed to 5:4) is caught. Members // without an in-tuplet decomposition are skipped (sound but // incomplete; the decomposition pre-pass is deferred). - if t.ratio.actual != 0 && t.ratio.notated != 0 { - let scale = - crate::time::RationalTime::new(t.ratio.notated as i64, t.ratio.actual as i64) - .expect("nonzero ratio"); + if t.ratio.actual() != 0 && t.ratio.notated() != 0 { + let scale = crate::time::RationalTime::new( + t.ratio.notated() as i64, + t.ratio.actual() as i64, + ) + .expect("nonzero ratio"); for &member in &t.members { let comps: Vec<&crate::graph::NotatedComponent> = self .score @@ -2011,7 +2007,7 @@ impl<'a> GraphIndex<'a> { format!( "tuplet {:?} ratio {}:{} is inconsistent with member {:?}'s notation \ (notated scaled to {:?}, sounding duration is {:?})", - t.id, t.ratio.actual, t.ratio.notated, member, sounding, sd + t.id, t.ratio.actual(), t.ratio.notated(), member, sounding, sd ), )); } @@ -2952,10 +2948,7 @@ mod review_fix_tests_2 { let e = s.events.ids_canonical()[0]; s.cross_cutting.tuplets.push(Tuplet { id: TupletId::new(r, 1), - ratio: TupletRatio { - actual: 3, - notated: 2, - }, + ratio: TupletRatio::new(3, 2).expect("3:2 is a valid tuplet ratio"), members: vec![e], parent: Some(TupletId::new(r, 7_000_002)), required_total: MusicalDuration(RationalTime::new(1, 4).unwrap()), @@ -3168,11 +3161,11 @@ mod review_fix_tests_3 { use crate::graph::{ AleatoricAnchoringDiscipline, AleatoricTimeModel, Instrument, Region, RegionContent, RegionTimeModel, Spanner, StaffBasedContent, StaffExtent, StaffInstance, Tie, TieClass, - TimeExtent, Tuplet, TupletRatio, Voice, + TimeExtent, TupletRatio, Voice, }; use crate::ids::{ EventId, InstrumentId, PitchId, RegionId, ReplicaId, SpannerId, StaffInstanceId, TieId, - TupletId, VoiceId, + VoiceId, }; use crate::pitch::{ AcousticPitch, AcousticRealization, CmnNominal, IdentifiedPitch, Pitch, PitchSpaceId, @@ -3285,23 +3278,20 @@ mod review_fix_tests_3 { } #[test] - fn inv16_flags_degenerate_tuplet_ratio() { - let mut s = valid_score(91); - let r = s.identity.replica_id; - let e = s.events.ids_canonical()[0]; - s.cross_cutting.tuplets.push(Tuplet { - id: TupletId::new(r, 1), - ratio: TupletRatio { - actual: 0, - notated: 2, - }, - members: vec![e], - parent: None, - // Match the member's 1/4 so only the ratio is wrong. - required_total: MusicalDuration(RationalTime::new(1, 4).unwrap()), - }); - let v = check_invariant(&s, GraphInvariant::TupletSum); - assert!(v.iter().any(|x| x.witness.contains("degenerate ratio"))); + fn degenerate_tuplet_ratio_is_rejected_at_construction() { + // Pass 11, item 3.5 / Tuplet honesty: a degenerate ratio is rejected by + // `TupletRatio::new` at construction, so it can never enter the graph + // and is no longer a runtime invariant. A zero term or `actual == + // notated` is refused; a well-formed ratio is accepted. + assert!(TupletRatio::new(0, 2).is_none(), "n:0-form rejected"); + assert!(TupletRatio::new(2, 0).is_none(), "0:n-form rejected"); + assert!(TupletRatio::new(0, 0).is_none(), "0:0 rejected"); + assert!( + TupletRatio::new(4, 4).is_none(), + "actual == notated rejected" + ); + let ok = TupletRatio::new(3, 2).expect("3:2 is valid"); + assert_eq!((ok.actual(), ok.notated()), (3, 2)); } #[test] @@ -3840,10 +3830,8 @@ mod review_fix_tests_4 { // Changing the ratio to 5:4 (member notation unchanged) now has a // validation effect: the notated eighth no longer scales to 1/12. let mut s = valid_score_rich(241); - s.cross_cutting.tuplets[0].ratio = TupletRatio { - actual: 5, - notated: 4, - }; + s.cross_cutting.tuplets[0].ratio = + TupletRatio::new(5, 4).expect("5:4 is a valid tuplet ratio"); assert!(fires(&s, GraphInvariant::TupletSum)); } } diff --git a/crates/epiphany-core/src/pitch.rs b/crates/epiphany-core/src/pitch.rs index 633130d..e5bef78 100644 --- a/crates/epiphany-core/src/pitch.rs +++ b/crates/epiphany-core/src/pitch.rs @@ -899,9 +899,12 @@ mod tests { fn system_pitch_id_byte_form_is_locked() { // Golden: locks the MUSCSPCH canonical-input layout (space name, scale // position discriminant + payload, tuning, acoustic realization; strings - // length-prefixed NFC) and the hash. A change to the byte form breaks - // this deliberately, forcing the derivation change to be acknowledged - // (DECISIONS P11-6). + // length-prefixed NFC) and the hash. RATIFIED by Pass 11 (item 1.3, + // P11-6): this is the spec's golden, normative in core_spec + // §"System-Derived Pitch Identity", + // Requirement `req:graph:system-derived-pitch-id` — note the tuning + // reference (incl. the Inherit marker) is always part of intrinsic + // identity. A change to the byte form breaks this deliberately. let id = derive_system_pitch_id(&cmn(CmnNominal::C, 0, 4)); assert_eq!(id.replica(), crate::ids::ReplicaId::SYSTEM_DERIVED); const GOLDEN: [u8; 16] = [ diff --git a/crates/epiphany-core/src/time.rs b/crates/epiphany-core/src/time.rs index d4e97b0..6486ad2 100644 --- a/crates/epiphany-core/src/time.rs +++ b/crates/epiphany-core/src/time.rs @@ -328,8 +328,11 @@ impl Default for RationalTime { /// numerator's sign and big-endian magnitude (length-prefixed), then the /// positive denominator's big-endian magnitude (length-prefixed). The value is /// always reduced first, so equal rationals encode to equal bytes (Appendix D -/// §"Canonical serialization determinism"). The full bundle wire format is the -/// Binary Format companion's (Agent D); this is the prototype's canonical form. +/// §"Canonical serialization determinism"). RATIFIED by Pass 11 (item 1.7, +/// P11-4): this primitive layout is now normative in core_spec §"Binary Format +/// Companion", Requirement `req:format:rationaltime-encoding`; the full +/// composite wire format remains the Binary Format companion's (Agent J), which +/// inherits the ratified convention baseline (`req:format:codec-conventions`). impl CanonicalEncode for RationalTime { fn encode_canonical(&self, out: &mut Vec) { let big = self.to_big(); diff --git a/crates/epiphany-determinism/src/domain.rs b/crates/epiphany-determinism/src/domain.rs index 8f23efb..691fceb 100644 --- a/crates/epiphany-determinism/src/domain.rs +++ b/crates/epiphany-determinism/src/domain.rs @@ -26,8 +26,9 @@ impl DomainTag { /// The prefix marking a *system-derived* tag (Chapter 5: /// "Additional domain tags introduced by registered extensions MUST begin - /// with `MUSCS` and have length exactly 8 bytes"). The two built-in system - /// tags ([`Self::SYSTEM_VOICE`], [`Self::SYSTEM_PITCH`]) also carry it. + /// with `MUSCS` and have length exactly 8 bytes"). The three built-in + /// system tags ([`Self::SYSTEM_VOICE`], [`Self::SYSTEM_PITCH`], + /// [`Self::SYSTEM_ANOMALY`]) also carry it. const SYSTEM_PREFIX: &'static [u8] = b"MUSCS"; /// The raw 8 ASCII bytes. @@ -56,9 +57,13 @@ impl DomainTag { pub const SYSTEM_VOICE: DomainTag = DomainTag(*b"MUSCSVCE"); /// System-derived pitch counter derivation (Chapter 5 §"System-Derived"). pub const SYSTEM_PITCH: DomainTag = DomainTag(*b"MUSCSPCH"); + /// `IntegrityAnomalyId` derivation (Chapter 5 §"System-Derived Counter + /// Collisions"). Reserved built-in: anomalies are core, not an extension + /// concern (Pass 11, item 1.4). + pub const SYSTEM_ANOMALY: DomainTag = DomainTag(*b"MUSCSANM"); /// Every built-in tag, in declaration order. The closed core vocabulary. - pub const BUILTINS: [DomainTag; 9] = [ + pub const BUILTINS: [DomainTag; 10] = [ Self::CHUNK, Self::MANIFEST, Self::BLOB, @@ -68,6 +73,7 @@ impl DomainTag { Self::MANIFEST_ID, Self::SYSTEM_VOICE, Self::SYSTEM_PITCH, + Self::SYSTEM_ANOMALY, ]; /// Constructs a domain tag from raw bytes, accepting only the spec's closed @@ -114,16 +120,16 @@ impl DomainTag { Self::BUILTINS.contains(self) } - /// Whether this is a *system-derived* tag (begins `MUSCS`): the built-in - /// [`Self::SYSTEM_VOICE`] / [`Self::SYSTEM_PITCH`] or an extension tag - /// minted via [`SystemDomainTag::new_extension`]. + /// Whether this is a *system-derived* tag (begins `MUSCS`): a built-in + /// [`Self::SYSTEM_VOICE`] / [`Self::SYSTEM_PITCH`] / [`Self::SYSTEM_ANOMALY`] + /// or an extension tag minted via [`SystemDomainTag::new_extension`]. #[inline] pub fn is_system_derived(&self) -> bool { self.0.starts_with(Self::SYSTEM_PREFIX) } /// Whether this is an *extension-introduced* system tag: system-derived and - /// not a reserved built-in. The two built-in system tags return `false` + /// not a reserved built-in. The three built-in system tags return `false` /// here — they are reserved, not extension-introduced. #[inline] pub fn is_extension_system_tag(&self) -> bool { @@ -131,8 +137,9 @@ impl DomainTag { } } -/// A [`DomainTag`] proven to be *system-derived* (begins `MUSCS`): the built-in -/// [`DomainTag::SYSTEM_VOICE`] / [`DomainTag::SYSTEM_PITCH`], or an +/// A [`DomainTag`] proven to be *system-derived* (begins `MUSCS`): a built-in +/// [`DomainTag::SYSTEM_VOICE`] / [`DomainTag::SYSTEM_PITCH`] / +/// [`DomainTag::SYSTEM_ANOMALY`], or an /// extension-introduced tag. Only these are admissible seeds for /// [`crate::derive_system_counter`] (Chapter 5 §"System-Derived Identifiers"). /// @@ -148,6 +155,8 @@ impl SystemDomainTag { pub const VOICE: SystemDomainTag = SystemDomainTag(DomainTag::SYSTEM_VOICE); /// Built-in: system-derived pitch counters (`MUSCSPCH`). pub const PITCH: SystemDomainTag = SystemDomainTag(DomainTag::SYSTEM_PITCH); + /// Built-in: integrity-anomaly identifiers (`MUSCSANM`). + pub const ANOMALY: SystemDomainTag = SystemDomainTag(DomainTag::SYSTEM_ANOMALY); /// Wraps a domain tag if it is system-derived; returns `None` otherwise. #[inline] @@ -215,6 +224,7 @@ mod tests { DomainTag::MANIFEST_ID, DomainTag::SYSTEM_VOICE, DomainTag::SYSTEM_PITCH, + DomainTag::SYSTEM_ANOMALY, ]; for t in tags { assert_eq!(t.as_bytes().len(), DomainTag::LEN); @@ -235,6 +245,7 @@ mod tests { DomainTag::MANIFEST_ID, DomainTag::SYSTEM_VOICE, DomainTag::SYSTEM_PITCH, + DomainTag::SYSTEM_ANOMALY, ]; for (i, a) in tags.iter().enumerate() { for b in &tags[i + 1..] { @@ -255,6 +266,7 @@ mod tests { assert_eq!(DomainTag::MANIFEST_ID.as_bytes(), b"MUSCMNIF"); assert_eq!(DomainTag::SYSTEM_VOICE.as_bytes(), b"MUSCSVCE"); assert_eq!(DomainTag::SYSTEM_PITCH.as_bytes(), b"MUSCSPCH"); + assert_eq!(DomainTag::SYSTEM_ANOMALY.as_bytes(), b"MUSCSANM"); assert_eq!(&BUNDLE_MAGIC, b"MUSCBND\0"); assert_eq!(&SUPERBLOCK_MAGIC, b"MUSCSUPR"); } @@ -264,10 +276,13 @@ mod tests { // They are system-derived (begin MUSCS)... assert!(DomainTag::SYSTEM_VOICE.is_system_derived()); assert!(DomainTag::SYSTEM_PITCH.is_system_derived()); + assert!(DomainTag::SYSTEM_ANOMALY.is_system_derived()); // ...but reserved built-ins, NOT extension-introduced. assert!(DomainTag::SYSTEM_VOICE.is_builtin()); + assert!(DomainTag::SYSTEM_ANOMALY.is_builtin()); assert!(!DomainTag::SYSTEM_VOICE.is_extension_system_tag()); assert!(!DomainTag::SYSTEM_PITCH.is_extension_system_tag()); + assert!(!DomainTag::SYSTEM_ANOMALY.is_extension_system_tag()); // A non-system tag is neither. assert!(!DomainTag::CHUNK.is_system_derived()); assert!(!DomainTag::CHUNK.is_extension_system_tag()); @@ -301,6 +316,8 @@ mod tests { assert!(SystemDomainTag::new_extension(*b"MUSCXXXX").is_none()); // Must not collide with a reserved built-in. assert!(SystemDomainTag::new_extension(*b"MUSCSVCE").is_none()); + // MUSCSANM is now a reserved built-in too (Pass 11): not extension-mintable. + assert!(SystemDomainTag::new_extension(*b"MUSCSANM").is_none()); // Non-ASCII rejected. assert!( SystemDomainTag::new_extension([b'M', b'U', b'S', b'C', b'S', 0xFF, b'A', b'B']) @@ -313,7 +330,9 @@ mod tests { assert!(ext.tag().is_extension_system_tag()); // Built-in system tags wrap; non-system tags do not. assert_eq!(SystemDomainTag::VOICE.tag(), DomainTag::SYSTEM_VOICE); + assert_eq!(SystemDomainTag::ANOMALY.tag(), DomainTag::SYSTEM_ANOMALY); assert!(SystemDomainTag::new(DomainTag::SYSTEM_PITCH).is_some()); + assert!(SystemDomainTag::new(DomainTag::SYSTEM_ANOMALY).is_some()); assert!(SystemDomainTag::new(DomainTag::CHUNK).is_none()); } } diff --git a/crates/epiphany-determinism/src/hash.rs b/crates/epiphany-determinism/src/hash.rs index 63b9c69..07f9b58 100644 --- a/crates/epiphany-determinism/src/hash.rs +++ b/crates/epiphany-determinism/src/hash.rs @@ -76,9 +76,11 @@ impl ContentHash { pub const ZERO: ContentHash = ContentHash([0u8; 32]); /// Hashes a blob payload: `BLAKE3(MUSCBLOB || payload)`. This is the - /// `BlobId` construction (Chapter 8: "BLAKE3 of blob content with domain - /// tag `MUSCBLOB`") — the only spec content hash that is a bare - /// `domain || payload`. + /// `BlobId` construction — the only spec content hash that is a bare + /// `domain || payload`. RATIFIED by Pass 11 (item 3.1, P11-D3, a spec-bug + /// fix): core_spec §"Blobs", Requirement `req:format:blob-hash-shape` now + /// states the bare form explicitly and deletes the contradictory + /// "identically to chunks" phrasing. /// /// Other content hashes are *not* this shape: a chunk hash also commits to /// kind, schema version, and uncompressed length (Chapter 8 diff --git a/crates/epiphany-layout-ir/DECISIONS.md b/crates/epiphany-layout-ir/DECISIONS.md index b8d420f..1dc5c79 100644 --- a/crates/epiphany-layout-ir/DECISIONS.md +++ b/crates/epiphany-layout-ir/DECISIONS.md @@ -7,6 +7,15 @@ rather than improvised in code (QUICKSTART, Process notes: *"Ambiguities go into a batch, not into code … Don't open Pass 11 until you have at least three such items batched."*). +> **RATIFIED (Pass 11, 2026-06-21).** layout P11-2 (`LayoutObjectId` derivation) +> is ratified into `core_spec.tex` §"Provenance" +> (`req:layoutir:object-id-derivation`): a `MUSCLOID`-tagged derivation keying +> multiply-manifested objects on `(source, region)` and synthesized objects on +> `(source, synthesis_kind, stable_semantic_instance_key)`. Layout ids are +> non-canonical, so this is flagged for Track A (solver/renderer). layout P11-1 +> (layout→ops dependency) stays a crate-topology call for the G–K re-cut. See +> `spec/PASS11_RATIFICATION_LOG.md`. + ## Scope The crate implements the Chapter 7 interface surface: all four stages, the diff --git a/crates/epiphany-ops/DECISIONS.md b/crates/epiphany-ops/DECISIONS.md index 6f6daee..570ecd6 100644 --- a/crates/epiphany-ops/DECISIONS.md +++ b/crates/epiphany-ops/DECISIONS.md @@ -7,6 +7,16 @@ than improvised in code (QUICKSTART, Process notes: *"Ambiguities go into a batch, not into code … Don't open Pass 11 until you have at least three such items batched."*). +> **RATIFIED (Pass 11, 2026-06-21).** The ops-layer Pass 11 candidates have been +> ratified into `core_spec.tex` — see `spec/PASS11_RATIFICATION_LOG.md`. +> Highlights: C2 adopted (`IntegrityAnomalyId` = `derive_system_id(MUSCSANM,…)`, +> with `MUSCSANM` promoted to a reserved built-in tag); C3 decided +> (field-collision tags the *winner* `Conflicted`); C4 adopted + lifted to spec +> (the >2-way, partial-overlap promotion generalization); C7 fixed (zero-based +> DVV floor made normative); C9 decided (`TransactionCategory`/`ObjectKind` core +> vocabularies pinned); C10 decided (added `ResolutionAction::Dismiss` so +> `Dismissed` is reachable). C1/C5/C6/C8 stay deferred to their tracks. + ## Implementation decisions (QUICKSTART "Decisions you'll need to make") 1. **Replica ID entropy / event-arena / chunk store** — N/A to this crate diff --git a/crates/epiphany-ops/src/anomaly.rs b/crates/epiphany-ops/src/anomaly.rs index a0c37ee..f6860dc 100644 --- a/crates/epiphany-ops/src/anomaly.rs +++ b/crates/epiphany-ops/src/anomaly.rs @@ -32,13 +32,15 @@ use crate::support::{ IntegrityAnomalyRegistryId, ObjectKind, ReplicaAnomalyRegistryId, SerializedCanonicalInputs, }; -/// The `MUSCS`-prefixed system domain tag under which integrity-anomaly -/// identifiers are content-derived, so two replicas reducing the same operation -/// set mint byte-identical anomaly ids (Chapter 5 §"System-Derived -/// Identifiers"). A prototype choice (see `DECISIONS.md`): the spec gives -/// `IntegrityAnomaly` an `IntegrityAnomalyId` but does not pin its derivation. +/// The reserved built-in system domain tag (`MUSCSANM`) under which +/// integrity-anomaly identifiers are content-derived, so two replicas reducing +/// the same operation set mint byte-identical anomaly ids. Ratified by Pass 11 +/// (item 1.4): `MUSCSANM` is a built-in reserved tag alongside `MUSCSVCE` / +/// `MUSCSPCH` (Chapter 5 §"System-Derived Counter Collisions", +/// Requirement `req:graph:integrity-anomaly-id`), not an extension tag — +/// anomalies are core. fn anomaly_domain_tag() -> SystemDomainTag { - SystemDomainTag::new_extension(*b"MUSCSANM").expect("MUSCSANM is a valid system tag") + SystemDomainTag::ANOMALY } /// A range of envelopes from a single replica identified as diff --git a/crates/epiphany-ops/src/causal.rs b/crates/epiphany-ops/src/causal.rs index b8e4cf7..8ca5b5c 100644 --- a/crates/epiphany-ops/src/causal.rs +++ b/crates/epiphany-ops/src/causal.rs @@ -7,7 +7,11 @@ //! //! * `vector`: for each replica the authoring replica knows, the highest //! *contiguous* counter it has observed. `vector[r] = n` asserts that every -//! operation `(r, 0..=n)` is a causal predecessor. +//! operation `(r, 0..=n)` is a causal predecessor. The counter floor is +//! **zero-based** and normative — RATIFIED by Pass 11 (item 3.4, P11-C7): +//! core_spec §"Causal Context via Dotted Version Vectors" now pins the +//! zero-based floor so a second implementation cannot pick a one-based floor +//! and diverge on pending detection. //! * `dots`: individual [`OperationId`]s observed but not yet contiguous in the //! vector — "known but not yet contiguous" predecessors. //! diff --git a/crates/epiphany-ops/src/conflict.rs b/crates/epiphany-ops/src/conflict.rs index b762583..cdb310e 100644 --- a/crates/epiphany-ops/src/conflict.rs +++ b/crates/epiphany-ops/src/conflict.rs @@ -187,6 +187,10 @@ pub enum ResolutionAction { Override { override_operation: OperationId }, /// Re-anchor to a user-chosen target. Reanchor { new_target: TypedObjectId }, + /// Dismiss the conflict without changing the materialized graph: the user + /// acknowledges it and accepts the current (winner) state. Selects the + /// `Dismissed` resolution state (Pass 11, item 2.5; Chapter 6). + Dismiss, /// Custom resolution for a registered conflict kind. Registered(ResolutionRegistryId), } @@ -198,7 +202,8 @@ impl ResolutionAction { ResolutionAction::KeepWinner => 1, ResolutionAction::Override { .. } => 2, ResolutionAction::Reanchor { .. } => 3, - ResolutionAction::Registered(_) => 4, + ResolutionAction::Dismiss => 4, + ResolutionAction::Registered(_) => 5, } } } @@ -207,7 +212,9 @@ impl CanonicalEncode for ResolutionAction { fn encode_canonical(&self, out: &mut Vec) { push_tag(out, self.discriminant()); match self { - ResolutionAction::AcceptLoser | ResolutionAction::KeepWinner => {} + ResolutionAction::AcceptLoser + | ResolutionAction::KeepWinner + | ResolutionAction::Dismiss => {} ResolutionAction::Override { override_operation } => { push_canon(out, override_operation) } diff --git a/crates/epiphany-ops/src/decode.rs b/crates/epiphany-ops/src/decode.rs index a03d54e..8e4672a 100644 --- a/crates/epiphany-ops/src/decode.rs +++ b/crates/epiphany-ops/src/decode.rs @@ -340,7 +340,8 @@ fn resolution_action(reader: &mut Reader<'_>) -> Result { 3 => Ok(ResolutionAction::Reanchor { new_target: typed_object_id(reader)?, }), - 4 => Ok(ResolutionAction::Registered(registry_id( + 4 => Ok(ResolutionAction::Dismiss), + 5 => Ok(ResolutionAction::Registered(registry_id( reader, ResolutionRegistryId, )?)), diff --git a/crates/epiphany-ops/src/reduce.rs b/crates/epiphany-ops/src/reduce.rs index 436b341..ceab209 100644 --- a/crates/epiphany-ops/src/reduce.rs +++ b/crates/epiphany-ops/src/reduce.rs @@ -42,7 +42,9 @@ use epiphany_core::{ use epiphany_determinism::{CanonicalEncode, ContentHash}; use crate::anomaly::{detect_replica_anomalies, IntegrityAnomaly, IntegrityAnomalyKind}; -use crate::conflict::{ConflictKind, ConflictRecord, ConflictRegistry, FieldPath}; +use crate::conflict::{ + ConflictKind, ConflictRecord, ConflictRegistry, FieldPath, ResolutionAction, +}; use crate::effect::{ NoOpReason, OperationEffect, PreconditionFailureReason, ReanchorReason, RepairKind, RepairRecord, TupletCompensationKind, @@ -1737,9 +1739,12 @@ impl<'a> Reducer<'a> { }, Some(RS::Unresolved) => { if let Some(rec) = self.conflicts.get_mut(op.target) { - rec.resolution_state = RS::Resolved { - by: env.id, - action: op.action, + // ResolutionAction::Dismiss selects the Dismissed state; + // every other action selects Resolved with that action + // applied (Pass 11, item 2.5). + rec.resolution_state = match op.action { + ResolutionAction::Dismiss => RS::Dismissed { by: env.id }, + action => RS::Resolved { by: env.id, action }, }; } OperationEffect::Applied @@ -2353,4 +2358,97 @@ mod tests { .map(|(_, e)| e); assert_eq!(kept, Some(&OperationEffect::Applied)); } + + #[test] + fn resolve_conflict_with_dismiss_reaches_dismissed_state() { + // Pass 11, item 2.5: an authored ResolveConflict whose action is + // `Dismiss` must reach the `Dismissed` resolution state (previously + // `Dismissed` was representable but unreachable by any authored op). + use crate::conflict::{ConflictResolutionState, ResolutionAction}; + use crate::payload::{ResolveConflictPayload, RespellPitchOp}; + use epiphany_core::PitchId; + use epiphany_determinism::ContentHash; + + let pitch = PitchId::new(ReplicaId(9), 500); + + // An InsertEvent carrying `pitch` makes the pitch Live. + let mut insert_env = insert(1, 0, 10, 1, 100, 0); + if let OperationPayload::Primitive(OperationKind::InsertEvent(ref mut op)) = + insert_env.payload + { + op.pitches = vec![pitch]; + } + + // Two concurrent, differing respellings of `pitch`, both causally after + // the insert (so the pitch is Live) but concurrent with each other (so + // they collide into a StructuralFieldCollision conflict). + let respell = |replica: u64, counter: u64, physical: i64, byte: u8| { + let id = OperationId::new(ReplicaId(replica), counter); + OperationEnvelope { + id, + author: AuthorId(0), + stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(physical), 0), id), + causal_context: CausalContext::new().with_seen(ReplicaId(1), 0), + transaction: None, + payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp { + pitch, + spelling: ContentHash([byte; 32]), + })), + } + }; + let respell_a = respell(2, 0, 20, 0xAA); + let respell_b = respell(3, 0, 21, 0xBB); + + // Phase 1: reduce to discover the content-derived conflict id. + let mut set = OperationSet::new(); + set.accept_all(vec![ + insert_env.clone(), + respell_a.clone(), + respell_b.clone(), + ]); + let state = set.reduce(); + assert_eq!( + state.conflicts.records().len(), + 1, + "expected exactly one field-collision conflict" + ); + let cid = state.conflicts.records()[0].id; + assert_eq!( + state.conflicts.records()[0].resolution_state, + ConflictResolutionState::Unresolved + ); + + // Phase 2: author ResolveConflict { Dismiss } against that conflict, + // causally after the colliding respells so it reduces against the + // already-created conflict record. + let resolve_id = OperationId::new(ReplicaId(4), 0); + let resolve_env = OperationEnvelope { + id: resolve_id, + author: AuthorId(0), + stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(30), 0), resolve_id), + causal_context: CausalContext::new() + .with_dot(respell_a.id) + .with_dot(respell_b.id), + transaction: None, + payload: OperationPayload::ResolveConflict(ResolveConflictPayload { + target: cid, + action: ResolutionAction::Dismiss, + }), + }; + + let mut set2 = OperationSet::new(); + set2.accept_all(vec![insert_env, respell_a, respell_b, resolve_env]); + let state2 = set2.reduce(); + let rec = state2 + .conflicts + .records() + .iter() + .find(|r| r.id == cid) + .expect("conflict still present after resolution"); + assert_eq!( + rec.resolution_state, + ConflictResolutionState::Dismissed { by: resolve_id }, + "Dismiss action must select the Dismissed state" + ); + } } diff --git a/spec/PASS11_RATIFICATION_LOG.md b/spec/PASS11_RATIFICATION_LOG.md new file mode 100644 index 0000000..d939d72 --- /dev/null +++ b/spec/PASS11_RATIFICATION_LOG.md @@ -0,0 +1,57 @@ +# Pass 11 — Ratification Log + +One line per worklist item recording the disposition. `adopt` = blessed the +implementation's golden-locked choice as normative spec text. `decided-X` = a +real fork resolved to X with rationale in the spec. `fixed` = the spec text was +contradictory or missing and changed. Spec edits are in `core_spec.tex`; every +byte-layout golden test now cites its ratified requirement. + +Date: 2026-06-21. Spec revision: **Pass 11 (spec ratification)**. Architecture +unchanged. The full worklist is `PASS11_WORKLIST.md`. + +## Bucket 1 — Adopt-and-pin (bytes) + +| Item | P11 id | Disposition | Spec locus | Test/code anchor | +|---|---|---|---|---| +| 1.1 `TypedObjectId` discriminants | P11-1 | **adopt** — pinned the 16-bit BE discriminant table 0..=27; added the 5 missing variants (Tuplet/RepeatStructure/LyricLine/ChordSymbol/View) the code carried; `Registered`=27 = disc(2)‖reg(16)‖raw_be(16); `ObjectKindRegistryId` is 128-bit | §"Identifiers", `req:graph:typed-object-id-discriminants` | `typed_object_id_byte_form_is_locked` | +| 1.2 Promoted-voice id | P11-3 / C4 | **adopt** — `MUSCSVCE` 64-byte preimage (staff_instance‖original_voice‖winning_op‖losing_op, each 16 BE); staff-instance recovered from containment; one derivation feeds both the reducer and Invariant 18 | §"System-Promoted Voices" | `promoted_voice_id_byte_form_is_locked` | +| 1.3 System-pitch id | P11-6 | **adopt + decided** — `MUSCSPCH` over (scale position, acoustic realization), strings length-prefixed + NFC at the boundary; **decided** the tuning reference is *always* part of intrinsic identity, including the `Inherit` presence marker | §"System-Derived Pitch Identity", `req:graph:system-derived-pitch-id` | `system_pitch_id_byte_form_is_locked` | +| 1.4 `IntegrityAnomalyId` + tag | P11-C2 | **adopt + decided** — `derive_system_id(MUSCSANM, kind.canonical_bytes())`; **decided** `MUSCSANM` is a reserved *built-in* tag (anomalies are core), moved from `new_extension` to `DomainTag::SYSTEM_ANOMALY`. No byte change | §"System-Derived Counter Collisions", `req:graph:integrity-anomaly-id`; tag added to §"System-Derived Identifier Namespace" | `domain.rs` builtins; `anomaly.rs::anomaly_domain_tag` | +| 1.5 ChunkKind/Profile/Compression | P11-D4 | **adopt** — ChunkKind 0..=8, ProfileId 0..=3, CompressionAlgorithm 0..=2, all single declaration-order discriminant bytes; ChunkKind byte is in the chunk hash preimage | §"Chunks" `req:format:chunkkind-discriminants`; §"Format Profiles" `req:format:profileid-discriminants` | `chunk.rs::discriminant`, `chunk_kind_discriminants_round_trip` | +| 1.6 `ManifestId` preimage | P11-D5 | **adopt** — `trunc128(BLAKE3("MUSCMNIF" ‖ document_id ‖ generation_le ‖ manifest_body))`, body excludes `manifest_id` (self-reference exclusion is normative) | §"Manifest Encoding", `req:format:manifest-id`; Appendix D entry updated | `bundle/ids.rs::derive` | +| 1.7 `RationalTime` + scalars | P11-4 | **adopt** — sign byte + u32-LE-length-prefixed BE numerator + u32-LE-length-prefixed BE denominator, always reduced; wall-clock integers little-endian | §"Binary Format Companion", `req:format:rationaltime-encoding` | `time.rs::CanonicalEncode for RationalTime` | +| 1.8 Codec conventions | P11-4 / D2 | **adopt as companion baseline** — LE ints; single discriminant byte per tagged union; u32 counts/length-prefixes on every variable-width leaf; raw UTF-8 free-text, NFC only for catalog ids at construction. Companion *inherits* this | §"Binary Format Companion", `req:format:codec-conventions` | `core/codec.rs` module doc | + +## Bucket 2 — Decide-and-pin + +| Item | P11 id | Disposition | Spec locus | Test/code anchor | +|---|---|---|---|---| +| 2.1 Tempo `Linear` parameter | P11-7 | **decided: speed-linear** — interpolates whole-notes-per-second (beat-unit-agnostic), not bpm/period; `Exponential` interpolates speed geometrically. Rationale: a tempo map may change beat unit across segments, so only speed gives a beat-unit-independent wall-clock schedule | §"Conversion", `req:time:linear-interpolates-speed` + rationale | `tempo.rs::SpeedModel` | +| 2.2 Field-collision effect tag | P11-C3 | **decided: winner-carries-`Conflicted`** — the later op (which materializes and noticed the collision) reads `Conflicted`; the earlier op keeps `Applied`. Chosen for order-independence; a UI reads the record's `loser` field for "your edit was overridden" | `req:semops:field-collision-effect` + rationale; RespellPitch reduction rule | `reduce.rs::respell_pitch` | +| 2.3 `>2`-way promotion | P11-C4 | **adopt + lifted to normative** — order-independent pre-pass: bucket by voice, walk by OperationId, retain a non-overlapping set, promote each overlapping loser (lowest-id retained survivor wins); applies to **partial** interval overlaps, not just identical onsets | §"System-Promoted Voices", `req:graph:promotion-generalization` | `reduce.rs::compute_promotions` | +| 2.4 Open-vocab enums | P11-C9 | **decided: pinned core sets, kept `Registered`** — `TransactionCategory ∈ {NoteEntry, Structural, Layout, Import, Registered}`; `ObjectKind ∈ {Voice, Pitch, Registered}` (narrower than the 28 object kinds: only kinds minted into the system namespace) | `req:semops:transaction-category`, `req:graph:object-kind-vocab` | `payload.rs`, `support.rs` | +| 2.5 `ResolveConflict` Dismissed | P11-C10 | **decided: added `ResolutionAction::Dismiss`** (code + spec) — closes the half-unreachable state machine; the Dismiss action selects the `Dismissed` state, every other action selects `Resolved` | §"Conflict Resolution Operations" | `conflict.rs`, `reduce.rs::resolve_conflict`, `resolve_conflict_with_dismiss_reaches_dismissed_state` | +| 2.6 Layout-object id | layout P11-2 | **decided + registered tag (Track A)** — `MUSCLOID`-tagged derivation; keys multiply-manifested objects on `(source, region)`, synthesized objects on `(source, synthesis_kind, stable_semantic_instance_key)`. Non-canonical (not document state); consumed by the solver/renderer | §"Provenance", `req:layoutir:object-id-derivation` | `layout-ir` provenance | + +## Bucket 3 — Fixes (spec was contradictory or silent) + +| Item | P11 id | Disposition | Spec locus | Test/code anchor | +|---|---|---|---|---| +| 3.1 Blob hashing shape | P11-D3 | **fixed (spec bug)** — bare `BLAKE3("MUSCBLOB" ‖ payload)`; deleted the contradictory "identically to chunks" phrasing so the structured-vs-bare contradiction cannot recur | §"Blobs", `req:format:blob-hash-shape` | `determinism/hash.rs::of_blob` | +| 3.2 Equal-generation tie-break | P11-D1 | **fixed (gap) — adopted code rule** — equal generation + equal load-bearing fields {manifest_hash, manifest_schema_version, reduction_algorithm_version, profile_id} → equivalent, pick A; any divergence → `DivergentSameGeneration`, read-only. Advisory fields (commit_timestamp, offset/length) excluded | §"Superblock Selection" + rationale | `superblock.rs::selection_equivalent` | +| 3.3 RetentionPolicy placement | P11-D6 | **fixed (silent) — defined `ProfileConstraints`** (was a dangling forward reference) and placed the required `retention_policy` in it; **decided** multi-profile precedence = first-declared profile | §"Format Profiles", `req:format:profile-constraints` | `manifest.rs::ProfileConstraints` | +| 3.4 DVV zero-based floor | P11-C7 | **fixed (promote to normative)** — `vector[r]=n` ⟹ `(r,0..=n)` are predecessors; zero-based floor is normative so a one-based implementation can't diverge on pending detection. No behavior change | §"Causal Context via Dotted Version Vectors" | `causal.rs` | +| 3.5 Invariant count 18→19 | P11-2 | **fixed** — QUICKSTART 18→19; spec body states the count is 19 and names the three *construction-time* MUSTs (time-signature beat-group sum, ordering-DAG acyclicity, non-degenerate TupletRatio). **Tuplet honesty:** added `TupletRatio::new` rejecting degenerate ratios at construction (zero term or `actual==notated`) + codec decode validation; removed the now-redundant runtime sub-check | QUICKSTART; §"Graph Invariants" note; §"Tuplets" `req:time:tuplet-ratio-construction` | `graph.rs::TupletRatio::new`, `degenerate_tuplet_ratio_is_rejected_at_construction` | + +## No-spec-change items (recorded resolved, no Pass 11 action) + +`P11-C1` operation payload schemas → Track B (Operation Catalog). `P11-C5` +nearest-anchor stand-in → resolves with the graph-mutation phase. `P11-C6` +time-model compatibility → Track B. `P11-C8` forward undo → Track B. `P11-5` +Chapter 4 tuning catalog → Track C. `P11-D2` Binary Format companion → Track B +(item 1.8 baselines it). `layout P11-1` layout→ops dependency → crate-topology +call for the G–K re-cut (recommend blessing the dependency). + +**Tally:** 8 adopt-and-pin, 6 decide-then-pin, 5 fix, 6 no-spec-change-defer = +25 candidate items. All 19 ratifiable items are ratified; the 6 deferrals are +recorded with their owning track. diff --git a/spec/PASS11_WORKLIST.md b/spec/PASS11_WORKLIST.md new file mode 100644 index 0000000..f1a11f2 --- /dev/null +++ b/spec/PASS11_WORKLIST.md @@ -0,0 +1,211 @@ +# Pass 11 — Spec Ratification Worklist + +*Purpose: convert the v0 implementation's provisional choices into ratified spec text, so the durable byte layouts are fixed before the next-phase build-outs (visible slice, interchange core) start writing real documents on top of them.* + +*Scope: this is a **spec revision pass**, not an architecture pass. The architecture stays frozen. Every item below is either (a) a value the spec must pin, (b) a place the spec must show a field/derivation it implied but never wrote, or (c) a genuine spec-internal contradiction to fix. No item requires reopening a Pass 1–10 design decision.* + +*Working rule (unchanged from QUICKSTART): the spec is the contract. Where this worklist says "adopt," the disposition is to bless the implementation's existing choice in normative spec text. Where it says "decide," there is a real fork the implementer should resolve and record. Where it says "fix," the spec text is self-contradictory or missing and must change regardless of the code.* + +--- + +## How to use this document + +The 25 items are grouped into three buckets by disposition difficulty: + +- **Bucket 1 — Adopt-and-pin (mechanical).** The implementation made a deterministic, golden-locked choice; the spec just needs to write it down. Low judgment, high volume. An agent can draft all of these from the code directly. **Start here** — it unblocks the most downstream work for the least deliberation. +- **Bucket 2 — Decide-then-pin (judgment).** There is a real fork the spec left open; the implementation picked one branch but the choice has semantic consequences worth a deliberate call. +- **Bucket 3 — Fix (spec is wrong or silent).** The spec text contradicts itself or omits a required field. These change the spec independent of the code. + +Each item carries: the source P11 id, the spec location to edit, the implementation's current choice (with the code anchor), a recommended disposition, and the **blast radius** (what downstream work is unblocked by ratifying it). + +A golden-bytes test already locks every byte-layout item. **Ratification protocol per item:** (1) write the normative spec text; (2) if the spec adopts the code's layout, confirm the existing golden test is now the *spec's* golden, not just the crate's proposal — annotate the test to cite the ratified spec section; (3) if the spec overrides the code, update both the code and the golden in the same commit and add a migration note. Either way, no provisional byte layout survives this pass unannotated. + +--- + +## Bucket 1 — Adopt-and-pin (mechanical, do first) + +These are the items gating durable bytes. Every one is a discriminant table, derivation preimage, or encoding layout that already exists in code, is golden-locked, and just needs blessing. Ratifying this whole bucket is what converts "provisional encoding" to "stable format" — the precondition for the interchange track (J/K) and for any document a user saves and reopens later. + +### 1.1 — `TypedObjectId` discriminant table `[P11-1]` +- **Spec edit:** Chapter 5 §"Identifier Family" / wherever `TypedObjectId::canonical_bytes` is defined. The spec fixes the *shape* (16-bit big-endian discriminant + variant payload) but the variant list ends "…and so on for every named object kind." +- **Code choice:** declaration-order discriminants `Event = 0 … AnalysisLayer = 21`, then the kinds beyond the spec's explicit list: `Tuplet = 22, RepeatStructure = 23, LyricLine = 24, ChordSymbol = 25, View = 26, Registered = 27`. `Registered(reg, raw)` encodes `discriminant(2) || reg.canonical_bytes(16) || raw_be(16)`. Locked by `typed_object_id_byte_form_is_locked`. +- **Disposition:** **Adopt.** The table is reasonable and complete against the current graph. Two confirmations needed: (a) the full object-kind set is closed at these 27 — confirm no graph object kind is missing; (b) `ObjectKindRegistryId` is a 128-bit value. +- **Blast radius:** every content hash, every canonical ordering, every equality over objects. This is the single highest-leverage ratification in the pass — most other byte layouts transitively reference object ids. + +### 1.2 — Promoted-voice id derivation `[P11-3]` +- **Spec edit:** Chapter 5 §"System-Promoted Voices" / the semantic-operations companion's `derive_promoted_voice_id`. +- **Code choice:** 64-byte `MUSCSVCE` preimage = `staff_instance || original_voice || winning_op || losing_op`, each 16 big-endian bytes. Locked by `promoted_voice_id_byte_form_is_locked`. (Note the cross-crate dependency: this is **also** ops `P11-C4` — Agent C's reducer computes the same derivation Agent B's Invariant 18 verifies. Ratify once; both crates consume it.) +- **Disposition:** **Adopt.** Confirm field order and that staff-instance-from-containment is an acceptable recovery (the op does not store it redundantly). +- **Blast radius:** unblocks Invariant 18 verification and the ops promotion pre-pass from "provisional." Required before the Operation Catalog (K) fills in the real promotion payload. + +### 1.3 — System-derived (synthetic) pitch id derivation `[P11-6]` +- **Spec edit:** Chapter 5 §"System-Derived Identifiers" (the `MUSCSPCH` tag is reserved but the derivation function is deferred). +- **Code choice:** `derive_system_pitch_id` content-addresses the pitch from a fixed canonical byte form of intrinsic identity (scale position + acoustic realization; strings length-prefixed and NFC-normalized at the derivation boundary). Locked by `system_pitch_id_byte_form_is_locked`. +- **Disposition:** **Adopt**, but **decide one sub-point**: the exact field set that constitutes "intrinsic identity." The code uses scale position + acoustic realization. Confirm that's the complete identity (e.g., does a synthetic pitch's identity include or exclude its tuning reference when tuning is `Inherit`?). This touches Invariant 11. +- **Blast radius:** Invariant 11 enforcement; any future synthetic-pitch minting. + +### 1.4 — `IntegrityAnomalyId` derivation + anomaly domain tag `[P11-C2]` +- **Spec edit:** Chapter 5 (`IntegrityAnomaly` id) + the system-tag registry. +- **Code choice:** `derive_system_id(MUSCSANM, kind.canonical_bytes())` in the `SYSTEM_DERIVED` namespace, introducing a new `MUSCSANM` extension system tag. +- **Disposition:** **Adopt + decide:** confirm `MUSCSANM` should be a *built-in* reserved system tag alongside `MUSCSVCE`/`MUSCSPCH` (recommended — anomalies are core, not an extension), rather than an extension tag. If built-in, add it to the spec's closed tag vocabulary. +- **Blast radius:** cross-replica agreement on anomaly identity. Needed before equivocation/anomaly handling is part of any conformance claim. + +### 1.5 — `ChunkKind` / `ProfileId` / `CompressionAlgorithm` discriminants `[P11-D4]` +- **Spec edit:** Chapter 8 §"Domain-Separated Preimages" — `ChunkKind::canonical_bytes()` is in the chunk hash preimage, so its discriminants are normative. +- **Code choice:** declaration-order single-byte discriminants: `ChunkKind` `OperationEnvelopeBlock = 0 … Manifest = 8`; `ProfileId` `0–3`; `CompressionAlgorithm` `0–2`. +- **Disposition:** **Adopt.** Same situation as 1.1 for the bundle layer. +- **Blast radius:** every chunk hash. `ChunkKind` in particular changes content addresses, so this gates the bundle format's stability. + +### 1.6 — `ManifestId` derivation preimage `[P11-D5]` +- **Spec edit:** Chapter 8 §"The Manifest" (says "each commit produces a new `ManifestId`" + assigns `MUSCMNIF`, but gives no preimage). +- **Code choice:** `trunc128(BLAKE3("MUSCMNIF" || document_id || generation || manifest_body))`, where `manifest_body` is the canonical manifest encoding with the `manifest_id` field excluded (avoids self-reference). +- **Disposition:** **Adopt.** Confirm the self-reference exclusion is stated normatively (a conforming writer must zero/omit `manifest_id` when computing it). +- **Blast radius:** two conforming writers must derive identical manifest ids; gates multi-writer interop. + +### 1.7 — `RationalTime` + scalar canonical encodings `[P11-4]` +- **Spec edit:** Appendix D / Chapter 8 — explicitly deferred to the Binary Format companion, but the primitive layouts can be ratified now independent of the full companion. +- **Code choice:** `RationalTime` = sign + length-prefixed big-endian numerator and denominator magnitudes, always reduced; wall-clock integers little-endian (matching `QuantizedCoord`). +- **Disposition:** **Adopt the primitive layouts now**, leave the *composite* whole-Score codec (1.8) flagged for the companion. The primitives are stable, small, and referenced everywhere; pinning them de-risks the companion. +- **Blast radius:** the foundation for every higher composite encoding. + +### 1.8 — Whole-`Score` canonical codec conventions `[P11-4 / P11-C "Provisional encoding" / P11-D2]` +- **Spec edit:** This is the Binary Format companion's job in full, but the *conventions* the three crates already share should be ratified as the companion's baseline so they don't drift apart before it's written. +- **Code choice (uniform across core/ops/bundle):** little-endian integers; single discriminant byte per tagged union; `u32` counts/length-prefixes; every variable-width leaf length-prefixed; raw (non-NFC) UTF-8 for free-text fields, NFC only for catalog ids at construction. +- **Disposition:** **Adopt as the companion's baseline convention set.** Do not attempt to write the full companion in Pass 11 — that's Track B/Agent J. Pass 11's job is to bless the conventions so core/ops/bundle stay mutually consistent until J formalizes them. Record explicitly that the companion *inherits* these conventions rather than re-deriving them. +- **Blast radius:** this is the seam between Pass 11 and the interchange track. Getting the convention baseline ratified means Agent J writes a companion that matches three crates instead of reconciling three divergent codecs. + +--- + +## Bucket 2 — Decide-then-pin (judgment calls) + +Each has a real fork. The implementation picked a branch; the choice has semantic weight. Resolve deliberately and record the rationale in the spec, not just the verdict. + +### 2.1 — Tempo "Linear" interpolation parameter `[P11-7]` +- **Fork:** Chapter 3 says a `Linear` segment is "linear interpolation from `start_tempo` to `end_tempo`" without saying *what* interpolates linearly: bpm, period, or speed. +- **Code choice:** interpolates **speed** (whole notes per second) linearly; `Exponential` interpolates speed geometrically. Speed is beat-unit-agnostic and coincides with linear-bpm when both tempos share a beat unit. +- **Recommendation:** **adopt speed-linear**, and state the rationale in the spec (beat-unit-agnosticism is the right invariant for a format that supports tempo changes across beat-unit changes). But this is a genuine musical-semantics call — a conductor's "accelerando" intuition is arguably linear-in-bpm. Worth one deliberate confirmation because it changes the derived wall-clock schedule and therefore playback timing forever. +- **Blast radius:** every wall-clock schedule derived from a tempo map; playback, and any `wallclock_to_musical` inverse. + +### 2.2 — Per-operation effect tag in a field collision `[P11-C3]` +- **Fork:** for concurrent differing `RespellPitch`es the spec pins the *conflict record* (kind `StructuralFieldCollision`, winner/loser) and says the later-in-canonical-order op wins and materializes. It does not pin which participant's `OperationEffect` reads `Conflicted`. +- **Code choice:** tags the **winner** (later op, which materializes and whose processing created the record) `Conflicted`; leaves the earlier op's `Applied` in place. +- **Recommendation:** **decide deliberately.** There's a defensible alternative: tag the *loser* `Conflicted` (or `NoOp{SupersededByLaterOperation}`) since it's the one whose intent didn't survive, and leave the winner `Applied`. The code's choice (winner carries the flag because it's the one that *noticed* the collision) is order-independent and defensible, but the loser-tagged alternative is arguably more intuitive for a UI surfacing "your edit was overridden." Pick one, state why. Sub-point to resolve in the same breath: whether the superseded loser retroactively reads `NoOp{SupersededByLaterOperation}`. +- **Blast radius:** any UI or analytics consuming per-op effects; conflict-resolution UX. + +### 2.3 — `>2`-way voice-promotion collision `[P11-C4]` +- **Fork:** the spec describes a *pairwise* promotion rule. Three or more concurrent overlapping inserts into the same voice need a generalization the spec doesn't give. +- **Code choice:** order-independent pre-pass — bucket inserts by voice, walk by `OperationId`, keep a non-overlapping set in the original voice, promote each concurrent overlapping loser; the first lower-id overlapping op retained in the original voice is the winner for each promotion. Also applies the rule to partial interval overlaps, not just identical start positions. +- **Recommendation:** **adopt**, and **lift the generalization into the spec normatively** (it's currently only in code). The "lowest-id retained survivor wins" rule is deterministic and matches the pairwise rule's spirit. Confirm the partial-overlap extension is intended (it's stricter than the spec's identical-start-position language — arguably correct, but it's a widening of the rule). +- **Blast radius:** any score with 3+ concurrent inserts in one voice; the Operation Catalog's promotion payload. + +### 2.4 — Open-vocabulary enums: `TransactionCategory`, `ObjectKind` `[P11-C9]` +- **Fork:** the spec calls these open vocabularies. The code gives minimal core sets with a `Registered` escape: `TransactionCategory ∈ {NoteEntry, Structural, Layout, Import, Registered}`; `ObjectKind ∈ {Voice, Pitch, Registered}` (only the kinds actually derived into the system namespace). +- **Recommendation:** **decide the core set, keep `Registered`.** For `TransactionCategory`, confirm the four core categories are the right minimal set (UIs/analytics consume it; under-specifying is cheap to extend, over-specifying is not). For `ObjectKind`, note it's intentionally narrower than `TypedObjectId`'s 27 kinds because only Voice/Pitch are minted into the system namespace today — confirm that's the intended scoping rather than an oversight. +- **Blast radius:** `SystemIdentifierCollision` payloads (`ObjectKind`); UI/analytics (`TransactionCategory`). + +### 2.5 — `ResolveConflict` Dismissed selection `[P11-C10]` +- **Fork:** the spec distinguishes `Resolved` from `Dismissed` resolution states but provides one `ResolveConflictPayload { target, action }`. The code maps every applied resolve to `Resolved { action }`; `Dismissed` is a reachable state but no representative op authors it. +- **Recommendation:** **decide:** either add a distinct `action` variant that selects Dismissed, or a separate payload. Recommend the former (a `ResolutionAction::Dismiss` variant) — it's the smaller change and keeps one payload type. This is a small but real gap: without it, half the resolution-state machine is unreachable by authored operations. +- **Blast radius:** conflict-resolution UX; the Operation Catalog's `ResolveConflict` schema. + +### 2.6 — Layout-object id derivation `[layout P11-2]` +- **Fork:** Chapter 7 declares `LayoutObjectId(pub u128)` and requires stability across relayouts but specifies neither the derivation, whether it's domain-separated, how a multiply-manifested object (a staff in two regions) is keyed, nor how synthesized objects are keyed. +- **Code choice:** keys multiply-manifested objects on `(source, region)`; synthesized objects on `(source, synthesis_kind, stable_semantic_instance_key)`. No domain tag registered. +- **Recommendation:** **decide + register a tag.** Pin the derivation, the manifestation-context key, and the synthesized-object key. Appendix D's domain-separation discipline suggests registering a dedicated `MUSC*` layout tag — recommend doing so for consistency even though layout ids are non-canonical (they don't enter document state, but stability-across-relayout is easier to reason about with a fixed derivation). This one is lower-stakes than the Bucket 1 ids because layout ids are not canonical document state — flag it for Track A (the visible slice) rather than blocking on it. +- **Blast radius:** incremental relayout correctness; provenance back-references. Consumed by Track A (solver + renderer), not by the interchange track. + +--- + +## Bucket 3 — Fix (spec is contradictory or silent) + +These change the spec regardless of the code, because the spec text is wrong or missing. + +### 3.1 — Blob hashing shape contradiction `[P11-D3]` ⚠️ spec bug +- **The contradiction:** Chapter 8 §"Blobs" says blobs are "content-addressed identically to chunks (BLAKE3 of uncompressed payload, with the `MUSCBLOB` domain tag)." "Identically to chunks" implies the **structured** preimage (committing to kind, schema version, and length); "BLAKE3 of uncompressed payload with the domain tag" implies a **bare** `MUSCBLOB || payload`. These disagree. +- **Code choice:** follows Agent A's `ContentHash::of_blob` = bare `MUSCBLOB || payload` (the only spec content hash documented as a bare `domain || payload`). +- **Disposition:** **Fix the spec text** to state exactly one. Recommend **bare** (matches the existing `of_blob` and the intuition that a blob is opaque payload with no schema), and **delete the "identically to chunks" phrasing** so the contradiction can't recur. If the structured form is wanted instead, the code and golden change with it. +- **Blast radius:** every `BlobId`. Must be resolved before blobs are stored in any durable bundle. + +### 3.2 — Superblock equal-generation tie-break `[P11-D1]` +- **The gap:** Chapter 8 §"Superblock Selection" says "the slot with the higher generation is active" but gives no rule for two valid slots at the *same* generation — which the QUICKSTART nonetheless lists as a harness scenario. +- **Code choice:** equal generation + equivalent load-bearing fields (`manifest_hash`, `manifest_schema_version`, `reduction_algorithm_version`, `profile_id`; advisory `commit_timestamp` and physical offset/length excluded) → equivalent, pick A. Equal generation differing in any load-bearing field → `IntegrityAnomaly::DivergentSameGeneration`, opened read-only. +- **Disposition:** **Fix (add the missing rule).** Adopt the code's rule into Chapter 8 normatively. It's the conservative correct call: two genuinely different committed states cannot share a generation under a conforming writer, so divergence is an integrity anomaly, not a silent pick. State the exact load-bearing field set (the exclusion of advisory fields is the subtle part). +- **Blast radius:** crash-recovery determinism; the manifest-selection harness's correctness depends on this being specified. + +### 3.3 — `RetentionPolicy` field placement `[P11-D6]` +- **The gap:** Chapter 8 §"Garbage Collection and Retention" requires "the active conformance profile MUST declare a `RetentionPolicy`," but the `ProfileDeclaration` / `ProfileConstraints` structs shown in §"Format Profiles" don't include the field. +- **Code choice:** `retention_policy` placed inside `ProfileConstraints`; a bundle declaring multiple profiles resolves retention from the first declared profile. +- **Disposition:** **Fix (show the field).** Add `retention_policy` to the `ProfileConstraints` struct in the spec, and **decide** the multi-profile resolution rule (the code uses first-declared; confirm or specify a precedence). This is mostly mechanical but the multi-profile precedence is a small real decision. +- **Blast radius:** GC correctness; any bundle with a non-trivial retention policy. + +### 3.4 — DVV zero-based counter floor (make normative) `[P11-C7]` +- **The gap:** the DVV's contiguous `vector[r] = n` asserts predecessors `(r, 0..=n)` exist. The code relies on this zero-based floor; the spec documents it in prose but should retain it explicitly in the *normative* DVV definition (it's load-bearing for the missing-causal-predecessor pending logic). +- **Code choice:** zero-based per-replica counter floor; range check walks known ids rather than expanding `0..=n` (so a sparse high-counter context doesn't cause proportional work). +- **Disposition:** **Fix (promote to normative).** No behavior change — just ensure the normative DVV definition states the zero-based floor so a second implementation can't choose a one-based floor and silently diverge on pending detection. +- **Blast radius:** cross-implementation agreement on which operations are pending vs. ready. + +### 3.5 — Invariant count: 18 vs 19 `[P11-2]` +- **The gap:** QUICKSTART says "18 graph invariants"; Chapter 5 body enumerates 19; the code implements 19. +- **Disposition:** **Fix the QUICKSTART** (it's the stale doc; the spec body is authoritative). Trivial, but do it so the count is consistent across all three artifacts. While here, confirm the three Chapter-3 "reject at construction" rules (`TimeSignature` beat-group sum, `EventOrderingDAG` acyclicity, `Tuplet` degenerate ratios) are correctly *outside* the 19-invariant enumeration — the code enforces them at construction, which is faithful, but the spec should be explicit that they're construction-time MUSTs, not runtime invariants. +- **Blast radius:** documentation consistency only; no bytes. + +--- + +## Items that need no spec change (record as resolved) + +These appear in the DECISIONS files as P11 candidates but, on review, require no ratification — they resolve when later phases land, and the code already records the approximation honestly. List them so the agent doesn't spend time on them: + +- **`P11-C1` (operation payload schemas carry identifiers + fingerprints):** resolves when the Operation Catalog (Track B/Agent K) lands. No Pass 11 action — the payload *schemas* are that companion's deliverable, not a ratification item. The provisional projection is honest. +- **`P11-C5` ("nearest surviving anchor" stand-in):** resolves when the graph-mutation phase tracks resolved positions. The rule *structure* is faithful; only the metric is approximated. No spec change. +- **`P11-C6` (time-model compatibility computed when graph available):** the rich migration payload belongs to the Operation Catalog. No Pass 11 action. +- **`P11-C8` (forward undo via minted-object compensation):** faithful to the spec's content-equivalence definition for insert-shaped transactions; per-primitive inverses are the Operation Catalog's job. No spec change. +- **`P11-5` (Chapter 4 tuning catalog is a separate subsystem):** a scope boundary, not an ambiguity. Becomes Track-C work (the tuning catalog) when scheduled. No Pass 11 action. +- **`P11-D2` (Binary Format companion not yet written):** the companion is Track B/Agent J. Pass 11 ratifies the *convention baseline* (item 1.8) so the crates stay consistent until then, but does not write the companion. +- **`layout P11-1` (Agent E dependency set vs. edit-barrier types):** a build-organization question (does layout depend on ops for `OperationKindTag`, or do the edit-barrier types relocate?). Decide this when re-cutting the agents for G–K — it's a crate-topology call, not a spec-byte call. Recommend blessing the layout→ops dependency (the discriminator type is small and stable) rather than relocating types. + +--- + +## Suggested execution order for the agent + +1. **Bucket 1 in id-dependency order:** 1.1 (`TypedObjectId`) first — most things reference object ids — then 1.5 (`ChunkKind`), then the derivations that build on them (1.2, 1.3, 1.4, 1.6), then the encodings (1.7, 1.8). Draft each as normative spec text + annotate the existing golden test to cite the ratified section. This is the bulk of the value and the least deliberation. +2. **Bucket 3 fixes:** 3.1 (blob contradiction) is the only true spec *bug* and gates durable blobs — do it early. 3.2–3.5 are mechanical-with-one-decision-each. +3. **Bucket 2 judgment calls:** these benefit from a short written rationale each. 2.1 (tempo-linear) and 2.2 (effect tag) are the two with lasting semantic weight; give them the most thought. 2.6 (layout ids) can defer to Track A. + +**Deliverable:** a spec revision (call it the Pass 11 revision, consistent with the revision-history convention) plus a one-line-per-item ratification log recording the disposition (adopt / decided-X-because-Y / fixed) for each of the 25. The log is what lets the next-phase agents trust that "provisional" is now "ratified." + +**Do not:** open any architectural question, rewrite any Pass 1–10 decision, or attempt the Binary Format companion or Operation Catalog here. Those are Track B. Pass 11's boundary is exactly: pin what exists, fix what contradicts, and ratify the convention baseline the companions will inherit. + +--- + +## One-line inventory (for tracking) + +| Item | P11 id | Bucket | Disposition | Gates | +|---|---|---|---|---| +| 1.1 TypedObjectId discriminants | P11-1 | 1 | Adopt | all object hashing/ordering | +| 1.2 Promoted-voice id | P11-3 / C4 | 1 | Adopt | Inv 18; promotion payload | +| 1.3 Synthetic-pitch id | P11-6 | 1 | Adopt + 1 decision | Inv 11 | +| 1.4 IntegrityAnomalyId + tag | P11-C2 | 1 | Adopt + tag call | anomaly agreement | +| 1.5 ChunkKind/Profile/Compression discriminants | P11-D4 | 1 | Adopt | all chunk hashing | +| 1.6 ManifestId preimage | P11-D5 | 1 | Adopt | multi-writer interop | +| 1.7 RationalTime + scalars | P11-4 | 1 | Adopt | all composite encoding | +| 1.8 Whole-Score codec conventions | P11-4/D2 | 1 | Adopt as baseline | interchange track seam | +| 2.1 Tempo Linear parameter | P11-7 | 2 | Recommend speed-linear | playback timing | +| 2.2 Field-collision effect tag | P11-C3 | 2 | Decide winner vs loser | conflict UX | +| 2.3 >2-way promotion | P11-C4 | 2 | Adopt + lift to spec | 3+ concurrent inserts | +| 2.4 Open-vocab enums | P11-C9 | 2 | Decide core sets | collision payloads, UI | +| 2.5 ResolveConflict Dismissed | P11-C10 | 2 | Add Dismiss action | conflict state machine | +| 2.6 Layout-object id | layout P11-2 | 2 | Decide + tag (defer to Track A) | relayout stability | +| 3.1 Blob hashing shape | P11-D3 | 3 | **Fix** (recommend bare) | every BlobId | +| 3.2 Equal-gen tie-break | P11-D1 | 3 | Fix (adopt code rule) | crash recovery | +| 3.3 RetentionPolicy placement | P11-D6 | 3 | Fix + multi-profile call | GC | +| 3.4 DVV zero-based floor | P11-C7 | 3 | Fix (make normative) | pending detection | +| 3.5 Invariant count 18→19 | P11-2 | 3 | Fix QUICKSTART | docs | +| — payload schemas | P11-C1 | none | defer to Track B | — | +| — nearest-anchor stand-in | P11-C5 | none | resolves with graph phase | — | +| — time-model compat | P11-C6 | none | defer to Track B | — | +| — forward undo | P11-C8 | none | defer to Track B | — | +| — tuning catalog scope | P11-5 | none | defer to Track C | — | +| — Binary Format companion | P11-D2 | none | Track B (1.8 baselines it) | — | +| — layout/ops dependency | layout P11-1 | none | crate-topology call for G–K | — | + +25 candidate items: **8 adopt-and-pin, 6 decide-then-pin, 5 fix, 6 no-spec-change-defer.** (1.2/C4 and the layout dependency note are counted once each at their primary home.) diff --git a/spec/QUICKSTART.md b/spec/QUICKSTART.md index 396ac5a..96fbbb3 100644 --- a/spec/QUICKSTART.md +++ b/spec/QUICKSTART.md @@ -53,7 +53,7 @@ Spec sections: Appendix D in full, Chapter 4 §4.6 (frequency units), Chapter 7 ### Agent B — `epiphany-core` -Owns the score graph: the identifier family (`EventId`, `PitchId`, `VoiceId`, ..., `TypedObjectId`), the `ReplicaId::SYSTEM_DERIVED` reserved value and counter derivation (`trunc64(BLAKE3(domain_tag || canonical_inputs))`), `IdentifiedPitch`, `PitchSpelling`, `ScalePosition`, the duration union (`EventDuration` / `ConcreteDuration`), `MusicalPosition`, `WallClockTime`, `TimeAnchor`, `AnchorOffset`, the event arena, `Voice`, `Staff` and `StaffInstance` (these are distinct types — the spec is explicit), `Region`, `BarlineAlignmentGroup`, and the 18 graph invariants enumerated in Chapter 5. +Owns the score graph: the identifier family (`EventId`, `PitchId`, `VoiceId`, ..., `TypedObjectId`), the `ReplicaId::SYSTEM_DERIVED` reserved value and counter derivation (`trunc64(BLAKE3(domain_tag || canonical_inputs))`), `IdentifiedPitch`, `PitchSpelling`, `ScalePosition`, the duration union (`EventDuration` / `ConcreteDuration`), `MusicalPosition`, `WallClockTime`, `TimeAnchor`, `AnchorOffset`, the event arena, `Voice`, `Staff` and `StaffInstance` (these are distinct types — the spec is explicit), `Region`, `BarlineAlignmentGroup`, and the 19 graph invariants enumerated in Chapter 5. **Critical**: graph invariants are property tests in CI, not runtime assertions in release builds. The testkit provides arbitrary-instance generators. For each invariant, you must have a positive generator that produces valid graphs and a negative shrinker that minimizes invariant violations to a small witness for debugging. diff --git a/spec/core_spec.pdf b/spec/core_spec.pdf index 35c0469..bd1c4c6 100644 Binary files a/spec/core_spec.pdf and b/spec/core_spec.pdf differ diff --git a/spec/core_spec.tex b/spec/core_spec.tex index 0fbfe7c..a1b9a69 100644 --- a/spec/core_spec.tex +++ b/spec/core_spec.tex @@ -2011,6 +2011,43 @@ For constant segments, conversion is multiplication. For linear and exponential segments, conversion has a closed-form solution. For curve segments, conversion is numerical. +\begin{requirement} + \label{req:time:linear-interpolates-speed} + For a \texttt{TempoShape::Linear} segment, the quantity that varies + linearly across the segment is \emph{speed} --- whole notes per + second --- as a function of fractional position within the segment, + \emph{not} beats-per-minute and \emph{not} beat period. Speed is + derived from a \texttt{Tempo} as + $\text{speed} = \text{bpm} \times w(\text{beat\_unit}) / 60$, where + $w(\cdot)$ is the beat unit's value in whole notes. Writing + $s_0, s_1$ for the segment's start and end speeds and + $u \in [0,1]$ for fractional position, $s(u) = s_0 + (s_1 - s_0)\,u$, + and the wall-clock duration of the segment of musical length + $\ell$ is the closed form + $\int_0^1 \ell\,\mathrm{d}u / s(u) = (\ell / (s_1 - s_0))\, + \ln(s_1 / s_0)$ (and $\ell / s_0$ when $s_1 = s_0$). + + A \texttt{TempoShape::Exponential} segment interpolates speed + \emph{geometrically}: $s(u) = s_0\,(s_1/s_0)^{u}$, i.e. a constant + continuous rate of change of speed. +\end{requirement} + +\begin{rationale} + ``Linear interpolation from \texttt{start\_tempo} to + \texttt{end\_tempo}'' is ambiguous about which quantity is linear: + bpm, period, or speed. We pin \emph{speed} because it is the only + beat-unit-agnostic choice. A tempo map may change beat unit across + segments (a \texttt{quarter = 120} segment abutting a + \texttt{dotted-quarter = 80} segment); interpolating bpm would make + the wall-clock schedule depend on the arbitrary beat unit chosen to + notate each endpoint, whereas interpolating speed depends only on + the sounding rate. When both endpoints share a beat unit, speed-linear + and bpm-linear coincide, so the common case matches a conductor's + ``linear accelerando'' intuition. Because this choice fixes the + derived wall-clock schedule for every playback engine, it is stated + normatively rather than left to the implementation. +\end{rationale} + \begin{requirement} The tempo map \MUST{} expose two conversion functions: \texttt{musical\_to\_wallclock} and \texttt{wallclock\_to\_musical}. @@ -2178,6 +2215,20 @@ pub struct TupletRatio { } \end{lstlisting} +\begin{requirement} + \label{req:time:tuplet-ratio-construction} + A \texttt{TupletRatio} is \emph{degenerate} if either term is zero + or if \texttt{actual == notated} (a ratio that expresses no + augmentation or diminution). Degenerate ratios \MUST{} be rejected + at construction: a conforming constructor of \texttt{TupletRatio} + (and therefore of any \texttt{Tuplet}) \MUST{} fail rather than + produce a value with \texttt{actual == 0}, \texttt{notated == 0}, or + \texttt{actual == notated}. This is a construction-time rejection, + not a runtime graph invariant (Section~\ref{sec:graph:invariants}): + a degenerate ratio is never a representable graph state, so no + invariant restores it after the fact. +\end{requirement} + \subsection{Tuplet Nesting} Tuplets \MAY{} nest to arbitrary depth. The sounding duration of an @@ -2367,6 +2418,7 @@ encoding admits both as cases. \end{requirement} \begin{requirement} + \label{req:time:ordering-dag-acyclic} The ordering DAG \MUST{} be acyclic. Cycles \MUST{} be rejected at construction. Two events with no path between them in the DAG are unordered. @@ -3394,6 +3446,11 @@ pub enum TypedObjectId { GraphicGesture(GraphicGestureId), TimeSignature(TimeSignatureId), AnalysisLayer(AnalysisLayerId), + Tuplet(TupletId), + RepeatStructure(RepeatStructureId), + LyricLine(LyricLineId), + ChordSymbol(ChordSymbolId), + View(ViewId), /// Extension-defined object kind, identified by registry id /// plus the extension's own 128-bit identifier. Registered(ObjectKindRegistryId, u128), @@ -3407,6 +3464,51 @@ impl TypedObjectId { } \end{lstlisting} +\begin{requirement} + \label{req:graph:typed-object-id-discriminants} + The \texttt{TypedObjectId} variant tag \MUST{} be encoded as a + 16-bit big-endian discriminant followed by the variant payload's + canonical bytes. The discriminant of each variant is its + declaration-order index in the following closed table; this + assignment is normative and stable, and \MUSTNOT{} be reordered: + + \begin{center} + \begin{tabular}{r l @{\qquad} r l} + \toprule + \textbf{Disc.} & \textbf{Variant} & + \textbf{Disc.} & \textbf{Variant} \\ + \midrule + 0 & \texttt{Event} & 14 & \texttt{Spanner} \\ + 1 & \texttt{Pitch} & 15 & \texttt{Marker} \\ + 2 & \texttt{Voice} & 16 & \texttt{AnalyticalAnnotation} \\ + 3 & \texttt{Staff} & 17 & \texttt{Comment} \\ + 4 & \texttt{StaffInstance} & 18 & \texttt{GraphicObject} \\ + 5 & \texttt{StaffGroup} & 19 & \texttt{GraphicGesture} \\ + 6 & \texttt{Region} & 20 & \texttt{TimeSignature} \\ + 7 & \texttt{Instrument} & 21 & \texttt{AnalysisLayer} \\ + 8 & \texttt{PartDefinition} & 22 & \texttt{Tuplet} \\ + 9 & \texttt{Measure} & 23 & \texttt{RepeatStructure} \\ + 10 & \texttt{BarlineAlignmentGroup} & 24 & \texttt{LyricLine} \\ + 11 & \texttt{Slur} & 25 & \texttt{ChordSymbol} \\ + 12 & \texttt{Tie} & 26 & \texttt{View} \\ + 13 & \texttt{Beam} & 27 & \texttt{Registered} \\ + \bottomrule + \end{tabular} + \end{center} + + The object-kind set is closed at these 28 discriminants + (\texttt{0}--\texttt{27}). Every named object kind in the score + graph has a dedicated discriminant; kinds introduced by registered + extensions \MUST{} use discriminant \texttt{27} + (\texttt{Registered}) and are distinguished by their + \texttt{ObjectKindRegistryId}, never by extending this table. The + \texttt{Registered(reg, raw)} payload is encoded as the 128-bit + \texttt{reg.canonical\_bytes()} (16 big-endian bytes) followed by + the 16 big-endian bytes of the \texttt{raw} \texttt{u128}, for a + total canonical form of $2 + 16 + 16 = 34$ bytes. + \texttt{ObjectKindRegistryId} is a 128-bit value. +\end{requirement} + \subsection{Identifier Generation} \begin{requirement} @@ -3488,14 +3590,22 @@ fn derive_system_counter( } \end{lstlisting} - Domain tags for system-derived identifiers: + The built-in (reserved) domain tags for system-derived identifiers + are a closed set of three: \begin{itemize} - \item \texttt{"MUSCSVCE"} for system-promoted voices. + \item \texttt{"MUSCSVCE"} for system-promoted voices + (Section~\ref{sec:graph:promoted-voices}). \item \texttt{"MUSCSPCH"} for system-derived pitches (rare; - only when promotion logic introduces a synthetic pitch). + only when promotion logic introduces a synthetic pitch; + Section~\ref{sec:graph:system-derived-pitch}). + \item \texttt{"MUSCSANM"} for integrity-anomaly identifiers + (Section~\ref{sec:graph:system-collisions}). Anomalies are + core, not an extension concern, so their tag is built in + alongside the other two. \item Additional domain tags introduced by registered - extensions \MUST{} begin with \texttt{"MUSCS"} and have - length exactly 8 bytes. + extensions \MUST{} begin with \texttt{"MUSCS"}, have length + exactly 8 bytes, and \MUSTNOT{} collide with the three + reserved tags above. \end{itemize} Two replicas reducing the same operation set \MUST{} derive @@ -3550,8 +3660,48 @@ pub enum IntegrityAnomalyKind { /// transport. Registered(IntegrityAnomalyRegistryId), } + +/// The kind of object whose system-derived identifier collided. +/// Open vocabulary, but intentionally narrower than TypedObjectId: +/// only the kinds actually minted into the system-derived namespace +/// appear here. +pub enum ObjectKind { + Voice, // system-promoted voices (MUSCSVCE) + Pitch, // content-derived synthetic pitches (MUSCSPCH) + Registered(OperationKindRegistryId), +} \end{lstlisting} +\begin{requirement} + \label{req:graph:integrity-anomaly-id} + An \texttt{IntegrityAnomalyId} \MUST{} be derived by the + system-derived identifier function of + Section~\ref{sec:graph:system-derived} with the reserved domain tag + \texttt{"MUSCSANM"} over the anomaly kind's canonical bytes: + \texttt{derive\_system\_id::(b"MUSCSANM", + \&kind.canonical\_bytes())}. Because the identity is content-derived + from the kind, two replicas observing the same structural failure + derive the same anomaly identifier and therefore agree on anomaly + identity across the network. +\end{requirement} + +\begin{requirement} + \label{req:graph:object-kind-vocab} + \texttt{ObjectKind} (the kind field of + \texttt{SystemIdentifierCollision}) is an \emph{open} vocabulary + whose normative core set is exactly \texttt{Voice} and + \texttt{Pitch}, with a \texttt{Registered} escape for + extension-introduced system-derived kinds. It is deliberately + narrower than \texttt{TypedObjectId}'s 28-kind table + (Requirement~\ref{req:graph:typed-object-id-discriminants}): + \texttt{ObjectKind} enumerates only the kinds that are actually + minted into the \texttt{ReplicaId::SYSTEM\_DERIVED} namespace --- + today, promoted voices and synthetic pitches --- because those are + the only kinds for which a system-derived counter collision is + possible. A new core variant is added here only when a new object + kind begins being minted into the system-derived namespace. +\end{requirement} + \begin{requirement} Implementations \MUST{} perform a collision check during reduction whenever a new system-derived identifier is minted. @@ -3605,6 +3755,37 @@ pub enum IntegrityAnomalyKind { growth until external recovery resolves the structural issue. \end{rationale} +\subsection{System-Derived Pitch Identity} +\label{sec:graph:system-derived-pitch} + +When promotion logic introduces a synthetic pitch (rare), its +\texttt{PitchId} is content-addressed from the pitch's intrinsic +identity rather than allocated from a replica counter, so that all +replicas mint the same identifier for the same synthetic pitch. + +\begin{requirement} + \label{req:graph:system-derived-pitch-id} + A system-derived \texttt{PitchId} \MUST{} be derived by the + function of Section~\ref{sec:graph:system-derived} with domain tag + \texttt{"MUSCSPCH"} over a fixed canonical byte form of the pitch's + \emph{intrinsic identity}, defined as the pair (\emph{scale + position}, \emph{acoustic realization}). All variable-width string + components of this preimage (nominal names, accidental and tuning + catalog references) \MUST{} be length-prefixed and \MUST{} be + NFC-normalized at the derivation boundary, so that + canonically-equivalent Unicode spellings derive the same identifier. + + The acoustic realization's \emph{tuning reference} is always part of + intrinsic identity, including the \texttt{TuningReference::Inherit} + case: \texttt{Inherit} is encoded as a distinct presence marker, not + as the absence of a field. Consequently a synthetic pitch that + inherits its tuning and one that names an explicit tuning are + \emph{distinct} identities even when they would resolve to the same + frequency in context. This makes synthetic-pitch identity stable + under later changes to the inherited tuning environment, and aligns + Invariant~11's uniqueness check with a fixed, context-free preimage. +\end{requirement} + \subsection{Identifier Stability} \begin{requirement} @@ -4389,12 +4570,67 @@ preserves the non-overlap invariant without rejecting user intent. \item The losing operation's \texttt{OperationId}. \end{enumerate} - The exact derivation function (a hash of the concatenated - identifiers, normalized to the replica-plus-counter format - reserved for system-derived identifiers) is specified in the - semantic-operations companion document. Determinism is required so - that all replicas independently arrive at the same promoted - voice identity. + The derivation is the system-derived identifier function of + Section~\ref{sec:graph:system-derived} with domain tag + \texttt{"MUSCSVCE"} over a fixed 64-byte preimage formed by + concatenating the four identifiers in the order listed above, each + as its 16-byte big-endian canonical form: + + \begin{lstlisting}[language=Rust] +fn derive_promoted_voice_id( + staff_instance: StaffInstanceId, + original_voice: VoiceId, + winning_op: OperationId, + losing_op: OperationId, +) -> VoiceId { + // 4 * 16 = 64 bytes, each component big-endian. + let mut inputs = Vec::with_capacity(64); + inputs.extend_from_slice(&staff_instance.canonical_bytes()); + inputs.extend_from_slice(&original_voice.canonical_bytes()); + inputs.extend_from_slice(&winning_op.canonical_bytes()); + inputs.extend_from_slice(&losing_op.canonical_bytes()); + derive_system_id::(b"MUSCSVCE", &inputs) +} + \end{lstlisting} + + The enclosing staff instance is recovered from graph containment at + reduction time; the operation does not store it redundantly. + Determinism is required so that all replicas independently arrive at + the same promoted voice identity. This derivation is the same one + whose result Invariant~18 (Section~\ref{sec:graph:invariants}) + verifies; the reducer that mints the voice and the verifier that + checks it consume one derivation. +\end{requirement} + +\begin{requirement} + \label{req:graph:promotion-generalization} + The promotion rule generalizes from the pairwise case to any number + of concurrent overlapping inserts into the same voice by an + order-independent pre-pass: + + \begin{enumerate} + \item Bucket the concurrent \texttt{InsertEvent} operations by + their target \texttt{(staff\_instance, original\_voice)}. + \item Within a bucket, walk the operations in ascending + \texttt{OperationId} order, maintaining a \emph{retained set} of + mutually non-overlapping inserts that stay in the original voice. + \item An operation whose inserted interval does not overlap any + member of the retained set joins the retained set. An operation + whose interval overlaps some retained member is a \emph{loser} + and is promoted; the lowest-\texttt{OperationId} retained member + it overlaps is its \texttt{winning\_operation}. + \end{enumerate} + + Because the walk is by \texttt{OperationId} and the retained set is + built deterministically, every replica promotes the same operations + and derives the same promoted-voice identities regardless of + delivery order. ``Overlap'' is interval overlap of the half-open + inserted spans \texttt{[start,\,end)}: the rule applies to + \emph{partial} overlaps, not only to inserts that share an identical + start position. This is a deliberate widening of the pairwise rule's + identical-start-position language; it is the correct reading because + any positive-duration overlap breaks the voice non-overlap invariant + (Invariant~3), not just a shared onset. \end{requirement} \begin{requirement} @@ -5099,6 +5335,29 @@ The score graph maintains a set of structural invariants. Implementations changes that restore them within the same operation. \end{requirement} +This enumeration contains exactly \textbf{19} invariants. (Earlier +summary material, including the QUICKSTART, referred to ``18 graph +invariants''; the authoritative count is 19, matching this +enumeration and the reference implementation.) These 19 are +\emph{runtime} invariants: they hold over every well-formed graph and +are restored by compensating changes when an edit would break them. + +Distinct from them are three \emph{construction-time} rejections +defined in Chapter~\ref{ch:time}, which a conforming constructor +\MUST{} enforce before a value ever enters the graph, and which no +runtime invariant restores because the offending value is never a +representable graph state: + +\begin{enumerate} + \item A \texttt{TimeSignature}'s beat groups \MUST{} sum to the + measure duration (Section~\ref{sec:time:meter}). + \item An \texttt{EventOrderingDAG} \MUST{} be acyclic + (Requirement~\ref{req:time:ordering-dag-acyclic}). + \item A \texttt{TupletRatio} \MUSTNOT{} be degenerate + (Section~\ref{sec:time:tuplets}, + Requirement~\ref{req:time:tuplet-ratio-construction}). +\end{enumerate} + \section{Indexes and Auxiliary Structures} \label{sec:graph:indexes} @@ -5290,6 +5549,20 @@ pub struct CausalContext { output or in the operation log's expanded form, but the wire and storage representation of causal context is the DVV. + The per-replica counter floor is \textbf{zero-based} and + normative: \texttt{vector[r] == n} asserts that \emph{every} + operation \texttt{(r,\,0..=n)} is a causal predecessor --- the + contiguous range begins at counter \texttt{0}, not \texttt{1}. A + conforming implementation \MUSTNOT{} adopt a one-based floor: doing + so would shift the contiguous/dot boundary and make two + implementations disagree about which operations are pending versus + ready. The membership check (``is operation \texttt{(r,\,c)} + covered?'') is \texttt{c <= vector[r]} or \texttt{(r,\,c) in dots}; + an implementation \MAY{} evaluate it by walking the known operation + ids rather than materializing the full \texttt{0..=n} range, so a + sparse high-counter context does not force work proportional to the + counter value. + Interval tree clocks and other compact causal representations \MAY{} appear in future revisions as performance optimizations but are non-normative in this specification. @@ -6135,6 +6408,10 @@ pub enum ConflictResolutionState { Dismissed { by: OperationId }, } +// See Requirement (field-collision effect tag): for a +// StructuralFieldCollision the winner (later op) reads Conflicted; +// the earlier op retains Applied. + pub enum ResolutionAction { /// Accept the losing operation's effect (replacing the winner). AcceptLoser, @@ -6145,11 +6422,44 @@ pub enum ResolutionAction { Override { override_operation: OperationId }, /// Re-anchor to a user-chosen target. Reanchor { new_target: TypedObjectId }, + /// Dismiss the conflict without changing the materialized graph: + /// the user acknowledges it and accepts the current (winner) + /// state. Selects the Dismissed resolution state. + Dismiss, /// Custom resolution for registered conflict kinds. Registered(ResolutionRegistryId), } \end{lstlisting} +\begin{requirement} + \label{req:semops:field-collision-effect} + When two concurrent operations write the same non-LWW field and + reduction records a \texttt{StructuralFieldCollision}, the + per-operation \texttt{OperationEffect} is assigned as follows: the + \emph{winner} --- the operation later in canonical order, whose + choice materializes and whose processing created the conflict record + --- reads \texttt{Conflicted\{conflict\}}; the \emph{earlier} + (losing) operation retains the \texttt{Applied} effect it received + when it was first reduced. The winner carries the flag because it is + the single operation that both materializes the field and observes + the collision, which makes the assignment independent of the order + in which envelopes arrive at any replica. +\end{requirement} + +\begin{rationale} + The alternative --- tagging the loser \texttt{Conflicted} (or + \texttt{NoOp\{SupersededByLaterOperation\}}) because its intent did + not survive --- is defensible and arguably more intuitive for a UI + that surfaces ``your edit was overridden.'' We pin + \emph{winner-carries} instead because it is computed at the moment + the collision is detected (when the winning operation is reduced + against already-present state), needs no second pass to retroactively + re-tag an earlier operation, and is manifestly order-independent. A + UI that wishes to tell the losing author their edit was overridden + reads the \texttt{StructuralFieldCollision} record's \texttt{loser} + field rather than the loser operation's effect. +\end{rationale} + \subsection{The Conflict Registry} The score graph carries a top-level conflict registry alongside the @@ -6255,10 +6565,17 @@ pub struct ResolveConflictPayload { \begin{requirement} A \texttt{ResolveConflict} operation transitions a conflict's - state from \texttt{Unresolved} to \texttt{Resolved} (with the - declared action applied) or \texttt{Dismissed} (with no semantic - change to the materialized graph beyond the conflict record's - state). + state from \texttt{Unresolved} to one of two states selected by its + \texttt{action}. The \texttt{ResolutionAction::Dismiss} action + transitions the conflict to \texttt{Dismissed} (no semantic change + to the materialized graph beyond the conflict record's state: the + user acknowledges the conflict and accepts the current winner + state). Every other \texttt{action} transitions the conflict to + \texttt{Resolved} with that action applied. Both resolution states + are thus reachable by an authored operation; \texttt{Dismiss} is the + authored selector for \texttt{Dismissed}, closing the prior gap in + which \texttt{Dismissed} was a representable state with no operation + to reach it. Concurrent \texttt{ResolveConflict} operations on the same conflict are themselves subject to canonical reduction: the @@ -6288,8 +6605,32 @@ pub struct TransactionDescriptor { } pub struct TransactionId(pub u128); + +/// Open vocabulary: a minimal core set plus a Registered escape for +/// extension-defined categories. +pub enum TransactionCategory { + NoteEntry, + Structural, + Layout, + Import, + Registered(OperationKindRegistryId), +} \end{lstlisting} +\begin{requirement} + \label{req:semops:transaction-category} + \texttt{TransactionCategory} is an \emph{open} vocabulary. Its + normative core set is exactly \texttt{NoteEntry}, \texttt{Structural}, + \texttt{Layout}, and \texttt{Import}; any other category \MUST{} be + expressed as \texttt{Registered(OperationKindRegistryId)} rather than + by extending the core set. The field is advisory (consumed by UIs and + analytics, never by canonical reduction), so the minimal core set is + the right tradeoff: under-specifying is cheap to extend through + \texttt{Registered}, whereas baking speculative categories into the + core vocabulary is not. The discriminants follow declaration order + (\texttt{NoteEntry} $= 0$ through \texttt{Registered} $= 4$). +\end{requirement} + Each primitive operation belonging to a transaction carries the transaction's identifier in its envelope's \texttt{transaction} field. The transaction descriptor itself is a separate operation envelope @@ -7128,9 +7469,14 @@ that operation's choice. The losing respelling is recorded in a \texttt{ConflictRecord} of kind \texttt{StructuralFieldCollision} if the two respellings represent different spellings (i.e., differ in nominal, accidental stack, or -octave). Identical concurrent respellings reduce idempotently: the -later operation produces \texttt{NoOp\{reason: AlreadyApplied\}} -with no conflict recorded. +octave). The per-operation effect assignment follows the general +field-collision rule +(Requirement~\ref{req:semops:field-collision-effect}): the later +operation --- the winner, which materializes --- reads +\texttt{Conflicted}, and the earlier operation retains +\texttt{Applied}. Identical concurrent respellings reduce +idempotently: the later operation produces +\texttt{NoOp\{reason: AlreadyApplied\}} with no conflict recorded. \subsection{ChangeRegionTimeModel} @@ -7538,6 +7884,42 @@ pub struct LayoutObjectId(pub u128); whose change should invalidate this layout object. \end{requirement} +\begin{requirement} + \label{req:layoutir:object-id-derivation} + A \texttt{LayoutObjectId} \MUST{} be stable across re-layouts whose + underlying source is unchanged. It is derived by domain-separated + BLAKE3 truncation with the reserved layout domain tag + \texttt{"MUSCLOID"} over a key that depends on how the object is + manifested: + + \begin{itemize} + \item A layout object that manifests a single score-graph object + exactly once is keyed on \texttt{source.canonical\_bytes()}. + \item A multiply-manifested object --- one score-graph object that + appears in more than one layout context, e.g. a staff manifested + in two regions --- is keyed on the pair + \texttt{(source,\,region)}, so each manifestation receives a + distinct, stable id. + \item A synthesized object (no direct score-graph + \texttt{source}; \texttt{synthesis} is \texttt{Some}) is keyed + on the triple \texttt{(source,\,synthesis\_kind,\, + stable\_semantic\_instance\_key)}, where the + \texttt{stable\_semantic\_instance\_key} distinguishes multiple + synthesized objects sharing a source and synthesis kind by a + semantically stable discriminator (not a layout-position + ordinal, which would not survive relayout). + \end{itemize} + + \texttt{LayoutObjectId}s are \emph{non-canonical}: they are not part + of document state and do not enter any content hash, so the + \texttt{"MUSCLOID"} tag lives in the layout namespace and is not one + of the reserved \emph{canonical} system tags of + Section~\ref{sec:graph:system-derived}. The fixed derivation is + pinned for incremental-relayout correctness and provenance + back-reference stability; its consumers are the solver and renderer + (Track~A), not the interchange track. +\end{requirement} + \section{The Stage Pipeline} \label{sec:layoutir:pipeline} @@ -8779,7 +9161,28 @@ pub enum CommitState { highest-generation valid slot; it \MUSTNOT{} silently treat the file as normal. \item If both slots are valid and their generations differ by - at most one, the slot with the higher generation is active. + exactly one, the slot with the higher generation is active. + \item If both slots are valid and their generations are + \emph{equal}, the reader \MUST{} compare the slots' + \emph{load-bearing} selection fields: + \texttt{manifest\_hash}, \texttt{manifest\_schema\_version}, + \texttt{reduction\_algorithm\_version}, and + \texttt{profile\_id}. The advisory fields + \texttt{commit\_timestamp} and the physical chunk + offset/length are \emph{excluded} from this comparison. + \begin{itemize} + \item If every load-bearing field is equal, the two slots + select equivalent canonical state; the reader \MUST{} + deterministically choose slot~A and open normally. + \item If any load-bearing field differs, the reader \MUST{} + report a format-layer \texttt{IntegrityAnomaly} of kind + \texttt{DivergentSameGeneration} and open the bundle + read-only. Two genuinely different committed states cannot + share a generation under a conforming writer (the + atomic-write protocol increments the generation on every + commit), so the divergence is a structural failure, not a + state the reader may silently resolve by picking one. + \end{itemize} \item If exactly one slot is valid, that slot is active. \item If neither slot is valid, the file is corrupt; readers \MUST{} surface this as a hard error and \MUSTNOT{} @@ -8787,6 +9190,20 @@ pub enum CommitState { \end{enumerate} \end{requirement} +\begin{rationale} + The advisory fields are excluded from the equal-generation + comparison because they do not determine which canonical state a + slot selects: \texttt{commit\_timestamp} is wall-clock metadata, + and the physical offset/length merely locate the manifest chunk in + the file. Two slots that agree on the four load-bearing fields name + the same canonical document even if they were written at different + times or to different offsets, so treating an advisory-only + difference as divergence would raise false anomalies on benign + repacks. Conversely, a difference in any load-bearing field means + the two slots disagree about canonical state itself, which is the + precise condition that must not be silently resolved. +\end{rationale} + \section{The Manifest} \label{sec:format:manifest} @@ -9020,6 +9437,26 @@ manifest before it has any information from the chunk store. the few hundred bytes of compression headroom. \end{rationale} +\begin{requirement} + \label{req:format:manifest-id} + The \texttt{manifest\_id} field \MUST{} be derived as + \[ + \texttt{ManifestId} = \mathrm{trunc128}\!\left( + \mathrm{BLAKE3}(\texttt{"MUSCMNIF"} \Vert \texttt{document\_id} + \Vert \texttt{generation} \Vert \texttt{manifest\_body})\right), + \] + where \texttt{document\_id} is its 16-byte canonical form, + \texttt{generation} is the 8-byte little-endian \texttt{u64}, and + \texttt{manifest\_body} is the canonical manifest encoding with the + \texttt{manifest\_id} field itself \emph{excluded}. The exclusion is + normative: a conforming writer \MUST{} omit (equivalently, zero) the + \texttt{manifest\_id} field when computing the preimage, so that the + identifier does not reference itself. $\mathrm{trunc128}$ takes the + leading 16 bytes of the 32-byte BLAKE3 output. Two conforming + writers committing the same manifest body at the same generation of + the same document therefore derive identical \texttt{ManifestId}s. +\end{requirement} + \section{Content Hashing} \label{sec:format:hashing} @@ -9182,6 +9619,42 @@ pub struct ContentHash(pub [u8; 32]); pub struct ChunkId(pub ContentHash); \end{lstlisting} +\begin{requirement} + \label{req:format:chunkkind-discriminants} + \texttt{ChunkKind::canonical\_bytes()} is a single byte: the + variant's declaration-order discriminant. Because that byte is part + of the chunk hash preimage (Section~\ref{sec:format:chunks}, + ``Domain-Separated Preimages''), this assignment is normative and + stable and \MUSTNOT{} be reordered: + + \begin{center} + \begin{tabular}{r l @{\qquad} r l} + \toprule + \textbf{Disc.} & \textbf{Variant} & + \textbf{Disc.} & \textbf{Variant} \\ + \midrule + 0 & \texttt{OperationEnvelopeBlock} & 5 & \texttt{TextProjection} \\ + 1 & \texttt{OperationIndex} & 6 & \texttt{LayoutCache} \\ + 2 & \texttt{Snapshot} & 7 & \texttt{IntegrityIndex} \\ + 3 & \texttt{Blob} & 8 & \texttt{Manifest} \\ + 4 & \texttt{ExtensionData} & & \\ + \bottomrule + \end{tabular} + \end{center} + + \texttt{CompressionAlgorithm} likewise encodes as a single + declaration-order discriminant byte followed by any variant payload: + \texttt{None} $= 0$ (no payload); \texttt{Zstd\{level\}} $= 1$ + followed by the \texttt{level} byte; \texttt{Reserved(u8)} $= 2$ + followed by the reserved byte. Compression is \emph{not} part of a + chunk's content identity (it is metadata on the \texttt{ChunkRef}), + so these discriminants are stable but do not affect chunk hashes. + + \texttt{ProfileId}'s discriminants are pinned in + Section~\ref{sec:format:profiles} + (Requirement~\ref{req:format:profileid-discriminants}). +\end{requirement} + \begin{requirement} Chunks \MUST{} be immutable. Once a chunk is written and its bytes durably flushed, those bytes \MUSTNOT{} be modified. New state @@ -9393,9 +9866,23 @@ pub struct SnapshotRef { \label{sec:format:blobs} The blob store holds large opaque content: audio reference tracks, -embedded images, custom fonts, ML model checkpoints. Blobs are -content-addressed identically to chunks (BLAKE3 of uncompressed -payload, with the \texttt{"MUSCBLOB"} domain tag). +embedded images, custom fonts, ML model checkpoints. + +\begin{requirement} + \label{req:format:blob-hash-shape} + A \texttt{BlobId} \MUST{} be BLAKE3 over the \emph{bare} preimage + \texttt{"MUSCBLOB"} $\Vert$ \texttt{uncompressed\_payload}: the + 8-byte domain tag immediately followed by the uncompressed payload + bytes, with \emph{no} chunk-kind, schema-version, or length fields. + This is deliberately \emph{unlike} the structured chunk preimage of + Section~\ref{sec:format:chunks} (which commits to kind, schema + version, and length): a blob is opaque payload with no schema, so + its identity commits only to the domain tag and the bytes. As with + chunks, the compression algorithm is metadata on the + \texttt{BlobRef} and is \emph{not} part of the blob's identity, so + identical payloads stored under different compression share a + \texttt{BlobId}. +\end{requirement} \begin{lstlisting}[language=Rust] pub struct BlobRef { @@ -9872,6 +10359,45 @@ pub enum ProfileId { } \end{lstlisting} +The constraints a profile imposes are carried in +\texttt{ProfileConstraints}, which the GC and retention rules +(Section~\ref{sec:format:gc}) reference for the mandatory +\texttt{RetentionPolicy}: + +\begin{lstlisting}[language=Rust] +pub struct ProfileConstraints { + /// Maximum uncompressed operation-envelope block size a reader + /// must accept under this profile (default 64 MiB). + pub max_uncompressed_block_size: u64, + + /// The RetentionPolicy this profile declares. Required: the GC + /// and retention rules read it from the active profile. + pub retention_policy: RetentionPolicy, + // Additional constraint fields (permitted compression sets, + // required-extension lists) are reserved for a later revision; + // a reader MUST tolerate their future addition under the + // minor-version compatibility rule. +} +\end{lstlisting} + +\begin{requirement} + \label{req:format:profile-constraints} + Every \texttt{ProfileDeclaration} \MUST{} carry a + \texttt{ProfileConstraints}, and every \texttt{ProfileConstraints} + \MUST{} include a \texttt{retention\_policy} field. This satisfies + the requirement (Section~\ref{sec:format:gc}) that the active + conformance profile declare a \texttt{RetentionPolicy}: the policy + lives inside the active profile's constraints, not as a free-standing + manifest field. + + When a bundle declares more than one profile, the + \texttt{RetentionPolicy} in force is the one declared by the + \emph{first} profile in the manifest's \texttt{profile\_declarations} + list (declaration order is canonical). A bundle that declares no + profile constraints carrying a policy uses the Full profile's + default retention policy. +\end{requirement} + \begin{requirement} Every bundle \MUST{} declare at least one profile in its manifest's \texttt{profile\_declarations}. An implementation \MAY{} open a @@ -9880,6 +10406,18 @@ pub enum ProfileId { (Section~\ref{sec:format:fwdcompat}). \end{requirement} +\begin{requirement} + \label{req:format:profileid-discriminants} + \texttt{ProfileId} encodes as a single declaration-order + discriminant followed by any variant payload: \texttt{Full} $= 0$, + \texttt{ReadOnly} $= 1$, \texttt{Lite} $= 2$, + \texttt{Custom(ProfileRegistryId)} $= 3$ followed by the registry + id's canonical bytes. This assignment is normative and stable. + \texttt{ProfileId} participates in superblock selection + (Section~\ref{sec:format:bundle}, the load-bearing field set), so + its discriminants are part of that comparison. +\end{requirement} + \section{Compression} \label{sec:format:compression} @@ -9997,9 +10535,72 @@ Format document. The Binary Format document is normative; an implementation cannot conform to the file format specification without conforming to the Binary Format specification. +\subsection{Ratified Convention Baseline} +\label{sec:format:codec-baseline} + +Although the full Binary Format companion is still to be written, the +canonical encoding conventions shared by the core, operations, and +bundle layers are ratified now, so those three layers do not drift +apart before the companion formalizes them. The companion +\emph{inherits} this baseline rather than re-deriving it. + +\begin{requirement} + \label{req:format:codec-conventions} + The canonical encoding of every composite structure (whole + \texttt{Score}, operation envelopes, manifest) \MUST{} follow these + conventions: + \begin{itemize} + \item Integers are little-endian. A boolean is a single + \texttt{0}/\texttt{1} byte. + \item Counts and the length prefix of every variable-width + \emph{leaf} (an identifier, a \texttt{RationalTime}, a string) + are \texttt{u32} little-endian. Length-prefixing every leaf + makes the decoder width-agnostic: it never infers a boundary + from context. + \item A tagged union is encoded as a single discriminant byte + followed by the variant payload. + \item Free-text string fields are length-prefixed UTF-8 and are + \emph{not} NFC-folded by the codec: the in-memory value is + preserved byte-exact, so \texttt{decode(encode(x)) == x}. + Catalog identifiers are NFC-normalized at construction, not in + the codec. + \end{itemize} + + Certain fixed hashing preimages deliberately depart from the + single-discriminant-byte convention where a wider or order-bearing + tag is required --- notably \texttt{TypedObjectId}'s 16-bit + big-endian discriminant + (Requirement~\ref{req:graph:typed-object-id-discriminants}). Such + departures are pinned at their definitions and are not overridden by + this baseline. +\end{requirement} + +\begin{requirement} + \label{req:format:rationaltime-encoding} + The primitive scalar layouts referenced throughout the canonical + encodings are ratified as follows: + \begin{itemize} + \item \texttt{RationalTime} encodes as a sign byte + (\texttt{0} = zero, \texttt{1} = positive, \texttt{2} = + negative), then the \texttt{u32}-little-endian-length-prefixed + big-endian magnitude of the numerator, then the + \texttt{u32}-little-endian-length-prefixed big-endian magnitude + of the denominator. The value \MUST{} always be stored reduced + (lowest terms, positive denominator), so the encoding is + canonical: equal rationals encode to equal bytes. + \item Wall-clock integers (\texttt{WallClockTime}, + \texttt{WallClockDuration}) are little-endian fixed-width + integers, matching \texttt{QuantizedCoord}. + \end{itemize} +\end{requirement} + \begin{openquestion} - The Binary Format companion is under development as a separate - specification. Its development follows this chapter; the byte-level + The remainder of the Binary Format companion --- the full composite + struct layouts, schema-version wire evolution, and varint details + beyond the baseline above --- is under development as a separate + specification. Its development follows this chapter and inherits the + ratified baseline (Sections~\ref{req:format:codec-conventions} + and~\ref{req:format:rationaltime-encoding}); the remaining byte-level decisions depend on the structural decisions specified here. \end{openquestion} @@ -11625,10 +12226,12 @@ rules are normative here. \texttt{ManifestId} & Content-derived from manifest payload & - Unique per manifest version. Implementations \MAY{} use - trunc128(BLAKE3 of canonical manifest preimage) with domain - tag \texttt{"MUSCMNIF"}; the Binary Format companion defines - the exact derivation. \\ + Unique per manifest version. Derived as + \texttt{trunc128(BLAKE3("MUSCMNIF" || document\_id || + generation || manifest\_body))} with the \texttt{manifest\_id} + field excluded from \texttt{manifest\_body}; the derivation is + ratified normatively in + Section~\ref{req:format:manifest-id}. \\ \texttt{SnapshotId} & Content-derived from snapshot payload and frontier & @@ -13345,6 +13948,43 @@ state. Chapter~\ref{ch:format}'' wording, reflecting that the Pass 3 architectural shift made causal context part of operation semantics rather than only file storage. \\ + \today & Pass 11 revision (spec ratification) & + Converted the v0 implementation's provisional, golden-locked byte + choices into ratified normative spec text; the architecture is + unchanged. \emph{Adopt-and-pin (bytes):} pinned the + \texttt{TypedObjectId} 16-bit big-endian discriminant table + (\texttt{0}--\texttt{27}, \texttt{Registered}~=~27; added the + \texttt{Tuplet}/\texttt{RepeatStructure}/\texttt{LyricLine}/% + \texttt{ChordSymbol}/\texttt{View} variants the implementation + carried); the promoted-voice (\texttt{MUSCSVCE}), system-pitch + (\texttt{MUSCSPCH}, tuning always part of identity), and + integrity-anomaly (\texttt{MUSCSANM}, promoted to a reserved + built-in tag) derivations; the \texttt{ChunkKind} / + \texttt{ProfileId} / \texttt{CompressionAlgorithm} discriminants; + the \texttt{ManifestId} preimage (with \texttt{manifest\_id} + excluded); and the \texttt{RationalTime}/scalar primitive layouts + plus the shared codec convention baseline the Binary Format + companion inherits. \emph{Decide-and-pin:} \texttt{TempoShape::Linear} + interpolates \emph{speed} (whole notes/second), not bpm; a + \texttt{StructuralFieldCollision} tags the \emph{winner} + \texttt{Conflicted}; lifted the $>$2-way voice-promotion + generalization (partial-overlap, lowest-id-survivor) to normative; + pinned the \texttt{TransactionCategory} and \texttt{ObjectKind} core + vocabularies; added \texttt{ResolutionAction::Dismiss} so the + \texttt{Dismissed} resolution state is reachable by an authored + operation; and pinned the (non-canonical) \texttt{LayoutObjectId} + derivation with a \texttt{MUSCLOID} tag. \emph{Fixes:} blob hashing + is the bare \texttt{"MUSCBLOB" || payload} (deleted the + contradictory ``identically to chunks'' phrasing); added the + equal-generation superblock rule (\texttt{DivergentSameGeneration} + on load-bearing-field divergence); defined \texttt{ProfileConstraints} + and placed the required \texttt{RetentionPolicy} in it with + first-declared multi-profile precedence; made the DVV zero-based + counter floor normative; reconciled the graph-invariant count to 19 + (was 18 in QUICKSTART) and named the three construction-time MUSTs + (time-signature beat-group sum, ordering-DAG acyclicity, and + non-degenerate \texttt{TupletRatio}, now enforced at construction). + \\ \bottomrule \end{longtable}