Commit Graph

171 Commits

Author SHA1 Message Date
Levi Neuwirth 9d28dd6615 Clear P12-I12: scope spacing_distortion to rhythmic columns (QMC 0.2.0)
The Standard-tier spacing floor warned on short healthy scores: three
reference-suite entries measured spacing_distortion 0.36-0.41, above the
0.32 warning floor (0.8 x 0.40), a spurious diagnostic (every score still
passed Minimal). Root cause: the CV folded in the clef-to-first-note lead
advance, which is sized by notational-furniture width, not by rhythm.

Unlike P12-I11 (an engrave-side fix, no spec change), the defect here lived
in the metric's own normative definition, so the honest fix is a catalog
change - the mirror of I11: correct the measurement rather than relax the
threshold.

Quality Metric Catalog 0.1.0 -> 0.2.0: spacing_distortion is scoped to the
system's rhythmic columns (spring slots bearing a notehead or rest). The
clef / key-signature / time-signature lead and barlines contribute no
column, so a note-to-note advance spans them. quality::census now builds its
spacing columns only from a precomputed rhythmic-slot set (is_rhythmic: a
notehead*/rest* glyph anywhere in the slot); the CV and the >= 3-column
contributing-unit rule are otherwise unchanged.

Results: RS-3/5/6 drop to 0.2188 / 0.0819 / 0.0856 (all below the floor),
and the axis stays honest on real irregularity (RS-3 keeps 0.2188 from a
mid-line accidental). RS-2/RS-4 go vacuous-0.0 (their systems carry < 3
rhythmic columns - honestly "too little to measure," cleaner than the old
furniture noise). RS-1 0.1341 -> 0.1967 (cross-barline note advances).

The 1.0 anchor, orientation, range, tier thresholds, and the eight other
axes are unchanged. This is measurement-only: the resolved layout, canonical
bytes, render goldens, and ENGRAVER_VERSION are all untouched - only the
reported spacing_distortion value moves. The duration-aware optical-spacing
open question (deviation from duration-proportional spacing) stays open; it
needs the pipeline's deferred duration-aware preferred widths.

- catalog: spacing_distortion requirement (req:qmc:spacing) redefined over
  rhythmic columns, rationale + open-question note updated, version 0.2.0,
  revision-history row; PDF rebuilt clean (0 undefined refs).
- engrave: quality::census rhythmic-column filter + is_rhythmic; module and
  spacing_raw docs; the floor-column contrast test re-pointed from b-flat's
  spacing (no longer warns) to RS-1's casting-off (still between the Standard
  0.28 and Minimal 0.72 floors); new short_scores_do_not_trip_the_standard_
  spacing_floor locks the fix.
- QMC version breadcrumbs bumped to 0.2.0 (engrave + layout-ir quality.rs,
  both DECISIONS.md, testkit RS-1 comment); reference suite companion
  unchanged (cites the catalog by name, no pinned values).
- process trail: PASS12_BATCH I12 struck; PASS12_RATIFICATION_LOG I12
  section (Version movements: QMC 0.1.0 -> 0.2.0); engrave DECISIONS quality
  decision 8 + item 7 + candidate.

861 workspace tests pass; clippy -D warnings, fmt --check, rustdoc
-D warnings, and the catalog PDF build all clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
2026-07-05 13:57:10 -04:00
Levi Neuwirth bc9fb27d36 Clear P12-I11: casting-off widow-rebalance evens the stub last system
RS-1 honestly failed the Minimal casting_off_quality threshold under the
reference engraver: greedy first-fit left a two-measure stub last system
(width CV 0.6145 -> clamped 1.0 > 0.90). Cleared the honest way — an
engrave-side balance pass, no Quality Metric Catalog or core-spec change.

Casting-off gains a second phase, a widow rebalance
(casting::rebalance_widows, run between the greedy walk and vertical
stacking): it moves whole trailing measures from a region's penultimate
system into its final one, choosing the shift that minimizes the larger of
the two distribution penalties the catalog defines for the break family —
the width imbalance (casting_off_quality, the CV of the region's system
widths) and the non-final break penalty (system_break_penalty, the mean of
|W-w|/W over non-final systems). distribution_cost computes each raw by the
same formula as quality.rs's casting_off_raw / system_break_raw (mean not
worst, abs not clamp), so the rebalance optimizes the values the metric
census will report. The two axes pull against each other, so their min-max
lands on a 6/4 split for RS-1 (casting_off 1.0 -> 0.4463, system_break
0.254 -> 0.677, every axis <= 0.90) — with comfortable margin, over the
fragile full-balance 5/5 (system_break 0.889, a hair under 0.90).

Scope is tight: only a region's last boundary moves, and only when greedy
placed it (an Automatic boundary with no break requirement or page force
pinned to its slot); a user/IR-anchored or page-forced boundary is never
disturbed, the penultimate system keeps >= 1 measure, the final never grows
past its predecessor, and the system count is unchanged — so every
break-count and page-assignment invariant (and all break-constraint tests)
hold untouched.

The casting_off 0.5 anchor and the 0.90 Minimal column were vindicated, not
relaxed: the engraver improved, no anchor rescale / threshold loosening /
RS-1 override. Core spec Chapter 9's "Minimal makes no optimality claim"
already permits the heuristic, so nothing normative changed (no .tex/PDF
rebuild). P12-I12 (the Standard-tier spacing floor on short scores) stays
open.

- engrave: rebalance_widows + distribution_cost + two-phase module docs;
  ENGRAVER_VERSION 2 -> 3 (a wrapping score's baked geometry differs from
  pure greedy); three new casting tests (even-split preference, the
  mean-not-worst break penalty for 3+ systems, and the resolved final
  system); the_wrapping_fixture_is_measured_honestly re-pinned to the 6/4
  values (both axes floor-warn under Standard, status untouched).
- testkit: RS-1 minimal_xfail row removed (promoted to a plain Pass); the
  suite ships no xfail rows.
- render-svg: ten_measure.engrave.{svg,snapshot} goldens regenerated
  (view_box width 83.99 -> 64.95; still two systems).
- process trail: PASS12_BATCH I11 struck; PASS12_RATIFICATION_LOG
  "no spec change" section; engrave DECISIONS casting-off decision 9 +
  quality item 7 + candidate promoted.

860 workspace tests pass; clippy -D warnings, fmt --check, rustdoc
-D warnings all clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
2026-07-03 19:09:51 -04:00
Levi Neuwirth b9a9921d50 Phase 3 tranche 2: quality conformance companions, real metrics, multi-system click fix
857 workspace tests pass; clippy -D warnings, fmt, and rustdoc clean;
both new companions build with zero undefined references.

Quality Metric Catalog v0.1.0 (spec/quality_metric_catalog.tex, new):
- Formal definitions for all nine normative quality metrics, each with
  a raw measurement over resolved geometry and a clamped-linear
  normalization n = min(1, raw/R_worst) with pinned anchors.
- The vacuous-geometry rule (a metric over absent geometry evaluates
  to 0.0; the notated-but-unrendered honesty edge is an open
  question), all-1.0 default tie-breaking weights, and the per-tier
  threshold table — Minimal's uniform 0.90 deliberately fails the
  all-worst placeholder, forcing real measurement.
- Pins QualityMetricKind (referenced but never defined by the core
  spec) and the registered SolverProfile catalog (Draft selects the
  Minimal threshold column; Standard/Publication select Standard).
- QualityFloorApproached fires at 0.8x the applicable threshold and
  is status-neutral by requirement.

Reference Suite v0.1.0 (spec/reference_suite.tex, new):
- Six entries referenced by deterministic builder + seed (RS-1
  ten_measure_single_staff, RS-2 valid_score_rich, RS-3..6 corpus
  fixtures), each with the declared A4-at-8mm-staff solve geometry
  (Canvas.layout_defaults has no graph home yet, P12-I7).
- All entries required at Minimal; the same set is the pre-declared
  Standard bar (no implementation claims Standard yet). Fixed-
  expectation tests deliberately unused in v0.1.

Real metrics in the engraver (engrave/src/quality.rs, new;
layout-ir/src/quality.rs = the catalog constants transcribed):
- QualityMetricVector::unmeasured() replaced with computed values:
  collision sweep with the catalog's same-slot-cluster and stroke
  exclusions, per-system spacing CV, vertical gap deviations,
  system-break slack, page fill, casting-off width CV, symbol
  density; slur/beam vacuously 0.0 (no drawn geometry exists).
- Bit-identical across repeated solves (tested); floor warnings never
  change solve status; malformed inputs keep unmeasured(). The two
  all-worst test pins now assert real values; the StubSolver's
  unmeasured() stays (Stub genuinely computes nothing).

Reference-suite harness (testkit reference_suite module + tests):
- Each RS entry asserts the four-condition Minimal pass (hard
  constraints, byte/bit determinism, well-formed Minimal report,
  every axis within threshold) under the F1 Pass/Xfail discipline,
  with the measured table printed per run.
- HONEST FINDING, day one: RS-1 fails Minimal casting-off (measured
  1.0 vs 0.90) — greedy first-fit leaves a two-measure stub last
  system (width CV 0.6145). Encoded as an asserted Xfail row (fails
  on XPASS) and filed as P12-I11 (engrave balance pass, or catalog
  revision). P12-I12: the Standard spacing floor warns on short
  scores with wide lead measures.

Multi-system click-to-insert fix (editor-core):
- Casting-off exposed two inversion breaks: position_anchors fed a
  non-monotonic cross-system anchor list into a monotonic inverter
  (system-2 clicks resolved to system-1 times), and
  nearest_manifestation found only system 1's staff-line segment
  (system-2 clicks got system-1 pitch geometry).
- Fixed with a containing-system lookup over the resolved pages tree
  (containment, else nearest by vertical distance), per-system staff
  resolution, and per-system anchor filtering; degenerate-geometry
  fallback preserves the flat path, so all 84 pre-existing
  editor-core tests pass unmodified.
- Five regression tests through the real Engraver over the wrapped
  ten-measure fixture, each shown to fail without the fix; testkit
  gains dev-only dependencies on editor-core and engrave.

Trackers: P12-I11/I12 filed; DECISIONS entries in engrave, layout-ir,
and testkit; Phase-3 memory updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
2026-07-03 10:39:05 -04:00
Levi Neuwirth 0316160395 Phase 3 tranche 1: casting-off, K1 schema-fill, value-restoring undo
The chosen Phase-3 attack, run as two parallel waves. 829 workspace
tests pass; clippy -D warnings, fmt, and rustdoc clean; all three
spec documents build with zero undefined references.

