Editor T2 W1: the selection set with an anchor

Option<Selection> becomes a paint-ordered set with an anchor member.
click replaces, toggle_at adds/removes (anchor = last added), select_within
takes the hit-test within(rect) in paint order deduped by score object —
a ledgered note's synthesized ledger strokes collapse into its notehead's
member, the representative id preferring the non-synthesized occurrence so
relayout cannot wrongly drop it. Single-target intents retarget to the
anchor, byte-identical at N=1. delete_selection batches atomically via one
transaction (a refused member rolls back the whole batch);
alter_selection instead mints ONE TransposeIntervalOp naming every target —
the format's native atomic multi-target primitive
(req:opcat:transpose-interval-atomic) — preserving N=1 op-stream
compatibility outright. Anchor fallback across relayout: next surviving
member after the anchor's original position, wrapping. Four mutations
killed and coordinator re-verified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-23 17:08:38 -04:00
parent 35bba767d2
commit d27ea23ae1
2 changed files with 993 additions and 75 deletions

View File

@ -20,3 +20,113 @@ the manifest is frozen at major 0, so the record rides the blob layer or a
new chunk kind — next bundle-format tranche); new chunk kind — next bundle-format tranche);
`extensions_requiring_tombstone()` remains the producer awaiting that `extensions_requiring_tombstone()` remains the producer awaiting that
consumer. consumer.
## Selection v2 — the selection set with an anchor (2026-07-23, T2-W1)
Dispatched under `spec/CONTRACT_EDITOR_T2_SELECTION.md` §W1. Replaces the
session's single `Option<Selection>` (`EditorSession::selection`, formerly
`lib.rs:304`) with a private `SelectionSet { members: Vec<Selection>, anchor:
Option<LayoutObjectId> }`. Public surface: `selections() -> &[Selection]`,
`anchor() -> Option<&Selection>`, `click`/`select` (replace-with-single,
unchanged), `toggle_at(point)` (add/remove one member), `select_within(rect:
BoundingBox)` (paint-ordered rubber-band set), `clear_selection()`; `selection()`
is kept as the anchor, copied — it fell out of `anchor()` directly, so it was
kept rather than removed (the GUI crate's call sites are out of this packet's
blast radius per the contract, and the compatibility read costs nothing).
**The set model.** Anchor identity is tracked by `LayoutObjectId`, not
duplicated as a `Selection` copy, so `anchor()`'s lookup in `members` can never
desync from a member's relayout-refreshed `source`. Two constructors populate
`members`: `replace_single` (click/select) and the paint-ordered path built by
`select_within` itself (see below) via `SelectionSet::replace_members`, which
trusts an already-ordered, already-deduped `Vec<Selection>`. `toggle_at` keeps
members in add order (not re-sorted into paint order) — the contract's "a
selection set in paint order" is select_within's contract specifically; nothing
in the spec requires toggling to re-derive true paint order for an arbitrary
mixed-source set, and doing so would need a public paint-order query
`HitTestMap` does not expose (its `paint_order()` ranking is private, by
design — only `hit`/`within` are public).
**Anchor-fallback rule (one rule, two call sites).** Both `toggle`-removing-the-
anchor and `SelectionSet::reresolve` (relayout) apply the same rule: the anchor
falls to the member now occupying its old position (i.e. the member that was
right after it), wrapping to the first member if it was last (or only), or to
`None` if the set emptied. `reresolve` computes this over the *surviving*
members' original positions, so a member that also dropped is skipped rather
than treated as "the next one". Tested directly (m3): dropping the anchor's own
member via a plain single delete (bypassing `delete_selection`, to isolate the
mechanism from the batch path) leaves the other selected member as both the
sole survivor and the new anchor.
**`select_within` and the ledger-line finding.** A hit-test region's `source`
(the score object) and `layout_object` (its layout-id anchor) are **not** the
same identity when the object manifests as more than one primitive: a note
needing ledger lines gets the ledger strokes *synthesized from* its `Pitch`
source (`Provenance::synthesized`, distinct `stable_id` per synthesis kind/key)
**and** its own notehead glyph (`Provenance::manifested`/`projected`, a
different `stable_id`) — same `source`, different `layout_object`s. Deduping a
`within(rect)` result by `layout_object` (the first design here) does **not**
merge them into one selected object; it must dedupe by `source`, and the
group's representative `layout_object` must prefer the **non-synthesized**
occurrence when one exists — tracking a ledger stroke's id would make the
member wrongly drop on a relayout that keeps the note but moves it back
on-staff (no ledger needed), even though the note itself is still live. This
grouping needs `HitRegion::synthesis`, so it lives in
`EditorSession::select_within` (which sees full `HitRegion`s), not inside
`SelectionSet` (which only ever sees bare `Selection` values). Verified with a
dedicated fixture (a note pushed to octave 8, forcing real ledger-line
strokes): one member, its `layout_object` equal to the notehead's own region,
not a ledger's.
**Single-target intents retarget to the anchor** (`transpose_selection`,
`alter_selection` on a one-member set, `move_selection_staff_step`,
`add_note_to_selection`, `insert_note_after_selection`,
`set_selection_duration`): each now reads `self.selection.anchor().copied()`
where it used to read the bare field. Behavior with one member is unchanged —
every pre-existing test in this file passed **without modification** once the
five call sites were mechanically updated (no test needed a behavioral change,
only the internal accessor changed).
**Batch `delete_selection`: `apply_transaction`, N=1 unwrapped.** Every member
maps to its own delete op (matching today's single-object mapping exactly:
`Pitch``DeleteIdentifiedPitch`, `Event``DeleteEvent` with the plain
`NotInTuplet` compensation — so a tuplet member is a genuine refusal-worthy
target, not a construction error). One member: `self.apply(kind)`, exactly
today's op stream. More than one: `self.apply_transaction(...)`, whose
reducer-level all-or-nothing rollback (`reduce_transaction_block`,
`is_member_failure`) is what atomicity actually rests on — verified (m2) that
*without* the transaction wrapper, a refused tuplet-member delete is merely a
silent, accepted **no-op** (not a rejection: `graph_delete_precondition`'s
`TupletCompensationInvalid` returns a clean `OperationEffect::NoOp`, which
registers as a conflict only *inside* `reduce_transaction_block`, never for a
standalone `apply`), so the *other* selected member's delete would have gone
through — the exact partial-batch bug the transaction wrapper exists to
prevent. This mirrors the crate's pre-existing "no transaction for one op"
idiom (`insert_note_at`, `set_selection_duration`: `if ops.len() == 1 { apply }
else { apply_transaction }`), now applied to selection-set size.
**Batch `alter_selection`: *not* wrapped in `apply_transaction`, by design —
the N=1 finding generalized.** `TransposeIntervalOp.targets` is already a
`CanonicalSet<PitchId>` (a target *set*, not a scalar target), and the reducer
is already atomic over it end to end
(`req:opcat:transpose-interval-atomic` — every target's new value and spelling
is resolved before any of them is written, and the whole operation refuses if
any target cannot transpose). So a selection's pitches ride **one** primitive
`TransposeInterval` naming every one of them, applied via plain `self.apply`,
at *every* set size — never `apply_transaction`. This is not a smaller version
of the same "avoid a gratuitous wrapper at N=1" reasoning `delete_selection`
needed; it is the discovery the contract's N=1 clause was fishing for, taken to
its conclusion: wrapping in `apply_transaction` would (a) add a
`DeclareTransaction` envelope this method has never emitted, breaking N=1
op-stream compatibility outright, and (b) at N>1, replicate — forever, in the
canonical log — a strictly more verbose encoding (one descriptor + N
single-target ops) than the wire format already provides for exactly this
case. Non-pitch members are silently ignored (not refused): "alter" has
nothing to say about a selected slur or rest, unlike delete. Verified: a
single-member set mints the identical op (`applied_operations().len() == 1`,
same `targets`/`interval`) as before this packet; a multi-pitch batch mints
exactly one `TransposeInterval` naming every selected pitch; a batch containing
one untransposable pitch (`AcousticRealization::AbsoluteHz`, pinned) leaves
*every* selected pitch — including the otherwise-transposable one — unmoved
(`graph_changed == false`), proving the reducer's own atomicity, not this
method's plumbing, is what's carrying the guarantee.

File diff suppressed because it is too large Load Diff