24 KiB
epiphany-ops — decisions and Pass 11 candidates
This file records (a) the implementation decisions the QUICKSTART asked each
agent to make once and document, and (b) the ambiguities discovered while
building epiphany-ops, batched as Pass 11 candidates for the spec 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). The ops-layer Pass 11 candidates have been ratified into
core_spec.tex— seespec/PASS11_RATIFICATION_LOG.md. Highlights: C2 adopted (IntegrityAnomalyId=derive_system_id(MUSCSANM,…), withMUSCSANMpromoted to a reserved built-in tag); C3 decided (field-collision tags the winnerConflicted); C4 adopted + lifted to spec (the >2-way, partial-overlap promotion generalization); C7 fixed (zero-based DVV floor made normative); C9 decided (TransactionCategory/ObjectKindcore vocabularies pinned); C10 decided (addedResolutionAction::DismisssoDismissedis reachable). C1/C5/C6/C8 stay deferred to their tracks.
Implementation decisions (QUICKSTART "Decisions you'll need to make")
- Replica ID entropy / event-arena / chunk store — N/A to this crate
(Agents A, B, D).
epiphany-opsconsumes Agent B's identifier family and never mints graph identifiers itself, except the deterministic system-derived ones (promoted voices via Agent B'sderive_promoted_voice_id, and the content-derivedConflictId/IntegrityAnomalyId). - Async or sync — sync only. No async traits anywhere (decision 4).
- MSRV — workspace 1.77 (decision 5). No exotic features.
unsafeforbidden crate-wide (#![forbid(unsafe_code)]). - Canonical iteration is structural. Every collection that feeds canonical
output is a
BTreeMap/BTreeSetor a vector put into the normative order before encoding (Appendix D §"Ordered Iteration"). The determinism fuzzer is the tripwire: it reduces each random set in several acceptance orders and asserts byte-identical materialized state, which would fail the moment aHashMapiteration order leaked in.
Scope boundary: framework in full, representative operations
Chapter 6 specifies the operation framework and a representative selection of
operations; the full catalog of ~60–80 primitives is an explicit open
question (§6.11, marked \openquestion) deferred to the Operation Catalog
companion. This crate mirrors that division exactly:
- Implemented in full (the framework): operation identity/stamps, the HLC and
its monotonicity rule, DVV causal context, the order-independent
OperationSlotmodel and acceptance pipeline, the canonical reduction order, the four-phase lifecycle, effects with the typedPreconditionFailureReason, conflict records with content-derived ids, the conflict registry, integrity anomalies kept separate from conflicts, transactions with the descriptor-precedence rule, re-anchoring, LWW discipline, validation modes, and forward undo. - Representative (the §6.10 set):
InsertEvent,DeleteEvent,RespellPitch,CreateCrossCutting,ChangeRegionTimeModel,SetUserSystemBreak,DeclareTransaction, plusResolveConflictandUndoTransaction. Together they exercise every reduction discipline the chapter defines (position-keyed insert + voice promotion; delete-wins + tombstone + re-anchor; field-overwrite + conflict; set-union; structural migration; LWW; atomic transactions). The remaining catalog kinds are an additive future change behind the existingOperationKindenum.
MaterializedState is the canonical bookkeeping Chapter 6 owns — the effect
log, conflict registry, anomaly register, object existence/tombstones,
spellings, and LWW fields. M2 adds OperationSet::reduce_onto(&Score), which
seeds those indices from a canonical base and returns the corresponding Agent B
graph. Insert/delete, voice promotion, supported reference-level cross-cutting
values, system breaks, migration checks, transaction rollback, and undo mutate
that graph. The base-free reduce() remains the operation-set convergence API.
M2a (Group 1) — event & pitch leaf-field ops: graph materialization
The Group-1 leaf-field ops (ModifyEvent, Transpose, InsertIdentifiedPitch,
DeleteIdentifiedPitch, ModifyIdentifiedPitch) reuse M1's reduction
disciplines unchanged; the canonical MaterializedState records only their
effect-log entry and — for the field-overwrite ops — a StructuralFieldCollision
on a concurrent differing write. Their resolved values live in the graph
(reduce_onto), which is derived state, not a second canonical store, so the two
decisions below are graph-materialization-only: the bookkeeping projection,
and therefore convergence/determinism, is unaffected. Both exist because an
in-place EventArena::get_mut edit bypasses insert's well-formedness guard, so
the reducer must keep the graph Chapter-5-valid itself (otherwise the malformed
state surfaces only later, in check_invariants). Both are exercised at scale by
run_graph_convergence (criterion 1, now emitting these kinds) and pinned by
targeted reduce_onto tests in tests/graph_reduction.rs.
-
A note and a rest are the same slot under pitch add/remove (note↔rest conversion).
DeleteIdentifiedPitchof a single-pitch note's only pitch degrades the event to aRestof the same id/voice/position/duration rather than leaving an empty pitched event — Chapter 5 forbids the empty chord ("useRestfor the no-pitch case",ArenaError::EmptyPitchedEvent).InsertIdentifiedPitchinto a rest is the dual: the rest becomes a one-pitch note. This preserves the ops' disciplines (delete-wins / mint) and keeps the graph consistent with the bookkeeping (which tombstones / mints the pitch object either way). Rejected alternative: a "would empty the event" precondition failure — it needs a newPreconditionFailureReasonagainst a ratified set, breaks delete-wins, and is a worse fit than the editor-natural "deleting a note's last pitch leaves a rest". For the spec: the Operation Catalog §"Insert/Delete identified pitch" (M2e) ratifies this note↔rest equivalence normatively. -
ModifyEventdefers placement changes in the graph. AModifyEventwhose payload moves the event (different position or duration) is not applied to the graph: re-sorting a voice's event list on a placement change is deferred, and applying a move viaget_mutwould break invariant 3 (VoiceEventsSortedNonOverlap). Same-placement field edits apply, preserving the existing voice membership (owned by the voice list); a malformed (empty) pitched replacement is likewise skipped. The LWW bookkeeping records the modify either way. For the spec: the catalog §ModifyEvent (M2e) states the placement-change deferral as the prototype boundary (a full re-sort/move op is a later refinement).
DeleteEvent re-anchoring: the graph follows the ledger
A DeleteEvent that tombstones an event runs the re-anchoring rule table
(Chapter 6 §6.5) over the cross-cutting structures that referenced it. The
ledger decision (reanchor_for_tombstone) and the graph mutation
(materialize_graph_delete) MUST agree on whether a structure survives — else an
object is Live in MaterializedState but gone from the graph (or vice versa),
a faithfulness gap the graph convergence gate (criterion 1) would surface.
- Slurs and spanners re-anchor, not unconditionally drop. The ledger keeps a
slur/spanner
Livewhile ≥1 endpoint survives (re-anchored) and cascade-deletes only when none does.materialize_graph_deletenow mirrors that exactly: an endpoint-deleted slur/spanner re-anchors onto its surviving endpoint (the structure stays in the graph) and is removed only when no endpoint survives. Previously the graph removed any slur touching the deleted event regardless of the ledger's re-anchor — the divergence this entry fixes. Ties (cascade) and beams (truncate-while-≥2-members, else cascade) were already consistent and are unchanged. - A re-anchored two-endpoint structure collapses onto the survivor. With only
two endpoints, the sole survivor is the other endpoint, so re-anchoring sets
both to it (a degenerate
(B, B)slur / spanner). This is reference-clean — the cross-cutting invariant requires only that endpoints reference live events, not that they differ — but musically a stand-in. A proximity-aware target (the note that took the deleted one's place) needs resolved positions and is deferred (P11-C5;nearest_survivoris the lexicographic stand-in). - Base-score spanners are now indexed for re-anchoring.
seed_from_graphrecords each seeded spanner's event-anchored endpoints instructures(as it already did for slurs/ties/beams), so a base spanner re-anchors through the same rule as a created one rather than being left dangling.
This consistency is what lets graph_edit_session create cross-cutting structures
and delete their endpoints, giving the Group-2 CRUD ops (and slur re-anchoring)
real at-scale coverage under criterion 1 + check_invariants.
M2c (Group 3) — structural containers: empty-only delete
The structural-container CRUD ops (CreateRegion/DeleteRegion,
CreateStaffInstance/DeleteStaffInstance, CreateVoice/DeleteVoice) mint an
empty container (set-union creation) and tombstone an empty one. Two
scoping calls the user made fixed the shape: the slice covers structural
container CRUD (not the document root / canvas / global staves — those stay K1),
and delete semantics are empty-only (precondition).
- A delete of a non-empty container is a precondition no-op
(
ContainerNotEmpty), not a cascade. The caller deletes contents first. Rejected alternative: a cascading delete that tombstones the live children transitively — it conflates two intents (remove this container vs. remove its contents), and a cascade's re-anchoring interactions (a deleted voice's events feeding the cross-cutting re-anchoring table) are a strictly larger design than the slice needs. The empty-only gate is the conservative floor a cascade could later build on. Catalog §Structural Containers (M2e) states this normatively. - Live-child sets are tracked in the reducer, not re-derived from the graph.
region_instances: RegionId → {StaffInstanceId}andinstance_voices: StaffInstanceId → {VoiceId}(a voice's live events are read fromvoice_occupancy) are maintained by the create/delete materializers and seeded byseed_from_graph, so the empty-only precondition is decided identically in the base-freereduce()and graph-awarereduce_onto(). - Graph creation maintains the region staff extent. An empty staff-based
region with a freshly-created staff instance would violate
RegionExtents(the staff extent must list exactly the manifested staves) unless the extent is updated as instances are added/removed; the create/delete materializers do so, andvaluegen::regioncarries a far-future time extent so a fresh region does not overlap an existing one once a staff instance lands.
M2d (Group 4) — score settings: per-op discipline (review-hardened)
The score-settings ops (SetMetadata, SetMetricGrid, SetUserPageBreak) are
all field overwrites, but they do not share one discipline; the M2d review
(closed in commit d93baac) pinned each to the discipline its catalog
classification names. Recorded here because the review changed code/tests.
SetMetadatais an advisory LWW — no conflict. The catalog (§set-user-system-break "LWW advisory") and core_spec already classified metadata this way; the first implementation wrongly raised aStructuralFieldCollisionon a concurrent differing write, which could make a clean concurrent metadata edit turnMaterializedState::is_clean()false. It now silently last-writer-wins in canonical order (graph singleton overwrite, alwaysApplied, no working slot). Direction chosen: fix the implementation to match the already-correct spec, not the reverse.SetMetricGridis a structural overwrite with two preconditions. It keys aStructuralFieldCollision(fieldmetric_grid) on concurrent differing grids (kept), and (a) preconditions the region live and staff-based — a FreeGraphic region has no metric-grid slot — and (b) rejects a grid whose meter sequence names a time signature that is not a live object (the Chapter-5 invariant forbids installing such a grid). Both checks read only base-free indices.SetUserPageBreakmirrorsSetUserSystemBreakexactly, under the canonical LWW key. Both now (i) share the live-and-staff-based precondition via astaff_based_regionsindex, soreduce()andreduce_onto()agree on missing / tombstoned / FreeGraphic targets, and (ii) materialize the graph break under the anchor's resolved musical position (apply_break_lww+resolved_anchor_position): any existing anchor resolving to the same position is dropped before the new one is added, so the graph break list stays in lockstep with the resolved-position-keyed ledger map. The system-break fix is a sibling parity change, not new M2d scope.- Performance. The dedicated 10K-envelope reducer micro-bench (criterion 5) is Agent F's worklist F1; the M2 value-typed ops are already exercised at 10K·scale by the conformance suite's reduction-determinism and convergence gates, which emit every M2 kind. No criterion bench is added under Agent K.
Pass 11 candidates (ambiguities for the spec, not resolved in code)
P11-C1 — operation payload schemas are deferred; we carry identifiers + fingerprints
RESOLVED (Phase 2, Agent K — Operation Catalog, M1). The representative payloads are now value-typed:
InsertEventOp { staff_instance, event: Event },RespellPitchOp { pitch, spelling: PitchSpelling },CreateCrossCuttingOp { structure: CrossCuttingValue },ChangeRegionTimeModelOp { …, new_time_model: RegionTimeModel },SetUserSystemBreakOp { …, anchor: TimeAnchor }, andTupletCompensation::ReplaceWithRest { rest: Rest }. They serialize by framing each value'sepiphany_core::CanonicalValuebytes behind au32length prefix — the ratified byte-convention baseline (Pass 11 item 1.8,req:format:codec-conventions), introducing no new layout (the K↔J seam; seeepiphany-core/DECISIONS.md). Graph-aware reduction now materializes the real event/structure rather than the C4 placeholder described below.reduce_onto's reduction rules are unchanged — only the field read-sites moved onto the value. The v0 identifier-only shapes are frozen insrc/v0.rsas the migration regression guard, andsrc/migrate.rslifts a v0 envelope to v1 deterministically and equivalence-preservingly (migrate_v0_envelope(v0, context: &Score);epiphany-testkit::migrationis the merge gate). The full K0 set + the literal wire layout (Binary Format companion, Agent J) follow; the remainder of this entry is the historical v0 rationale. SeeP12-K1below andspec/operation_catalog.tex.
Chapter 6's payload structs embed rich graph values (InsertEventOp { event: Event }, RespellPitchOp { new_spelling: PitchSpelling }, …), but the
canonical wire encoding of those graph value types is itself deferred to the
Binary Format companion (Agent B's P11-4: epiphany-core canonically encodes
only identifiers and the scalar time types). An OperationEnvelope must be
hashable today — the EnvelopeHash and slot equivocation both need canonical
bytes — so this crate's payloads carry the reduction-relevant identifiers and
canonical scalar coordinates, plus a ContentHash fingerprint where the
reduction needs only equality (a respelling). Graph-aware reduction materializes
this projection as deterministic C4 pitches (or a rest when no pitch ids are
present); those placeholders do not claim to recover musical values absent from
the payload. For the spec: pin the payload
schemas (the Operation Catalog companion) and the canonical encoding (the Binary
Format companion); when they land, the structs regain their full value fields
without changing the reduction. The trigger will be a failing cross-crate
round-trip test, per the QUICKSTART process notes.
P11-C2 — IntegrityAnomalyId derivation is unspecified
Chapter 5 gives IntegrityAnomaly an IntegrityAnomalyId but does not pin how
it is derived. Because anomalies are deterministic facts of reduction, the id
must be content-derived (two replicas must agree). This crate derives it as
derive_system_id(MUSCSANM, kind.canonical_bytes()) in the SYSTEM_DERIVED
namespace — the same discipline Chapter 5 uses for system identifiers, with a new
MUSCSANM extension system tag. For the spec: confirm the derivation (and
whether a built-in MUSCS… tag should be reserved for anomalies, as MUSCSVCE/
MUSCSPCH are for voices/pitches).
P11-C3 — which participant's effect carries Conflicted in a field collision
For concurrent differing RespellPitches, the spec pins the conflict record
(kind StructuralFieldCollision, with winner/loser and the loser's spelling)
and says the later-in-canonical-order operation wins and materializes. It does
not pin which participant's OperationEffect is tagged Conflicted. This crate
tags the winner (the later op, which materializes and whose processing
created the record) Conflicted, and leaves the earlier op's already-recorded
Applied effect in place. The outcome is order-independent because canonical
order is fixed. For the spec: pin the per-operation effect tag for a field
collision (and whether the superseded loser should retroactively read
NoOp{SupersededByLaterOperation}).
P11-C4 — voice-promotion derivation inputs and the >2-collision generalization
Invariant 18's promoted-voice derivation takes (staff instance, original voice,
winning op, losing op). M2 expanded VoiceOrigin::SystemPromoted to retain both
operation ids, so Agent B verifies the exact Agent C derivation. This crate resolves promotion
in an order-independent pre-pass: bucket inserts by voice, walk them by
OperationId, keep a non-overlapping set in the original voice, and promote
each concurrent overlapping loser to derive_promoted_voice_id(staff_instance, voice, winner, loser). This applies the InsertEvent invariant to partial
interval overlaps as well as identical start positions. The op carries its
staff_instance explicitly (a full reducer recovers it from the voice's
container). One open point remains for the spec: define the >2-way collision
case — the spec describes a pairwise rule; this crate uses the first lower-id overlapping
operation retained in the original voice as the winner for each promotion.
P11-C5 — "nearest surviving anchor" needs resolved positions
The re-anchoring total order (Chapter 6 §6.5) ranks surviving candidates by
containment proximity, then absolute time distance, then direction, then id. The
prototype does not yet track resolved positions/time per object, so
nearest_survivor uses a deterministic stand-in: the lexicographically-
smallest surviving endpoint. The structure of the rule table (Tie →
cascade-delete, Comment → orphan, Beam → truncate, Slur/Spanner → reanchor-or-
cascade) is implemented faithfully; only the metric "nearest" is approximated.
For the spec: no change needed — this resolves once the graph mutation phase
tracks positions; recorded so the approximation is explicit.
P11-C6 — time-model compatibility is computed when a graph is available
ChangeRegionTimeModel retains a declared_incompatible list for base-free
reduction. Graph-aware reduction additionally derives incompatibilities from
every event's actual coordinate variants and mapping coverage, refusing any
migration that would violate Agent B's coordinate discipline. Concurrent
same-region migrations conflict; causally-later migrations are reevaluated
against the first migration's graph. For the spec: the rich migration
payload still belongs to the Operation Catalog.
P11-C7 — DVV contiguous ranges use the zero-based operation-counter floor
The DVV's contiguous vector[r] = n asserts predecessors (r, 0..=n) exist,
matching the operation-id and causal-context documentation. Reduction finds the
first absent id in every asserted range and holds the dependent pending; dots
and vector coverage of known equivocated/excluded ids remain direct blocking
signals, with transitive propagation to dependents. The range check walks known
ids rather than expanding 0..=n, so a sparse context with a very high counter
does not cause proportional work. For the spec: explicitly retain the
zero-based per-replica counter floor in the normative DVV definition.
P11-C8 — forward undo is modeled via minted-object compensation
The spec defines undo as a forward compensating edit (StrictInverse / BestEffort / Cascade) computed against the current materialized state. Without the full graph-mutation phase, this crate models the compensation as tombstoning the objects the target transaction minted: StrictInverse conflicts if any such object was already tombstoned/modified; BestEffort tombstones the survivors; Cascade is treated as StrictInverse over the same set (dependent-closure undo is deferred with the rest of the catalog). Graph-aware reduction also removes those event, pitch, promoted-voice, and supported cross-cutting mints from the live graph and records graph tombstones. For the spec: this is faithful to the "content-equivalence to pre-target state" definition for insert-shaped transactions; the inverse of every catalog primitive is the Operation Catalog's job.
P11-C9 — local minimal enums for open vocabularies
TransactionCategory (spec: open, "used by UIs and analytics") and ObjectKind
(used by SystemIdentifierCollision) are given minimal core enums with a
Registered(…) escape: TransactionCategory ∈ {NoteEntry, Structural, Layout, Import, Registered}, and ObjectKind ∈ {Voice, Pitch, Registered} (only the
kinds the spec actually derives into the system namespace). For the spec:
ratify or extend these sets.
P11-C10 — ResolveConflict Dismissed has no distinct payload
The spec distinguishes Resolved from Dismissed resolution states but provides
a single ResolveConflictPayload { target, action }. This crate maps every
applied resolve to Resolved { action }; Dismissed is reachable as a state but
is not authored by a representative op. For the spec: define how a
ResolveConflict selects Dismissed (a distinct action, or a separate payload).
Phase 2 update: the Operation Catalog (Ch. ResolveConflict) records that
ResolutionAction::Dismiss is the action that selects Dismissed; resolved.
Pass 12 candidates (Agent K — Operation Catalog)
P12-K1 — a v0 RespellPitch fingerprint is not invertible to a spelling
The v0 RespellPitchOp carried a ContentHash fingerprint of the new
spelling, not the PitchSpelling. The v0→v1 migration (src/migrate.rs) must
reconstruct the value, but a fingerprint cannot be inverted without a side table.
The migration recovers the spelling from the score-graph context — an
explicit per-pitch spelling attachment (SpellingScope::Pitch +
SpellingDirective::Explicit) whose canonical bytes hash to the fingerprint —
and, when the context lacks it, returns MigrationError::Irreversible (the
bundle opens read-only, per the QUICKSTART migration contract). This is the one
representative payload that is not self-contained under migration. For Pass
12: confirm the read-only fallback is the intended long-term disposition (vs.
requiring a richer v0 corpus that preserves spelling pre-images). Recorded in
spec/PASS12_BATCH.md and spec/operation_catalog.tex
(§RespellPitch, §Migration).
Provisional canonical encoding (mirrors Agent B's P11-4)
The composite Chapter 6 types use a concrete, reversible canonical byte form
(little-endian integers; u32 length prefixes on every variable-length part;
NFC + length-prefixed text; single-byte discriminants) so that envelope hashing,
conflict-id derivation, and the materialized-state bytes are testable now. This
is deterministic and unambiguous but provisional: when the Binary Format
companion lands, reconcile encode.rs and the per-type CanonicalEncode impls
with it. A failing cross-crate round-trip test is the trigger.