Casting-off (epiphany-engrave/src/casting.rs, wired into the
Engraver):
- Greedy first-fit system breaking per region at measure-start
  barline columns; a measure that would overflow the content width
  starts a new system. Hard SystemBreakAt/PageBreakAt always
  honoured; soft breaks honoured unless pathological (skipped with
  the spec's warning + IrOverride-recorded decision).
- Vertical stacking from real content extents with the inter-system
  gap read from the vertical band model; page overflow starts the
  next page. World frame: pages stacked vertically, coordinates
  baked into glyphs/strokes, so the SVG renderer, hit-testing, and
  the GUI viewport are unchanged.
- Real ResolvedPage/ResolvedSystem trees (1-based page numbers,
  content bounding boxes, staves from staff-line segments, measures
  from barline columns); every chosen break appends an
  EngravingDecision with MUSCLOID EngravedBreak provenance,
  UserOverride-attributed via the new ConstrainedLayoutIR
  break_origins sidecar; staff lines split per system with
  synthesized continuation provenance.
- Break-constraint evaluation flips: satisfied iff the layout breaks
  at the slot. The two single-system tests invert deliberately
  (a hard break is now honoured; a user break is honoured and
  attributed instead of warned). Geometric constraints evaluate in
  the pre-casting spaced frame (documented).
- Page geometry is engraver-side PageGeometry (A4 portrait at an
  8 mm staff: page 105 x 148.5 staff spaces, margins 7.5, content
  90 x 133.5; arithmetic documented) — Canvas.layout_defaults has no
  graph home and is a schema-major addition (P12-I7).
  ENGRAVER_VERSION = 2. Goldens regenerated: ten_measure_single_staff
  engraves as 2 systems (viewBox 84x20.6, was 103x11);
  valid_score_rich as 3 systems; stub goldens byte-identical.

K1 schema-fill (Operation Catalog 0.4.0 -> 0.5.0, ratified first;
wire discriminants strictly appended):
- CreateStaff (24 / tag InsertStaff 24): set-union mint of a global
  Staff; CreateStaffInstance now preconditions that its referenced
  staff is live.
- SetTimeSignature (25): value-carrying meter-change LWW keyed by
  (region, resolved position); the carried TimeSignature mints
  set-union; StructuralFieldCollision on meter_sequence.
- SetTempoSegment (26): LWW keyed by (scope, resolved start) over
  the score or region tempo map; a write that would malform the map
  refuses with the appended PreconditionFailureReason 11
  (TempoMapMalformed).
- SetStaffLayout (27): LWW advisory over the staff instance's three
  inline layout fields.
- Create score/canvas remain deliberately unavailable slots: the
  root and canvas are inline singletons with no addressable object
  model (P12-K8), not force-designed.

Value-restoring undo (P11-C8 narrowed; catalog §UndoTransaction
rewritten and per-primitive undo notes updated):
- Canonical-order write chains (base-seeded) across all eleven LWW
  families. StrictInverse restores each written key to its
  chain-predecessor value iff the transaction's write is still the
  key's last writer, else refuses the whole undo with a
  TransactionConflict naming the superseder; BestEffort restores the
  still-last keys. Clean compensations are Applied; only minted-
  object tombstone repairs ride AppliedWithRepair (no new repair
  vocabulary). Mixed mint+overwrite transactions compose; strand
  guards refuse tombstoning mints still referenced by live
  non-members.
- Undo-of-undo pinned and tested: restorations are chain writes, so
  undoing the undo's transaction restores the undone value, and a
  second undo of the same transaction conflicts (absence-restores
  repeat idempotently — documented asymmetry, P12-K11).
- Permutation invariance pinned across five delivery orders; the
  convergence generators gain the new ops and a tx-then-undo flow.
- Still deferred in normative text: delete resurrection (needs a
  system-derived tag outside the ratified closed set), Transpose
  inversion (P12-K2), Cascade dependent closure.

Trackers: Binary Format companion 0.1.0 -> 0.2.0 (appended wire/tag
tables, PreconditionFailureReason 11, payload layouts, history row —
a schema-minor evolution under its own rules); nine new Pass-12 rows
(C5, K8-K11, I7-I10); core-spec OperationKind listing gains the four
kinds; revision-history rows in core spec and companion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
2026-07-02 21:55:26 -04:00
Levi Neuwirth 3e91a8302a Push 4: Binary Format companion, F1 benches, subquadratic reduction order
The audit's fourth push: the biggest outstanding Phase-2 item plus the
performance gate. 793 workspace tests pass; clippy -D warnings, fmt,
and rustdoc (deny-warnings) clean; all three spec documents build with
zero undefined references.

Binary Format companion (spec/binary_format.tex, v0.1.0 — Agent J's
deliverable, 43 pages):
- Twelve chapters transcribed from the golden-locked implementation:
  encoding conventions (the three prefix/endianness regimes, a
  normative no-varint rule, reject-never-normalize decode discipline),
  identifiers imported from the core spec's Canonical Byte-Layout
  Reference, primitive value encodings, the whole-Score positional
  codec ratified as the schema-major-0 wire form, operation wire
  forms (envelope field order with the normative id-leads property,
  the OperationPayload 0..=3 and OperationKind 0..=23 tables,
  effects/conflict/anomaly/MaterializedState vocabulary), the bundle
  physical layout (64-byte header, 256-byte superblock, chunk
  preimages and framing, ChunkRef, manifest body order), the
  operation-index payload, and the extension-blob/edit-barrier byte
  forms.
- Ratifies P12-D1 (req:binfmt:opindex), P12-E1 (req:binfmt:ext-blobs),
  P12-E2 (req:binfmt:condition-depth, MAX_CONDITION_DEPTH = 64
  normative), and P12-E3 (req:binfmt:object-kind-open) — batch rows
  struck through; discharges the provisional-codec notes in core
  (P11-4), ops, and bundle (P11-D2/D4/D5) DECISIONS with ratification
  cross-references.
- Pins the frozen-layout schema-evolution keystone: within schema
  major 0 every positional struct layout is frozen; a field-set change
  is a schema-major change with migration — formally grounding the
  data-model-expansion staging decision. Open questions kept honest
  in-document: SnapshotId derivation, index-refresh threshold, u64/u32
  prefix unification at the next major.
- Not yet delivered from J's charter: the cross-implementation decoder
  test and the wire-format fuzzer (follow-up harnesses).

F1 benches (crates/epiphany-testkit/benches/, per the F0 decision):
- criterion 0.5.1 (workspace dev-dependency; MSRV 1.77 respected with
  documented transitive pins: clap 4.5.53, half 2.4.1).
- reduction bench at 1K/10K/50K envelopes with the Chapter-10 budget
  (>10,000 envelopes/second cold) written in the bench as a Pass/Xfail
  gate; bundle benches for the typical-edit commit (<=50 ms; measured
  ~14.7 ms on real disk after catching that tmpfs neuters fsync) and
  the open/bootstrap read (<=200 ms; measured ~60 us).
- CI: quick budget gates in the conformance job, full gates nightly.

Subquadratic canonical_reduction_order (the F-surfaces/K-fixes
handshake, closing K's 10K-envelope acceptance gate):
- The bench documented the failure (50K at ~1.7K env/s, a 29 s cold
  reduction; two O(n^2) loops); the fix replaces pair enumeration with
  threshold/frontier readiness per replica plus explicit-dot dependent
  lists and a stamp-tuple binary heap — O((n + sum(context)) log n),
  never materializing covered pairs.
- Byte-identical order: same edge relation, same ready predicate, same
  total order; the old implementation is retained as a test-only
  oracle with element-for-element order-equality property tests over
  fuzz sets, adversarial sets, and directed shapes (2,000-envelope
  full-coverage chains, dot cycles, duplicate-id stamp ties),
  mutation-tested for sensitivity.
- Measured: 1K 155K->674K env/s, 10K 12.5K->257K, 50K 1.7K->87K; all
  three scale points now pass and the 50K row is promoted from Xfail.

Also: fixed nine rustdoc private/unresolved intra-doc links that had
accumulated across the pushes (the CI deny-doc-warnings job would have
failed on them).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
2026-07-02 19:02:07 -04:00
Levi Neuwirth 92aaccf7e2 Pushes 1+3: fix the MUST-level violations, wire the types-only machinery
Two audit pushes whose code edits interleave line-by-line in the same
files (reduce.rs, bundle.rs, the DECISIONS logs), committed together so
the tree at every commit builds. Gate: 784 workspace tests pass, clippy
-D warnings clean, fmt clean.

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
2026-07-02 17:10:50 -04:00
Levi Neuwirth 80e3fed99c spec: Pass 12 tranche 1 — align spec text with ratified dispositions
The 2026-07 compliance audit's spec-side batch: every place where
normative text trailed a disposition the project had already made (the
ratified Operation Catalog, a crate DECISIONS entry, or the
PHASE2_QUICKSTART model the implementation follows). No byte-layout or
architecture changes.

- Re-anchoring table: the Slur/Spanner rows now state the catalog's
  surviving-endpoint semantics (re-anchor while >=1 endpoint survives,
  cascade-delete only when none does).
- RewriteTuplets adopts the catalog's id-only v1 payload (TupletRewrite
  removed); graph-aware reduction MUST refuse the variant rather than
  fabricate rewritten values.
- Operation-envelope blocks are pure envelope vectors (a content-
  addressed chunk cannot embed its own id); per-block summaries
  (dvv_summary/min_stamp/max_stamp) relocated to the manifest's
  operation_block_summaries map; the order-blocks-by-min_stamp SHOULD
  dropped (the canonical encoding sorts chunk refs by encoded form).
- Ch2+Ch3 pre-passes respecified as canonical derived annotations —
  deterministic functions of (graph, profile, versioned algorithm id),
  recomputed on materialization, never stored; the incremental-re-run
  MUST demoted to MAY with unobservability required.
- Spelling precedence ties break by canonical attachment order (the
  creation-timestamp tie-break deleted; attachments carry none).
- ConflictRegistry moved off the Score root into canonical materialized
  state, matching Chapter 6.
- SolverTier gains the non-conformance Stub variant.
- The drafted ModifyEvent M2e text landed in the catalog (0.2.0 ->
  0.3.0): metric placement changes materialize behind a voice-occupancy
  placement precondition.

Dispositions logged in spec/PASS12_RATIFICATION_LOG.md; the Pass 12
batch itself stays open (no P12-H/K rows resolved). Also carries the
audit-filed batch rows P12-H6/H7/K3/K4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
2026-07-02 17:04:14 -04:00
Levi Neuwirth 7f8d1a1f95 editor-core: a meter-derived default grid for click-to-insert
default_grid_at(point) gives the GridResolution the pencil should snap to,
derived from the meter of the region under the cursor instead of a fixed
quarter. region_default_grid resolves the governing time signature the way the
prepass's resolve_measure_units does (a region measure's, else the score's
first), and time_signature_beat reads its beat — 1/denominator from the display
(4/4 -> quarter, 6/8 -> eighth, 2/2 -> half) — defaulting to a quarter when the
meter has no single denominator or none is declared. None only when the click
resolves to no staff.

This is additive: position_at / insert_note_at still take an explicit
GridResolution, so their signatures and tests are untouched. The GUI's pencil
click (and the empty-staff preview) now pick the grid via
default_grid_at(world).unwrap_or_else(GridResolution::quarter).

Tests: the beat is 1/denominator (4/4, 2/2; mixed-denominator -> none); a
declared 6/8 yields an eighth-note grid; a meter-less score defaults to a
quarter and a non-finite click yields no grid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
2026-06-30 17:38:58 -04:00
Levi Neuwirth 1496c81302 editor-gui: undo/redo buttons and shortcuts
Wires the session's undo/redo into the GUI: ↶ Undo / ↷ Redo toolbar buttons,
add_enabled-gated by can_undo()/can_redo() so they grey out when there is
nothing to do, and Ctrl/Cmd+Z (undo) / Ctrl/Cmd+Shift+Z or Ctrl/Cmd+Y (redo)
shortcuts read with modifier checks (first in handle_keys, so a modified Z is
not also taken as a plain key). A run_history helper handles the
Option-returning steps, reporting "nothing to undo/redo" when the stack is
empty. These run in the toolbar/key path, before the frame's rerender slot, so
there is no stale-texture concern. Module doc, help text, and the crate
description are updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
2026-06-30 17:21:02 -04:00
Levi Neuwirth 285d7bfd95 editor-core: undo/redo by re-reducing the op-log prefix
The CRDT is delete-wins (a tombstone is permanent), so undo cannot invert an
edit — it re-reduces the log without it. The materialized score is always
reduce(applied onto base); undo drops the last unit (a primitive, or a whole
transaction) so even a delete is undone — its tombstone is simply never
produced. One user action is one unit; a transaction's descriptor and members
undo together. redo re-appends the unit; a new edit forks history (clears the
redo stack). undo/redo/can_undo/can_redo on EditorSession; a shared
materialize/install path keeps the score, render, hit-test, and clefs in step.

Undo must not let ids be reused (a streamed op would equivocate; an entity id
would collide), so the session keeps a permanent append-only `authored` log
beside the active `applied` prefix. New op-id counters come from authored.len()
(monotonic), and event/pitch/transaction minting scans authored — so a unit's
ids stay reserved after it is undone or forked away. The causal context is
derived from the active head (active_prior_context + extend_context): it covers
the whole active prefix, compact while contiguous and dot-based after a fork, so
it never asserts coverage of a removed counter (which would strand a later op
pending). with_identity now guards on authored history, and applied_operations
is documented as the active prefix with authored_operations the full record.
authored history is a local high-water source, not streaming-consistent undo.

Tests: undo/redo a transpose; undo restores a deleted note; undo a split insert
and a make-room overwrite as one unit; undo an override-aware move (value +
spelling); a new edit forks history; a fork mints fresh op and entity ids
(not the undone ones); edits after a fork re-reduce cleanly; with_identity is
refused after an undone edit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
2026-06-30 17:12:02 -04:00
Levi Neuwirth 872ff03dd5 editor-gui: a duration palette over set_selection_duration
A toolbar palette — 1 / 1/2 / 1/4 / 1/8 / 1/16 — sets the selected note or
rest's written value via set_selection_duration(NoteValue::whole_note_fraction),
with the editor's make-room overwrite when lengthening; refusals (tuplet member,
decomposed event, non-note selection) surface in the status line. The palette
lives in the toolbar, which runs before the frame's rerender slot, so it has no
stale-texture concern. Adds epiphany-core as a direct dependency for NoteValue
(already a transitive workspace member, so no MSRV/CI impact). The module doc and
crate description are updated; only an undo UI remains unbuilt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
2026-06-30 16:32:30 -04:00
Levi Neuwirth 0f298508af editor-core: a set_selection_duration intent with make-room overwrite
The duration-palette gesture: resize the selected note/rest. Shrinking frees the
space after it; lengthening makes room over the events it grows into (trim,
delete, or split), atomically with the resize — the same overwrite policy as
insert. Built on phase 3's metric placement materialization.

Extracts the make-room logic shared with insert_note_at: make_room classifies a
voice's events overlapping a span (excluding the event being inserted/resized)
into deletes/trims/split-tails; make_room_ops turns that into ops (cloning a
split tail's shape with fresh ids, carrying authored spellings); a Minter struct
replaces the inline id closures. insert_note_at now calls these (its 7 tests
confirm behaviour is preserved).

Refuses cleanly: InvalidDuration (non-positive), WrongSelection (nothing apt /
non-metric / not a note or rest — replace_span only resizes Pitched/Rest),
OverlapsTuplet (a tuplet member, whose duration is ratio-governed), and
DecomposedEvent — a resize/trim/split would change the duration of an event with
a persistent decomposition attachment, leaving its notated components no longer
summing to it (invariant 15); a full-cover delete is allowed, since the
tombstoned target's decomposition is no longer checked. make_room also skips
non-positive existing spans, matching the reducer's overlap rule.

Tests: shrink, lengthen-into-empty, lengthen-deletes-a-covered-note,
lengthen-trims-a-partial-overlap, refuse non-positive/tuplet, refuse a decomposed
event, refuse a non-note/rest event, requires a selection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
2026-06-30 16:25:14 -04:00
Levi Neuwirth 7387eeafc5 editor-core: resolved-spelling-aware chord stacking
add_note_to_selection no longer refuses when a note carries an authored
spelling override. It ranks the chord's notes by their *resolved* staff
position — the spelling the layout draws, from derive_annotations (the same
prepass to_logical uses), so an authored override or an inferred respelling is
honored — and steps one staff step above the rendered-highest note. The new
note's raw position lands at rendered_top + 1: stepping the top's pitch by
rendered_top + 1 - raw_top carries its alteration and places it there (for an
un-respelled note this is the old +1 behavior); the acoustic realization still
resets to Implicit. A note with no resolved CMN spelling falls back to its raw
pitch position.

rendered_top_of_event replaces highest_pitch_in_event; the event-level override
refusal, the has_authored_spelling_override helper, and the now-unraised
EditorError::PitchSpellingOverridden variant are removed; note_above becomes
note_stepped(_, 1).

The refusal test is replaced by one that pins a note three staff steps above its
raw position and asserts the add stacks at rendered + 1 (raw + 4), not raw + 1 —
verified to fail under raw-position ranking. This closes the last
override-refusal in the editing track.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
2026-06-30 14:44:27 -04:00
Levi Neuwirth ba84df67fb editor-gui: a pencil mode that clicks notes onto the staff
Wires the click-to-insert into the GUI: a pencil ("insert") mode, toggled by a
toolbar button or the P key, turns a staff click into insert_note_at on a
default quarter-note grid (make-room overwrite), instead of selecting. A
non-pencil click still selects, and its empty-staff status now reports what a
pencil click would insert (pitch + grid-snapped beat).

The pencil edit runs in score_view, after the frame's rerender slot, so it
requests a repaint and returns before the selection overlay would paint the new
hit map over the not-yet-rerendered texture. Non-pencil selection keeps the
immediate overlay path (it does not mutate the rendered score).

This completes the beat-grid click-to-insert feature (vertical inverse →
horizontal inverse → trim/move reducer semantics → insert_note_at → this GUI
wiring). The GUI's rendering/interaction is still not visually verifiable in
this environment; only the ViewMap coordinate logic is unit-tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
2026-06-30 13:17:32 -04:00
Levi Neuwirth ce37de8834 editor-core: an insert_note_at click→insert with make-room overwrite
The pencil: a click-to-insert that composes the two inverses — staff_pitch_at
(the natural pitch at the height) and position_at (the grid-snapped onset) —
into a fresh CMN note (duration = grid step) in the staff's primary voice, and
makes room under the overwrite policy as one atomic transaction.

Make-room over the overlapping notes/rests of the voice: a fully-covered event
is deleted; one the new note partially overlaps is trimmed (a phase-3
ModifyEvent placement change); one the new note lands inside is split — the
original is trimmed to its head and its tail re-inserted. A split tail is a
clone of the original event's whole shape (a rest's visibility, a note's
articulations/dynamics/stem/grace) with fresh event and pitch ids, and any
authored spelling is carried onto the fresh pitches via RespellPitch (an
inferred spelling re-derives) — the same atomic copy insert_note_after_selection
does. The several ids minted in one intent advance local checked counters, since
the session high-water mark does not move until commit.

Refuses cleanly rather than corrupt: an overlapped tuplet member raises
OverlapsTuplet (ratio compensation is out of scope); an off-staff or non-metric
click, or a non-note/rest overlap, raises NoInsertTarget.

Tests (against valid_score's tuplet-free metric region; the rich fixture's only
metric region is a triplet): empty-space insert, full-cover overwrite, the split
structure, the split tail's shape fidelity and authored-spelling carry, the
tuplet refusal, and the non-metric refusal. A click_for_position helper inverts
position_at so a test targets an exact onset.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
2026-06-30 12:35:39 -04:00
Levi Neuwirth b3403c1d6a ops: ModifyEvent materializes metric placement changes (trim/move)
The make-room enabler: a ModifyEvent that moves a metric event's span
(different Musical position or duration) is now applied to the graph and the
owning voice re-sorted by ascending position (id-tiebroken, the order an
insert maintains), so invariant 3 (VoiceEventsSortedNonOverlap) holds. The
deferral documented in DECISIONS.md is lifted.

modify_event computes one sanction — the move is a valid metric move
(PlacementVerdict::Moved) and the replacement is well-formed — and uses it to
gate both the graph mutation (graph_replace_event) and the voice_occupancy
update, so the canonical index and the graph never diverge. The verdict is
read from voice_occupancy, the graph-independent placement index, so reduce()
and reduce_onto() agree on it; a move with a non-positive span or one that
would overlap a live sibling is refused as a clean NoOp(EventDurationInvalid)
rather than skipped silently. A non-metric move stays deferred, and a
malformed (empty) pitched replacement is neither materialized nor allowed to
move occupancy.

Tests: trim frees the voice slot (a later insert fits); a move onto a sibling
is refused; a trim materializes in the graph (reduce_onto, invariants hold);
a non-metric event is not rewritten onto the musical grid; a malformed move
does not free the slot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
2026-06-30 10:43:10 -04:00
Levi Neuwirth 1ab3e5f298 editor-core: a position_at click→beat-grid horizontal inverse
The horizontal half of click-to-insert: resolve a world point to the metric
region under the cursor and the grid-snapped musical position to insert at.
Together with staff_pitch_at it gives the (pitch, position) an insert needs.

The region and staff are picked through a shared nearest_manifestation helper
(extracted from staff_pitch_at, the non-finite guard moved into it), so the
vertical and horizontal halves agree on which staff the click is over.

The x→time inverse is solver-agnostic: position_anchors reads resolved glyph
x-positions and maps each to its event's onset through the glyph's Pitch/Event
provenance, so the samples come from the actual rendered layout. Only directly
-manifested onset glyphs anchor — synthesized glyphs (accidentals sit left of
the notehead, ledgers, …) are skipped so they cannot pull the time column off
the notehead. invert_x is piecewise-linear through those anchors, extrapolating
the end segment past the last note — the make-room case of clicking the empty
staff after the last note. snap_to_grid rounds to the nearest multiple of the
grid step, rebuilt by exact rational arithmetic so it lands on the grid.

GridResolution is the caller-supplied beat grid (a musical-time step, also the
default duration of a note entered there); a meter-derived default is deferred.
position_at refuses a non-positive grid, a non-metric region (no musical onset),
and a region with fewer than two rendered events (no scale to extrapolate from).

The GUI shows the resolved pitch and snapped position on an empty-staff click,
on a default quarter-note grid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
2026-06-29 21:46:11 -04:00
Levi Neuwirth 8bffd2f2fd editor-core: a staff_pitch_at click→pitch vertical inverse
The vertical half of click-to-insert: resolve a world point to the staff
under the cursor and the natural diatonic pitch at that height under the
staff's clef.

Staff selection is manifestation-aware. Each rendered bottom staff line
carries its staff's manifestation id as the stroke's stable_id, so a click
maps back to a concrete (region, staff_instance) by matching that id, then
chooses the nearest staff by 2D proximity — horizontal span first (which
region, since one staff tiles across regions that can share a y band), then
the vertical band. The earlier StaffId-collapsing helpers are gone.

The clef is resolved by time, not vector order: render_score builds a
per-staff start-clef table from the logical layout via active_clef (now
exported from epiphany-layout-ir) and threads it through open/commit, so a
mid-vector [bass@1, treble@0] still spells the staff start as treble.

A non-finite click is rejected up front — NaN would slip through
dist_to_band's comparisons as distance 0 and saturate the round() as i32
step into a bogus pitch.

Tests cover finite resolution, x-aware manifestation selection across staves
sharing a y band, non-finite rejection, and time-ordered clef resolution.
The GUI surfaces the resolved pitch on an empty-staff click.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
2026-06-29 18:56:34 -04:00
Levi Neuwirth e5efe22d5b layout-ir: staff_step_pitch, the clef-relative position inverse
The inverse of staff_position: a staff step (a clicked staff height) back to the
diatonic pitch (nominal, octave) written there under a clef. This is the vertical
half of the click-to-insert inverse the GUI will use — turning where you click on
a staff into the pitch to place.

It mirrors the forward map's clef and octave-shift convention exactly, computes
the diatonic index in i64 so an extreme step cannot overflow the intermediate,
and returns None for a percussion clef (no diatonic mapping) or a position whose
octave falls outside i8 (rather than wrapping to a wrong-looking octave). A
round-trip test covers treble/bass/alto/tenor and octave-shifted (8va/8vb)
trebles across octaves and nominals, plus the out-of-range refusals.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
2026-06-29 17:28:23 -04:00
Levi Neuwirth 9539b1652b layout-ir, engrave: ledger lines
Notes above or below the staff now get ledger lines — the gap the GUI surfaced
the moment a note is moved off the staff. Each notehead carries its StaffStep;
to_constrained emits one short horizontal stroke per whole step between the
staff (lines at steps 0..=8) and the note, reaching LEDGER_LINE_EXTENSION past
each side of the note's actual bounding box (so a wide whole note gets a wider
ledger), synthesized from the pitch so the strokes are deterministic and
hit-testable. The synthesis key splits component (high 64 bits) from signed step
(low 64) so two components of a very low note can never collide.

Ledger lines are fixed-width marks, not system-spanning lines, so the Engraver's
horizontal spacing must not scale them. is_rigid_width_stroke marks them; the
remap translates such a stroke rigidly by its *owning glyph's* column delta
(found by source, not the stroke midpoint — which for a wide head can fall nearer
a neighbouring column), so it keeps both its length and its offset from the
notehead. The spacing pass folds each ledger's extent into its notehead's slot,
so adjacent off-staff notes' ledgers reserve room and do not overlap. The stub
solver passes ledgers through unchanged.

Tests cover the step geometry, key distinctness (incl. steps below -128), the
bbox span, width preservation and offset (no-drift) through the Engraver, the
adjacent-overlap spacing, and an explicit two-whole-note off-staff drift case.
Render goldens regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
2026-06-29 17:12:56 -04:00
Levi Neuwirth c56ea4845f editor-gui: a thin native (egui) editor over the editor-core seam
A new demo binary crate that proves the EditorSession seam end to end. It renders
the session's resolved layout with epiphany-render-svg, rasterizes that SVG with
resvg into an egui texture, resolves clicks back to staff-space world coordinates
to select, and drives all six note-editing intents from a toolbar and keyboard
(delete, chromatic transpose, staff-step move, add chord note, insert-after). A
debug panel shows the selection and the last applied op, proving the op log is
usable. Intentionally narrow: no duration editing, empty-space insert, or undo UI.

The one piece of nontrivial logic — the screen<->world map a click depends on — is
a pure ViewMap that inverts render-svg's translate(-min_x, max_y) scale(1 -1)
transform, unit-tested for round-trip, the y-up corners, and the logical-vs-ceiled
size mapping (the texture is displayed at the SVG's logical size with a uv crop, so
the click plane is not stretched by the pixmap's rounded-up dimensions). rerender
updates view box, logical size, and texture together on success and clears the
texture on a rasterization failure, so the pixels never disagree with the click
plane.

eframe + resvg are the workspace's first heavy external deps. eframe is pinned to
0.29 for the classic App::update(ctx) + panel API (0.35 redesigned App around
ui()). The crate needs a newer toolchain than the workspace MSRV, so CI excludes
it from the pinned-1.77 workspace commands and checks it in a separate
current-stable editor-gui job; the rest of the workspace is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
2026-06-29 11:47:25 -04:00
Levi Neuwirth d16435f2a9 editor-core: expose the resolved layout for renderers
EditorSession kept only the RenderIR (its hit-test projection) and discarded the
ResolvedLayoutIR the solver produced — but a renderer like epiphany-render-svg
consumes the ResolvedLayoutIR, not the RenderIR. Keep it on the session and add
resolved(), so a GUI can draw the score without re-running the solve. render_score
now threads all three (resolved, render, map); they are updated together on open
and on every committed edit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
2026-06-29 11:46:31 -04:00
Levi Neuwirth d6fb8afcdc editor-core: override-carrying insert_note_after_selection
An insert after a pitch that carries an authored spelling override no longer
refuses — it carries the spelling onto the copy. With no override the insert is
still a plain InsertEvent (the copied value takes the inferred spelling); with
one, it emits a transaction of InsertEvent plus a RespellPitch on the new note's
fresh PitchId set to the anchor's authored spelling. The members apply in counter
order, so the insert mints the pitch before the respell spells it, and the copy
renders like the original. The spelling is copied verbatim, not stepped: an
insert-after is a same-pitch time copy, so it keeps the same staff position.

This completes the four-step transaction plan: foundation, override-aware move,
override-carrying insert. The only remaining override refusal is the chord add
(PitchSpellingOverridden), where picking the visual top note needs
resolved-spelling-aware stacking — a separate follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
2026-06-29 10:35:35 -04:00
Levi Neuwirth 703d9d7e35 editor-core: override-aware move_selection_staff_step
A staff-step move of a pitch that carries an authored spelling override no longer
refuses — it rebases the override atomically. With no override the move is still a
plain ModifyIdentifiedPitch (the inferred spelling follows the new value); with
one, it emits a transaction of ModifyIdentifiedPitch (step the value) plus
RespellPitch (step the spelling by the same staff step), so the pinned notehead
and the sound move together as one undoable unit.

authored_spelling returns the winning override spelling, mirroring
resolve_spelling's selection (lowest precedence rank, then highest priority, then
first in canonical order); has_authored_spelling_override now delegates to it.
Because RespellPitch materializes a UserChosen attachment (top precedence),
rebasing the winning spelling works even when the original override was Imported
or Propagated — the new UserChosen shadows it. staff_step_spelling is the spelling
analogue of staff_step: it moves the CMN spelling nominal and octave (B-C carry)
while preserving the accidental stack and render hints, so pitch and spelling move
the same number of staff positions and stay enharmonically consistent.

This is the first intent built on transaction-aware apply. The chord add and the
copy-insert still refuse on an override (PitchSpellingOverridden); insert gets its
override-carrying version next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
2026-06-29 10:30:21 -04:00
Levi Neuwirth f770f9acf2 editor-core: transaction-aware apply
apply_transaction(label, category, kinds) commits a sequence of primitives as
one atomic transaction: a DeclareTransaction descriptor plus one member envelope
per kind, all minted under the session identity and committed together. The
session's contiguous zero-based causal context gives every member
descriptor-precedence over the descriptor for free (members come after it in
counter order), so the reducer's well-formedness rule holds without special
handling. A fresh TransactionId is minted log-only (transaction ids live only in
the op stream).

apply and apply_transaction now share one commit(Vec<OperationEnvelope>) engine:
accept the prior log plus the new envelopes, reduce the whole set onto the
pristine base, and commit only if every envelope is accepted, the reduction is
clean, and the layout renders. The is_clean gate is the key correctness point —
a transaction whose member preconditions fail rolls back as a conflict yet the
reducer still returns a score, so without it a rolled-back (dead) transaction
would be logged as successful.

Guards keep the log meaningful: an empty member list (EmptyTransaction) and a
DeclareTransaction submitted as an edit, directly or as a member
(DeclareTransactionNotAllowed), are both refused before minting. This is the
foundation only; no intent uses it yet. Override-aware move/insert land next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
2026-06-29 10:18:11 -04:00
Levi Neuwirth 6a8de4bdea editor-core: an insert_note_after_selection new-event intent
Insert a new note in the selected pitch's voice, immediately after its event: a
fresh single-note event (InsertEvent) at the next musical position, in the same
voice, copying the selected pitch and its rhythmic value. The selection stays on
the anchor. This completes the insert work begun by the chord-add — both anchor
on the selection, since the click-empty-space-to-position inverse is not in the
hit-test seam yet.

Fresh EventId minting reuses the three-source high-water-mark basis of pitch
minting (base, current score, op log), so event ids are never reused either.

Every reducer rejection is pre-checked so the edit refuses cleanly instead of
appending a dead, non-materializing op: voice overlap (InsertSlotOccupied,
mirroring the reducer's interval test), a non-metric region or non-positive
duration (resolved together with the staff instance, requiring
RegionTimeModel::Metric), and an authored spelling override on the copied pitch
(PitchSpellingOverridden — the raw-value copy would drop the override and render
differently). Refusals are early returns before minting/apply, so the op log and
id counters are untouched; tests assert the log does not grow across a refusal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
2026-06-28 17:20:34 -04:00
Levi Neuwirth f4d6922587 editor-core: an add_note_to_selection chord intent
The first insert intent, and the one that introduces fresh object-id minting
into editor-core (delete/move only referenced existing ids). It adds a note to
the selected pitch's event via InsertIdentifiedPitch — a new identified pitch a
diatonic staff step above the event's highest note, so repeated calls build a
rising chord rather than stacking duplicates on one staff position. The
selection stays on the anchor (which survives), and the new note's acoustic
realization is reset to Implicit so it sounds at its written position rather
than inheriting an explicit absolute frequency from the note it stacks above.

Fresh PitchId minting takes the high-water mark over three sources — the
pristine open-time base, the current score (each live or tombstoned), and this
session's op log — because a pitch deleted via DeleteIdentifiedPitch leaves no
trace in the materialized score (the reducer tombstones only its own state), so
reusing its id would make a later insert no-op against a tombstone under
whole-log reduction. Increment is checked; the log scan covers both insert ops.

Adding is refused (PitchSpellingOverridden) when any note in the event carries
an authored spelling override, since the rendered staff order then cannot be
read off the raw pitch positions — resolved-spelling-aware stacking, and the
new-event insert_note_after_selection, are follow-ups.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
2026-06-28 16:32:03 -04:00
Levi Neuwirth 42208c166d editor-core: a diatonic move_selection_staff_step intent
Move the selected pitch by N diatonic staff positions (+1 up, -1 down): a
nominal move that carries the octave at the B-C boundary and preserves the
accidental — the "diatonic move" a vertical drag performs. It emits
ModifyIdentifiedPitch, which keeps the note's id, so the selection survives the
relayout. The staff step is the same data path the per-edit causal context was
built for: two sequential moves to one pitch reduce as intentional overwrites,
not a StructuralFieldCollision (proven by replaying the op log).

An authored spelling override (user-chosen, imported, or propagated) resolves
ahead of the inferred spelling and pins the rendered staff position, so a
value-only move would change the sound without moving the notehead. The intent
refuses that case (PitchSpellingOverridden) rather than mislead — the predicate
mirrors resolve_spelling's precedence ranking. A respelling-aware move that
atomically rebases the override is a follow-up (it needs a transaction-aware
apply). The diatonic index is computed in i64 so an extreme step count cannot
overflow before the octave range-check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
2026-06-27 19:03:05 -04:00
Levi Neuwirth 2b4340feea editor-core: an applied-operation log with causal context
Expose the envelopes a session has applied — applied_operations() (oldest
first) and last_applied() — the append-only record undo, history, and sync
build on. Each intent feeds it automatically through apply().

Minted envelopes now carry a real causal context: the session's edits form one
replica's contiguous, zero-based history, so the op at counter n covers the
range [0, n-1]. Two sequential edits to the same target therefore reduce as
intentional overwrites (the later covering the earlier), not as concurrent
StructuralFieldCollision conflicts — both when the session re-reduces its own
log and when a peer replays it.

To keep a covering context satisfiable, apply() reduces the whole accumulated
log onto the pristine open-time score rather than the new op alone onto the
running materialization: the predecessors are present in the set, so the
missing-predecessor rule does not hold the new op pending, and the session's
render is now exactly the canonical reduction of the op log it emits. The
counter is derived from applied.len() (a failed apply consumes no id), and
with_identity is enforced as pre-edit-only so the contiguous history stays
hole-free.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
2026-06-27 18:36:50 -04:00
Levi Neuwirth eca5e37ec9 editor-core: a delete_selection() intent
EditorSession::delete_selection() deletes the selected object through the right
operation: a selected pitch (a notehead) -> DeleteIdentifiedPitch (the reducer
degrades a last-pitch event to a rest of the same duration, so the rhythm
survives; a chord note simply drops), a selected event (a rest, a stem) ->
DeleteEvent. The deleted object's layout id no longer exists, so the selection is
cleared. Errors (NoSelection / WrongSelection) leave the session untouched, via the
atomic apply().

This exercises a different reducer path from transpose (tombstone-with-rest-degrade
vs. in-place pitch mutation) and stays small. Tests: deleting a note changes the
graph and the render and drops the selection; the intent requires a selection.

(Duration editing is intentionally not added here: a ModifyEvent duration change is
not materialized into the graph -- graph_replace_event defers position/duration
edits pending the voice non-overlap semantics -- which is an epiphany-ops increment,
not editor-core code.)

Full gate green: build, fmt, clippy, 608 tests, conformance scale 1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 17:45:32 -04:00
Levi Neuwirth a28d888c8e epiphany-editor-core: a headless editor API + a conformance UI-seam gate
Packages the proven editing-loop vertical slice as the API a GUI calls -- no UI,
no rendering backend of its own (it produces a RenderIR). EditorSession owns:

  - selection state (Selection { source, layout_object }): click(point) selects the
    topmost hit, select(id) restores a selection, selection()/clear_selection();
  - render/hit-test query: render() and hit_test() for the GUI to draw and resolve
    clicks/drags;
  - operation minting -- the ergonomics gap the harness exposed, closed before UI
    depends on it: the caller passes an OperationKind to apply() (or an intent like
    transpose_selection(+1)) and the session assembles the OperationEnvelope (id,
    author, stamp, causal context). A GUI never hand-rolls envelope bookkeeping;
  - apply/re-render -- ATOMIC: a minted op the reducer rejects (e.g. a reserved
    replica identity) returns Err(RejectedOperation), not a silent no-op, and a
    diagnostic-only layout returns Err(NotRenderable); on any error nothing mutates,
    operation counter included (the candidate id is committed only on success);
  - selection preservation: the selection is re-resolved against the new layout,
    kept when its layout object survives and cleared when it is gone.

The session is solver-agnostic (Box<dyn ConstraintSolver>), so a GUI plugs in the
Engraver, the stub, or any conformant solver. EditorError implements Display/Error.
epiphany-ops now re-exports AcceptOutcome (accept()'s return type, previously
unreachable) so a caller can inspect a rejection.

Also wires the edit-loop harness into the conformance suite as the [7c] UI-seam
gate: over both fixtures (ten_measure_single_staff and valid_score_rich) every seed
must drive a click->sharpen->re-render cycle whose selection survives the relayout
-- the contract a GUI's correctness rests on.

Full gate green: build, fmt, clippy, 606 tests, conformance scale 1 (incl. [7c]).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 16:47:34 -04:00
Levi Neuwirth bdab6dee1f testkit: an editing-loop vertical slice across the ops/layout/render seams
One iteration of an interactive edit, wired end to end so a missing connection
between the crates surfaces here, not in a GUI: render a score to its RenderIR,
resolve a click on a notehead to its graph pitch through the hit-test map, apply a
real operation (sharpen -- a +1-chromatic Transpose) by reducing it onto the score
graph, re-render, and confirm the selection survives the relayout.

  - run_edit_loop_with<S: ConstraintSolver>(base, solver) returns an EditLoopReport
    (selected_pitch, selection, graph_changed, selection_preserved, render_changed);
    run_edit_loop is the stub wrapper. render_with returns None for a
    diagnostic-only (non-renderable) solver report, so the loop never hit-tests or
    edits a layout the caller must not render.
  - The click resolves like a GUI's: aim at each real notehead's centre (a notehead
    glyph, not synthesized, pitch-backed) and select whatever the topmost hit there
    is, taking the first aim whose topmost hit is a pitch-backed glyph -- faithful
    to a click and robust to an occluding unison/chord notehead.
  - Selection survival rests on the MUSCLOID layout id, a function of the pitch's
    identity (PitchId), not its content: the sharpen changes the pitch's value but
    not its id, so its layout object keeps the same stable id and the cursor does
    not jump off the edited note. The prepass re-runs inside to_logical, so the
    edited pitch is re-spelled and its accidental reflects the new value (why the
    re-render reliably differs).

Tests drive the loop on valid_score_rich (one fixture and across 48 seeds, every
seed required), refuse a diagnostic-only solver layout, and -- in epiphany-engrave,
via its existing testkit dev-dep -- run it through the real Engraver across 16
seeds, proving the selection survives even as the Engraver re-spaces every glyph.
Full gate green: build, fmt, clippy, conformance scale 1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 16:25:17 -04:00
Levi Neuwirth cb92d890ec layout-ir: a render-to-hit-test contract at the RenderIR boundary
Chapter 7 calls provenance "the basis of hit-testing, selection, and
back-reference navigation in the UI" and defers the renderer details to a
companion. An editor needs more than the trace it already has: a structured map
from a rendered primitive to its layout object and its score object, with a
selectable shape, so a click or drag resolves to something to select without the
GUI re-deriving geometry or guessing the chain.

RenderIR::hit_test_map() (new hittest.rs) builds that map: one HitRegion per glyph
primitive and per stroke, each carrying the full chain -- rendered primitive ->
layout object (stable_id) -> score object (source) -- plus the synthesis kind and
a selectable HitShape:

  - HitShape::Box: a glyph's IR bounding_box placed by its position and any
    transform (corners mapped through the same placement the SVG renderer applies,
    so the region aligns with the drawn glyph; I-4a made the IR bbox contain the
    ink).
  - HitShape::Segment: a stroke's segment with a half-width (half its thickness).

Queries: HitShape::contains (point-in-shape, click) and intersects_rect (an exact
capsule-vs-rectangle test for drag, not just AABB overlap, so a diagonal stroke
whose bounding box clips a corner is not falsely selected); HitTestMap::hit(point)
returns regions topmost-first (paint order: layer, glyph-over-stroke, index) and
within(rect) returns the drag selection in ascending paint order.

Two deliberate boundaries: shapes are in staff-space WORLD coordinates (the same
frame as RenderPrimitive.position, before any renderer's world->screen transform),
so the contract is renderer-independent and a GUI applies the inverse of the same
transform its renderer uses; and a glyph's region is its IR bounding_box, not the
render-only outline.

Tested at the RenderIR boundary (8 tests): box/segment contains + aabb; world-box
placement incl. an affine transform; coverage + provenance-chain preservation over
the real pipeline; click-on-a-notehead resolves to its Pitch; topmost-first point
hits; exact (not AABB-only) drag intersection; paint-ordered drag results. Full
gate green: build, fmt, clippy, 595 tests, conformance scale 1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 14:25:53 -04:00
Levi Neuwirth 3ef1a8abd9 Pass-12 batch: mark the resolved I-series rows done
A status sweep: the I-series Pass-12 candidates are all closed, but the tracker
and two crate DECISIONS.md files still described them as open.

  - PASS12_BATCH.md: P12-I1 (structural-placeholder pipeline) is resolved by I-1
    -- to_constrained now builds real notation and the Engraver re-spaces it;
    P12-I3 (BRAVURA_METRICS approximations) is resolved by I-4a -- metrics
    re-extracted from the same pinned 1.392 font, containment-tested. Both rows
    struck through and marked done.
  - render-svg/DECISIONS.md: fixed the stale P12-I2 bullet, which still said the
    MUSCLOID derivation was unwired and the determinism crate exposed no tag (a
    miss from the P12-I2 commit); now marked resolved.
  - engrave/DECISIONS.md: its P12 section described P12-I1/I3 as open and omitted
    I2; rewritten so all three read resolved.

The batch's top-level Status stays OPEN -- the H-series and K-series candidates
remain. Docs-only; no code change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 12:53:36 -04:00
Levi Neuwirth e2d389330d P12-I2: wire the ratified MUSCLOID layout-object id derivation
The spec's domain-tag registry reserves a non-canonical MUSCLOID layout tag for
LayoutObjectId derivation (req:layoutir:object-id-derivation, Pass-11 item 2.6),
but it was never realized in code: layout-ir minted provisional, untagged ids and
synthesized objects borrowed MUSCCONF. This wires the ratified derivation.

  - epiphany-determinism: add the reserved built-in DomainTag::LAYOUT_OBJECT_ID
    (`MUSCLOID`), non-canonical/layout-namespace like FONT_METRICS (following the
    SYSTEM_ANOMALY Pass-11 precedent of adding a reserved tag). The tag-enumeration
    tests now derive from BUILTINS so they cannot drift; the spelling is locked and
    from_bytes resolves it as a non-system builtin.
  - layout-ir provenance.rs: all three LayoutObjectId derivations route through
    MUSCLOID exactly per the requirement -- single keyed on source.canonical_bytes(),
    multiply-manifested on (source, region), synthesized on (source, synthesis_kind,
    instance_key); synthesized no longer borrows MUSCCONF. A reference-lock test
    pins the derivation and proves it is genuinely domain-separated; another asserts
    the three keying schemes do not collide (safe by the discriminant-led,
    fixed-width canonical_bytes).
  - layout-ir engraving.rs: EngravingDecisionId borrowed MUSCCONF for the same
    reason; moved it onto MUSCLOID too, keeping its `engraving-decision` prefix so it
    cannot alias a layout-object id within the namespace.

Layout ids are non-canonical (never document state, in no content hash), so this
changed id *values* but no durable or interchanged artifact: the only golden churn
is the data-prov hex in the four render goldens (every changed line is a data-prov;
geometry/structure byte-identical).

Spec/status sync: core_spec.tex descriptive notes (the requirement tail, the
domain-tag registry row, the registry intro, and the revision-history entry) now
say the reference implementation wires MUSCLOID as of P12-I2; MUSCLOID is moved out
of the "deferred to the companions" (not-ratified/provisional) list and given a
non-canonical anchor paragraph after the reference-implementation-locks table.
PASS12_BATCH.md marks P12-I2 resolved; PASS11_RATIFICATION_LOG.md keeps the
historical row with a "superseded by P12-I2" note; layout-ir/DECISIONS.md updates
the ratified-block note, the id bullet, and the open candidate (now RESOLVED).

Full gate green: build, fmt, clippy, 587 tests, conformance scale 1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 12:46:19 -04:00
Levi Neuwirth 6293734fa0 Agent I-4c: embedded @font-face glyph mode (a second self-contained renderer)
A render mode that references each glyph by its SMuFL codepoint via `<text>`,
drawn from an `@font-face`-embedded Bravura subset, alongside the default inline
`<path>` outlines. The SVG stays self-contained (the font travels in it) and the
text is selectable, at a larger file size.

  - GlyphMode::EmbeddedFont: the SVG declares the font once via
    <defs><style>@font-face{...}</style></defs>, then emits one
    <text transform="translate(x y) scale(1 -1)" font-size="4" ...>&#xNNNN;</text>
    per glyph. Geometry is consistent with PathOutline by construction: same
    origin, a per-glyph counter-flip cancels the outer y-flip, and the SMuFL em is
    four staff spaces. A new `text_count` stat; unbundled glyphs still fall through
    to the visible bbox rect + diagnostic. The metadata comment declares which mode
    produced the SVG (and, on the empty canvas too, via a shared `glyph_note`).
    Path mode stays the byte-golden-locked, pixel-verified reference; the embedded
    mode is structurally tested (well-formed XML — also under `xmllint`; one
    @font-face; one <text>/codepoint per glyph; provenance preserved; determinism).

  - The subset is a GENERATED artifact, not a vendored binary: a deterministic
    base64 OTF emitted into src/font_subset_generated.rs by
    `tools/extract_bravura_outlines.py --font-out` (the same SHA-pinned 1.392 font
    the outlines come from). `recalcTimestamp=False` keeps the source font's fixed
    head.modified so the bytes are reproducible across runs, not just within one.

  - OFL compliance: the subset is a Modified Version, so its PRIMARY font name is
    renamed off the Reserved Font Name "Bravura" to "EpiphanyBravuraSubset" in BOTH
    naming structures an OTF carries — the SFNT `name` table AND the CFF (Name INDEX
    + top-dict FullName/FamilyName). The copyright/trademark/license records, which
    name Bravura as attribution, are kept; the renderer references the renamed
    family in @font-face and <text>. The generator reparses the saved bytes and
    fails if the reserved name leaks into a primary record, and validates the cmap
    covers every glyph.

  - Machine-locked payload: the generator emits the decoded length and a BLAKE3-256
    (the workspace's sole hash) of the font bytes; a render-svg test base64-decodes
    the payload (no new runtime dep) and asserts the length, the OTTO signature, the
    BLAKE3, and — parsing the SFNT name table and CFF Name INDEX — that neither
    primary name is the reserved name. Adds an epiphany-determinism dev-dep.

The demo example gains `--glyph-mode=path|embedded`; lib/README/DECISIONS document
the two modes, the subset's OFL rename, and the regeneration command (--font-out +
the blake3 dependency). PathOutline goldens are byte-unchanged; the outlines stay
byte-identical. Full gate green: build, fmt, clippy, 585 tests, conformance scale 1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 11:09:34 -04:00
Levi Neuwirth 41cd8bf58b Agent I-4b: a trace-free render declares itself display-only
`RenderOptions::emit_provenance = false` dropped every data-prov trace silently,
so the output was indistinguishable from an archival render even though it no
longer satisfies the renderer's "every element traces to its source" contract.

Now the SVG's metadata comment declares the provenance state, so suppression is
announced rather than silent:

  - archival (default):    "...; every glyph and stroke carries a data-prov trace
                            to its score-graph source"
  - display-only (false):  "...; provenance traces suppressed (display-only
                            output, not archival)"

A shared `provenance_note(emit_provenance)` helper feeds both the main render and
the empty-canvas path, so an empty trace-free layout is held to the same honesty
contract as a full one (neither can drift). The module doc and the render-svg
DECISIONS.md non-overreach rule now frame data-prov as the default archival
contract plus an explicit, declared display-only mode. Tests assert the suppressed
marker (full and empty layouts) and that the default render declares traces
present.

Goldens regenerated: the default (archival) render's metadata comment now carries
the new "every glyph and stroke carries a data-prov trace" clause, so the four
`.svg` goldens change by that one line (the snapshots, which omit the comment, do
not). Full gate green: build, fmt, clippy, 581 tests, conformance scale 1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 09:47:35 -04:00
Levi Neuwirth eec63aa244 Agent I-4a: reconcile the Bravura metrics with the 1.392 outlines
The layout metrics table claimed Bravura 1.38.0 while the renderer's outlines are
extracted from 1.392, so the advances/bboxes the engraver reserves space and
evaluates collisions from did not match the ink actually drawn (P12-I3). Both now
come from the SAME SHA-pinned bravura-1.392 font.

  - BRAVURA_METRICS is regenerated from 1.392 by tools/extract_bravura_outlines.py
    (the same font and script the outlines come from; the regenerated outlines are
    byte-identical to the committed ones, and the extracted timeSig rows match the
    values bundled earlier -- two integrity checks that the extraction is faithful).
    Several old rows were not just stale but wrong: flag8thUp had its ink above the
    origin when the glyph hangs below the stem tip; restWhole/restHalf and the
    clefs had approximate or mis-signed bounds.
  - The metric bbox is rounded OUTWARD from the outline bounds (floor the mins,
    ceil the maxes) rather than to the nearest 1/1024, so the integer metric box
    always CONTAINS the drawn outline -- the engraver evaluates collisions from
    that box, and a containing box keeps a hard no-collision result honest on
    paper. A new render-svg test (metric_bboxes_contain_the_drawn_outlines) proves
    the containment for every bundled glyph, so a future re-extraction cannot
    silently regress it.
  - BRAVURA_VERSION is 1.38.0 -> the literal font version: name-table ID 5 reads
    "Version 1.392" and head.fontRevision ~= 1.392 (a single decimal), recorded
    verbatim as SemVer { major: 1, minor: 392, patch: 0 } so the identifier
    round-trips to the font's own string. The canonical mapping rule is documented
    on BRAVURA_VERSION; DECISIONS.md is updated to match.

The corrected metrics surfaced a coupled placement bug: the old barlineSingle
metric falsely centred the glyph (+/-2048), so the bottom-origin Bravura barline
(which runs 0..4 staff spaces UP from its origin) was anchored at staff-centre and
floated above the midline. It is now anchored at the staff bottom (yo), so a
barline connects the bottom and top staff lines.

Goldens regenerated (stub + engrave) for the corrected geometry. Full gate green:
build, fmt, clippy, 581 tests, conformance scale 1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:45:33 -04:00
Levi Neuwirth af297881e2 Agent I-3: criterion-6 round-trip and golden lock on the real Engraver
Criterion 6 (the Chapter 7 layout round-trip) and the render goldens previously
exercised only the verbatim StubSolver, so a regression in the real Engraver's
geometry could land unseen. I-3 drives both through the Engraver.

  - round_trip_with<S: ConstraintSolver> factors the solver-agnostic provenance
    contract out of round_trip (now a one-line stub wrapper): coverage, the
    complete Provenance surviving constrained -> resolved -> render, the source
    surjection, and no duplicate stable ids hold for *any* conformant solver. The
    Stub tier's verbatim-geometry clause is gated behind solver.tier() == Stub;
    every other tier re-spaces. The status gate accepts any renderable status
    (Solved / SolvedWithWarnings / PartialBudgetExhausted), not exactly Solved, so
    the helper matches its "arbitrary conformant solver" contract while still
    rejecting the diagnostic-only statuses that carry no authoritative layout.
  - criterion_six_round_trips_through_the_engravers_respacing (epiphany-engrave)
    runs the full graph -> logical -> constrained -> *engraved* -> render round
    trip over the criterion-6 hand-off fixtures -- ten_measure_single_staff (the
    measured fixture) and valid_score_rich (cross-cutting tuplet/tie/spanner),
    plus valid_score for breadth -- and asserts the whole provenance contract
    survives the Engraver's re-spacing. A non-vacuity check confirms the Engraver
    genuinely moved geometry, so provenance is preserved *through* a real geometry
    change -- the statement the verbatim stub can never make. This adds an
    epiphany-testkit dev-dep (no cycle: testkit does not depend on this crate).
  - The render-svg engraver acceptance test is upgraded from invariant-only to
    byte-locked: new .engrave.snapshot.txt / .engrave.svg goldens for both
    fixtures capture the Engraver's re-spaced output (e.g. ten_measure view_box
    width 82.26 vs the stub's 88.88, same glyph/stroke/class counts), so an
    Engraver geometry regression is caught at the byte level. A companion test
    asserts the engrave goldens genuinely differ from the stub goldens, catching
    the degeneracy where the Engraver echoes the stub (which would otherwise pass
    both golden checks independently).

Also corrects the epiphany-engrave package description, which still claimed it
reports SolverTier::Stub until it earns Minimal (it earned Minimal in I-2).

Full gate green: build, fmt, clippy, 580 tests, conformance scale 1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:58:31 -04:00
Levi Neuwirth 1c147534bf Agent I-2: Engraver evaluates hard constraints, earns the Minimal tier
The Engraver now evaluates the IR's declared hard constraints against the
resolved geometry and reports honestly, so it reports SolverTier::Minimal --
"hard constraints satisfied, no claim about optimality" (Chapter 9) -- instead
of the interface-only Stub.

  - evaluate_constraints checks each LayoutConstraint against the resolved glyph
    boxes: NoCollision (boxes do not overlap), Align (equal baseline y for the
    Horizontal axis, equal x for Vertical), PositionWithin (box inside the
    region). A hard SystemBreakAt/PageBreakAt is reported unsatisfied -- a
    single-system, single-page Minimal solve casts off nothing (a soft break
    imposes no obligation); an unverifiable Registered extension constraint is
    conservatively not claimed satisfied.
  - Honest report status: a malformed input (invalid structure or forged/unknown
    catalog) is InternalError; a valid problem whose hard constraints cannot all
    be satisfied is Unsatisfiable, naming the offenders in unsatisfied_constraints;
    all satisfied is Solved. satisfied_hard_constraints and
    budget_used.constraint_evaluations reflect real work (0 when evaluation is
    skipped on a malformed input).
  - Minimal makes no normalized-metric claim, so the metric vector stays the
    conservative all-worst "no claim" placeholder (the Quality Metric Catalog is
    Phase 3 / Standard). Still deferred to a later tier: the vertical spring pass
    (glyph y is the constrained natural staff layout, preserved verbatim) and
    casting-off.

Tests pin the tier, an empty (vacuously satisfied) constraint set, satisfied vs
violated NoCollision, hard-vs-soft breaks, and the skipped-evaluation count.
Full gate green: build, fmt, clippy, 578 tests, conformance scale 1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:11:25 -04:00
Levi Neuwirth 91c9f01bb5 Agent I Phase 2-3: engrave recognizable notation from Score to SVG
Turn the Score -> layout-IR -> SVG pipeline from placeholder glyphs into real
music notation, rendered through the stub solver and the real Engraver alike.

to_constrained (the spacing pass) now dispatches each layout object to the
notation primitive that represents it, on a column-based spring model:

  - Pitch -> notehead at its clef-relative staff position; chord pitches share
    one column slot. StaffInstance -> clef glyph. Staff -> five staff-line
    strokes (the bottom line its anchor, four synthesized). Pitched Event ->
    stem stroke; Measure -> barline glyph; rest Event -> rest glyph.
  - Phase 3 ornaments as synthesized glyphs: a spelling's full accidental stack
    left of its notehead, a key signature's clef-relative sharp/flat zigzag in
    the lead, and a measure's numerator/denominator time-signature digit pair.
  - A tied decomposition draws one notehead/stem/rest per component (offsets
    honored, not collapsed). Active clef and key resolve by time, not vector
    order. The lead area reserves clef + key-signature width.
  - Coverage/surjection preserved so the round-trip holds: each laid-out object
    is covered by exactly one exact-provenance primitive, and derived primitives
    (staff lines, components, accidentals, key/time glyphs) are synthesized from
    a laid-out source. Engraving-coverage gaps (missing spelling, unbundled
    glyph) are surfaced as ConstrainedLayoutIR diagnostics, not silently
    defaulted. A measure depends on the time signature it displays.

The Engraver re-spaces glyphs AND the strokes that track them through one
collision-aware coordinate map (per-slot left/right bearings, pairwise
advances), so stems / barlines / staff lines stay attached and a note's
accidental never overlaps the previous note. validate() now rejects empty
spring slots -- the contract that map relies on -- and to_constrained never
emits one (slots are realized by glyph occupancy).

Bundle the genuine Bravura outlines and metrics for time-signature digits 0-9
(regenerated from the SHA-pinned font via tools/extract_bravura_outlines.py),
replacing an inconsistent hand-written placeholder.

Regions tile left-to-right (no page casting-off yet). Goldens regenerated into
recognizable notation. Full gate green: build, fmt, clippy, 574 tests,
conformance scale 1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:46:33 -04:00
Levi Neuwirth 47a581a4de Agent I-0: real clef + key-signature data in the core graph
Prerequisite for the visible-slice engraving milestones (I-1+): give the
score graph the clef/key data the engrave pipeline needs for clef-relative
staff positioning and key-signature rendering, replacing the Chapter-7
TimeAnchor-only placeholders.

epiphany-core:
- ClefShape { G, F, C, Percussion } — each family's reference pitch (G4 / F3 /
  middle C4) pins the staff-position mapping.
- Clef { shape, line, octave_shift } — the SMuFL family, the staff line its
  reference pitch sits on (1 = bottom line), and an octave transposition; with
  treble/bass/alto/tenor constructors and a treble Default. Generalizes to any
  C-clef line and octave-transposing clefs.
- KeySignature — circle-of-fifths position, validated -7..=7: a private field
  behind KeySignature::new (Option) + fifths(), and a custom Codec that rejects
  an out-of-range count on decode (Reconstruct, as PowerOfTwo / Tempo do).
- ClefChange now carries clef: Clef; KeySignatureChange carries key: KeySignature.
- Codec (cstyle_enum_codec / struct_codec / the validating KeySignature impl),
  lib.rs re-exports, a round-trip test over every shape x line x octave and every
  fifths value, and an out-of-range decode-rejection regression test.

Zero blast radius: nothing constructed these with data (clef/key sequences are
always empty) and invariants only read .anchor, so existing Score bytes, hashes,
and goldens are unchanged. Fixtures get populated and the engrave pipeline reads
the data in I-1.

Gates: build/fmt/clippy -D warnings clean; cargo test --workspace green;
conformance_suite scale 1 passes. Stages only epiphany-core; the pre-existing
Agent-I working tree (engrave/layout-ir/render-svg + .gitignore) stays unstaged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 10:31:16 -04:00
Levi Neuwirth dfdbc625b1 Agent K M2e review follow-up: tighten catalog accuracy + container creates
Address the two-pass review of the M2e catalog expansion (ca07c28). All gates
green: cargo test --workspace 535/0, conformance_suite scale 1, fmt + clippy
-D warnings clean, catalog PDF rebuilt (no undefined refs).

Pass 1 (doc-vs-implementation accuracy):
- Undo semantics narrowed to the prototype minted-object model (the catalog's
  own UndoTransaction model + P11-C8): the new K0 sections' minting members
  (insert/create) keep tombstone-the-mint undo; the non-minting ops (modify /
  transpose / deletes / settings) now state they synthesize no inverse, rather
  than promising rich restore/reintroduction the reducer does not implement.
- Spanner migration corrected: Tie/Slur/Beam reconstruct self-containedly while
  a Spanner remains read-only/unmigratable until the v0 projection carries its
  TimeAnchors (a Phase-3/Pass-12 extension); the "joins in M2" claim is removed,
  in both the CreateCrossCutting section and the migration chapter.
- The reduce()/reduce_onto() agreement claim (DECISIONS + the staff_based_regions
  comment) narrowed to regions represented in reducer state: op-created/deleted
  regions agree, but reduce_onto additionally seeds base regions a base-free
  reduce() never sees.

Pass 2 (empty-container enforcement, made real and complete):
- create_region / create_staff_instance / create_voice reject (ContainerNotEmpty)
  a carried value bearing ANY typed child object — not just the structural
  hierarchy. A region: no staff instances, barline-alignment groups, or graphic
  objects; a staff instance: no voices or measures; a voice: no events. Each is a
  distinct TypedObjectId the reducer mints separately, so a carried child would
  otherwise materialize an unminted object into the graph (a graph/ledger
  faithfulness gap). ClefChange/KeySignatureChange/metric-grid carry no
  TypedObjectId and are values, so they are correctly not gated. The check reads
  the carried value only, so reduce() and reduce_onto() agree.
- Catalog §Structural Containers states the precondition as "no typed child
  object" with the per-container enumeration, matching enforcement exactly.

Coverage: new graph_reduction tests create_rejects_a_non_empty_carried_container
(hierarchy children) and create_rejects_carried_non_hierarchy_children (barline
group / graphic object / measure). DECISIONS M2c gains the create-emptiness bullet.

Stages only ops + spec; the unrelated Agent-I working tree is left untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 09:52:13 -04:00
Levi Neuwirth ca07c28c82 Agent K M2e: catalog expansion + DECISIONS for the M2 broad-K0 groups
The documentation milestone deferred through M2a–M2d. Documentation only —
no Rust changes; the d93baac code gates (cargo test --workspace 533/0,
conformance_suite scale 1) are unchanged.

operation_catalog (v0.1.0 -> v0.2.0):
- Chapter K0 gains full six-part schema sections for every M2-implemented op:
  ModifyEvent; Identified-Pitch Operations (insert/delete/modify, with the
  note<->rest equivalence stated normatively); Transpose; DeleteCrossCutting;
  ModifyCrossCutting; Structural Containers (region/staff-instance/voice
  set-union mint + empty-only delete); Score Settings (advisory metadata,
  structural metric grid with the staff-based + live-time-signature
  preconditions, advisory page break under the resolved-position LWW key).
- Chapter K1 cleanup: the implemented groups now cross-reference their K0
  sections rather than sit in "MUST reject"; the stale Phase-3 listing of
  SetMetadata / SetMetricGrid / page-break advisory (implemented in M2d) is
  removed, and the remaining slots are split to the genuinely-unimplemented
  finer metric ops (time signature / tempo segment) and non-break layout.
- Intro, conformance-profile, and version strings updated for the expansion.
- PDF rebuilt with xelatex (18 pp, no undefined refs); .xdv removed, only
  tex+pdf tracked.

epiphany-ops/DECISIONS.md: new M2c (Group 3 — empty-only structural-container
delete; live-child indices; staff-extent maintenance) and M2d (Group 4 — the
per-op disciplines as review-hardened in d93baac: advisory metadata, metric
grid with both preconditions, resolved-position break key) entries, and a note
that 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 reduction-determinism / convergence gates.

The unrelated Agent-I working tree is left untouched; this commit stages only
spec/ + ops DECISIONS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:21:32 -04:00
Levi Neuwirth d93baac3ba Agent K M2d review follow-up: harden the Group-4 score-settings ops
Address the five-finding review of M2d (1e4ab24) plus the two-finding
follow-up review; all fixes are code/test/doc only, no spec change needed
(the catalog/core-spec already classify metadata as advisory LWW).

- SetMetadata is now a true advisory LWW: it silently last-writer-wins in
  canonical order and records no conflict, matching operation_catalog
  §set-user-system-break. Dropped the StructuralFieldCollision, the
  `last_metadata` working slot, and the `env` parameter; rewrote the
  conflict test as `concurrent_differing_set_metadata_is_advisory_lww`
  (no conflict, state stays clean, permutation-independent resolution).
- SetMetricGrid / SetUserPageBreak / SetUserSystemBreak share a new
  `layout_region_slot` precondition backed by a `staff_based_regions`
  index: the target must be live and staff-based (FreeGraphic regions have
  neither a metric-grid nor a break slot). The index is read from base-free
  state, so reduce() and reduce_onto() reach the same verdict for missing,
  tombstoned, and FreeGraphic regions.
- SetMetricGrid now rejects a grid whose meter_sequence names an undeclared
  time signature, rather than installing an invariant-violating grid.
- User breaks materialize under the canonical LWW key: `apply_break_lww`
  drops any existing anchor resolving to the same position before adding,
  so the graph break list stays in lockstep with the resolved-position
  ledger map (shared `resolved_anchor_position`). Applied to page and
  system breaks alike.
- Coverage: SetMetadata/SetMetricGrid/CreateVoice/DeleteVoice added to the
  tag-distinctness test; layout_stub `gen_operation_kind_tag` extended to
  every normative tag; the MaterializedState decode test populates
  page_breaks; four direct regression tests pin each fixed bug; the stale
  SetMetadata/score_metadata doc comments now say advisory LWW.

Gates: build/fmt/clippy -D warnings clean; cargo test --workspace green
(533); conformance_suite scale 1 passes. Stages only core/ops/testkit; the
unrelated Agent-I working tree is left untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 21:45:51 -04:00
Levi Neuwirth 1e4ab24779 Agent K M2d (Group 4): score-settings operations
Fourth broad-K0 subsystem group — three value-typed LWW field-overwrite ops, one
per settings cluster (additive: OperationKind variants 21-23; framework frozen):

- SetMetadata { metadata: ScoreMetadata } — overwrite the score-singleton
  metadata (title/composer/copyright); concurrent differing ⇒
  StructuralFieldCollision.
- SetMetricGrid { region, grid: Option<MetricGrid> } — overwrite a region's
  default metric grid (LWW keyed by region; concurrent differing ⇒ collision).
- SetUserPageBreak { region, anchor, present } — the page-break sibling of
  SetUserSystemBreak: a canonical LWW advisory.

- core: expose ScoreMetadata + MetricGrid via CanonicalValue (no new byte layout
  — they already have whole-score Codec impls).
- SetMetadata / SetMetricGrid mirror the modify ops: the resolved value lives in
  the graph (reduce_onto), with new last_metadata / last_metric_grid LWW working
  state for concurrent-differing detection; MaterializedState records only the
  effect and conflict.
- SetUserPageBreak mirrors SetUserSystemBreak's canonical advisory: a new
  MaterializedState.page_breaks map (encode + decode added, parallel to breaks),
  plus the graph's region user_page_breaks.

Migration: v1-native (no lossy v0 predecessor) -> project/migrate by identity;
the round-trip identity test now covers all four M2 groups.

Coverage:
- testkit operation_payload + ops fuzz gen_payload emit the three kinds, so the
  convergence / determinism / migration-equivalence and MaterializedState
  decode-roundtrip gates exercise them (incl. page_breaks) at scale.
- reduce_onto graph tests: the three settings materialize in graph and ledger
  (metadata title, region default metric grid, region user page break +
  MaterializedState.page_breaks), invariant-clean; plus a concurrent-differing
  SetMetadata conflict test.

Gates: build/fmt/clippy -D warnings clean; cargo test --workspace green (519);
conformance_suite scale 1 passes. Catalog sections + DECISIONS for the M2 groups
land in M2e per the staged plan. The unrelated Agent-I working tree is left
uncommitted; this commit stages only core/ops/testkit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 19:26:31 -04:00
Levi Neuwirth 7a94814ba3 Cross-seam review fixes: respell→pre-pass visibility, profile enforcement, canonical fingerprint, catalog reconciliation
Addresses four findings spanning the H (pre-pass) and K (reduction) seams plus
the Operation Catalog.

1. [High] A reduced RespellPitch is now visible to the pre-pass. The reducer
   stored overrides only in MaterializedState.spellings, but Agent H's
   derive_annotations resolves authored spellings from score.spelling_attachments
   — so a real respelling accepted by reduce_onto was lost before annotation
   derivation, violating manual-override precedence. respell_pitch now upserts a
   user-chosen explicit SpellingAttachment into the materialized graph
   (materialize_respell / graph_respell_pitch); DeleteIdentifiedPitch drops that
   attachment (graph_delete_pitch) so none dangles (it does NOT tombstone the
   pitch — the event survives a pitch delete and a later ModifyEvent may reuse
   the id, which would make it both live and tombstoned). New testkit gate
   assert_reduced_respell_is_honored reduces a real RespellPitch and proves
   derive_annotations honors it as Authored(UserChosen); wired into run_all.

2. [Medium] PrePassProfile algorithm ids are now enforced, not just recorded.
   derive_annotations ran the default logic and labeled the result with the
   requested algorithm. It now runs each pre-pass only when its requested id is
   the implemented "default"; an unknown/future id yields no annotations for that
   pre-pass (the requested id stays in the result profile), so a future algorithm
   can no longer silently alias the default in a derivation cache. Test:
   unknown_algorithm_ids_are_not_honored.

3. [Medium/Low] The determinism gate now fingerprints canonical bytes, not Debug.
   DerivedAnnotations gains canonical_fingerprint(): embedded graph values
   (PitchSpelling, DecompositionAttachment, SpellingSourceKind — the latter two
   added to the CanonicalValue surface) use their ratified bytes; counts/ids are
   little-endian, length-framed. The pre-pass harness fingerprints with it. A
   discrimination check confirms it is not a degenerate constant.

4. [Low] operation_catalog.tex K1 chapter reconciled with the implemented M2
   work: the now-dispatched ops (event/pitch leaf-field, cross-cutting CRUD,
   structural container CRUD) are listed as implemented-since-M2 (available under
   the Phase-2 profile), and the "MUST reject" scope is narrowed to the genuinely
   deferred slots (create score/canvas/staff, set metadata, metric-grid/time-sig/
   tempo, layout/page-break). PDF rebuilt clean (0 undefined refs).

Gates: build/fmt/clippy -D warnings clean; cargo test --workspace green (criterion
1 + the pre-pass and convergence gates); conformance scale 1 passes. The unrelated
Agent-I working tree is left uncommitted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 18:52:34 -04:00
Levi Neuwirth a207077cd7 Agent K M2c (Group 3): structural container CRUD operations
Third broad-K0 subsystem group — six new value-typed ops for the score-graph
containers, reusing M1's disciplines (additive: OperationKind variants 15-20,
new apply arms + reduction methods; framework frozen). Per the project lead's
call, container deletes are EMPTY-ONLY (no cascade): a precondition NoOp unless
the container has no live children, so the caller deletes contents first.

- CreateRegion / DeleteRegion, CreateStaffInstance / DeleteStaffInstance,
  CreateVoice / DeleteVoice. Creates are value-typed mints of an empty container
  (set-union creation); deletes are delete-wins tombstones gated on emptiness.
- core: expose Region / StaffInstance / Voice via CanonicalValue (no new byte
  layout — they already have whole-score Codec impls), with round-trip coverage.
- New PreconditionFailureReason::ContainerNotEmpty (additive discriminant 10;
  encode + decode), reported when an empty-only delete hits a non-empty container.

Reduction (reduce.rs):
- Two child-existence indices, region_instances and instance_voices, drive the
  emptiness checks (a voice's events are read from voice_occupancy), so the
  ledger projection and the graph agree on every delete result. Populated by
  seed_from_graph, the create ops, and implicit voice creation in insert_event.
- CreateStaffInstance / DeleteStaffInstance maintain the region's staff_extent so
  it lists exactly the manifested staves (Chapter 5 RegionExtents); valuegen's
  fresh region uses a far-future wall-clock extent so it never overlaps an
  existing region in both time and staff.

Migration: v1-native (no lossy v0 predecessor) -> project/migrate by identity;
group1_and_group2_kinds_round_trip_by_identity extended to cover Group 3.

Coverage:
- testkit operation_payload + ops fuzz gen_payload emit the six kinds, so the
  convergence / determinism / migration-equivalence gates exercise the
  bookkeeping projection at scale.
- A reduce_onto graph test materializes a region -> staff instance -> voice
  subtree (invariant-clean), asserts the empty-only delete refuses a non-empty
  container with ContainerNotEmpty, and verifies an ordered teardown clears the
  subtree from both graph and ledger.

Gates: build/fmt/clippy -D warnings clean; cargo test --workspace green (criterion
1 green with the new container objects in the corpus); conformance_suite scale 1
passes. Catalog sections + DECISIONS for these ops land in M2e per the staged
plan. The unrelated Agent-I working tree is left uncommitted; this commit stages
only core/ops/testkit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 18:10:15 -04:00
Levi Neuwirth 0f1b209e54 Agent K: DeleteEvent re-anchoring — make the graph follow the ledger
Standalone follow-up to the M2b review: close the pre-existing reanchor /
graph-delete divergence, which unlocks at-scale criterion-1 coverage for the
Group-2 cross-cutting CRUD ops.

The divergence: a DeleteEvent tombstoning a slur/spanner endpoint re-anchored the
structure in the bookkeeping ledger (object stays Live) but materialize_graph_delete
removed it from the graph unconditionally — so the object was Live in
MaterializedState yet gone from the Score. Latent only because graph_edit_session
never created cross-cutting structures.

Fix (graph-materialization only; bookkeeping/convergence unchanged):
- materialize_graph_delete now mirrors reanchor_for_tombstone for slurs and
  spanners: an endpoint-deleted structure re-anchors onto its surviving endpoint
  (stays in the graph) and is removed only when no endpoint survives. A
  two-endpoint structure collapses onto the survivor (degenerate (B,B), but
  reference-clean — the cross-cutting invariant requires only live endpoints;
  proximity-aware target deferred, P11-C5). Ties (cascade) and beams
  (truncate-while->=2) were already consistent and are unchanged. This also fixes
  a latent dangling-spanner bug (spanners weren't handled on event delete at all).
- seed_from_graph records each base-score spanner's event-anchored endpoints in
  `structures`, so a seeded spanner re-anchors through the same rule as a created
  one.

Coverage:
- New reduce_onto tests: deleting one slur endpoint re-anchors in both graph and
  ledger (slur Live + collapsed onto survivor); deleting both cascades in both
  (slur Tombstoned + removed).
- graph_edit_session now creates slurs over replica-0 events and emits
  DeleteCrossCutting / ModifyCrossCutting, so criterion 1 (reduce_onto +
  check_invariants, across delivery permutations) exercises cross-cutting CRUD and
  slur re-anchoring at scale.

Docs: DECISIONS.md records the graph-follows-ledger re-anchoring decision and the
degenerate-collapse / P11-C5 deferral.

Gates: build/fmt/clippy -D warnings clean; cargo test --workspace green (ops
graph_reduction 20; criterion 1 green with cross-cutting wired in); conformance
scale 1 passes. The unrelated Agent-I working tree is left uncommitted; this
commit stages only ops/testkit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:23:11 -04:00
Levi Neuwirth f62f5d4276 Agent K M2b (Group 2): cross-cutting CRUD operations
Second broad-K0 subsystem group — two new value-typed ops reusing M1's proven
disciplines (additive: OperationKind variants 13-14, new apply arms + reduction
methods; framework frozen):

- DeleteCrossCutting { structure: TypedObjectId } — delete-wins tombstone of a
  cross-cutting structure (idempotent concurrent deletes; guarded to the
  Tie/Slur/Beam/Spanner kinds). Drops the transient endpoint/LWW indices so a
  later event-tombstone re-anchoring pass never re-processes the deleted
  structure.
- ModifyCrossCutting { structure: CrossCuttingValue } — LWW field-overwrite by
  the structure's id; concurrent differing => StructuralFieldCollision. Mirrors
  modify_event (resolved value lives in the graph, not MaterializedState);
  re-derives endpoints from the new value, and mirrors CreateCrossCutting's
  beam->=2 / endpoints-live preconditions.

Graph materialization (reduce_onto): graph_delete_cross_cutting removes the
structure by id; graph_modify_cross_cutting replaces it in place by id, across
all four kinds (Slur/Tie/Beam/Spanner). New last_cross_cutting_modify LWW map,
synced through WorkingSnapshot/snapshot/restore.

Migration: v1-native (no lossy v0 predecessor) -> project/migrate by identity;
group1_and_group2_kinds_round_trip_by_identity extended to cover them.

Coverage:
- testkit operation_payload + ops fuzz gen_payload now emit both kinds, so the
  convergence / determinism / migration-equivalence gates exercise the
  bookkeeping projection at scale.
- Targeted reduce_onto graph tests (tests/graph_reduction.rs) cover every kind
  arm of graph_delete/graph_modify_cross_cutting (Slur/Tie/Beam/Spanner) plus
  the beam->=2 reject branch of modify, with check_invariants; plus two
  bookkeeping unit tests (delete tombstones; concurrent differing modify
  conflicts).

Not wired into graph_edit_session (criterion 1): doing so requires creating
structures in the session, which exposes a pre-existing M1 reanchor/graph-delete
divergence (a slur whose endpoint event is deleted is re-anchored in bookkeeping
but removed from the graph). That is a separate DeleteEvent fix; the targeted
reduce_onto tests above give the M2b graph paths guaranteed coverage meanwhile.

Gates: build/fmt/clippy -D warnings clean; cargo test --workspace green (ops lib
53, ops graph_reduction 18); conformance_suite scale 1 passes. Catalog sections +
DECISIONS for these ops land in M2e per the staged plan. The unrelated Agent-I
working tree is left uncommitted; this commit stages only ops/testkit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:31:24 -04:00