From a28d888c8e6128895e507917be6ec1a4c7b24115 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 27 Jun 2026 16:47:34 -0400 Subject: [PATCH] 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), 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 --- Cargo.lock | 9 + Cargo.toml | 4 + crates/epiphany-editor-core/Cargo.toml | 21 + crates/epiphany-editor-core/src/lib.rs | 480 ++++++++++++++++++ crates/epiphany-ops/src/lib.rs | 2 +- .../examples/conformance_suite.rs | 33 +- 6 files changed, 546 insertions(+), 3 deletions(-) create mode 100644 crates/epiphany-editor-core/Cargo.toml create mode 100644 crates/epiphany-editor-core/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 501eb8f..4608d7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -93,6 +93,15 @@ dependencies = [ "blake3", ] +[[package]] +name = "epiphany-editor-core" +version = "0.0.0" +dependencies = [ + "epiphany-core", + "epiphany-layout-ir", + "epiphany-ops", +] + [[package]] name = "epiphany-engrave" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index 7d3b8c7..57f6f2a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/epiphany-layout-ir", "crates/epiphany-engrave", "crates/epiphany-render-svg", + "crates/epiphany-editor-core", "crates/epiphany-testkit", ] @@ -55,6 +56,9 @@ epiphany-layout-ir = { path = "crates/epiphany-layout-ir" } # for the demo binary's `--solver=real` path. epiphany-engrave = { path = "crates/epiphany-engrave" } epiphany-render-svg = { path = "crates/epiphany-render-svg" } +# A headless editor core over the score graph (selection, hit-test query, operation +# minting, apply/re-render, selection preservation) — the API a GUI calls. +epiphany-editor-core = { path = "crates/epiphany-editor-core" } # Agent F's testkit. Declared here so Agent I's render crate can use its score # fixtures (`ten_measure_single_staff`) as a dev-dependency for the demo binary # and acceptance tests. diff --git a/crates/epiphany-editor-core/Cargo.toml b/crates/epiphany-editor-core/Cargo.toml new file mode 100644 index 0000000..890b2ca --- /dev/null +++ b/crates/epiphany-editor-core/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "epiphany-editor-core" +version = "0.0.0" +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +repository.workspace = true +description = "A headless editor core over an Epiphany score: selection state, render + hit-test queries, primitive-operation minting, apply/re-render, and selection preservation across relayouts. The solver-agnostic API a GUI drives — no UI, no rendering backend of its own (it produces a RenderIR)." + +[dependencies] +# The document graph and its ids (Score, PitchId, OperationId, …). +epiphany-core.workspace = true +# Operations and the reduction driver (mint an envelope, reduce_onto a score). +epiphany-ops.workspace = true +# The layout pipeline, the ConstraintSolver interface (held as a trait object so +# the editor is solver-agnostic), the RenderIR, and the hit-test contract. +epiphany-layout-ir.workspace = true + +[dev-dependencies] +# Tests drive the editor from real score fixtures. +epiphany-core.workspace = true diff --git a/crates/epiphany-editor-core/src/lib.rs b/crates/epiphany-editor-core/src/lib.rs new file mode 100644 index 0000000..4ddcba6 --- /dev/null +++ b/crates/epiphany-editor-core/src/lib.rs @@ -0,0 +1,480 @@ +#![forbid(unsafe_code)] +//! # epiphany-editor-core +//! +//! A **headless editor core** over an Epiphany score: the API a GUI calls to drive +//! the editing loop, with no UI and no rendering backend of its own. It packages +//! the proven vertical slice — hit-test → score object → operation → reduce → +//! re-layout → re-render → re-resolve selection — behind [`EditorSession`]. +//! +//! It owns: +//! +//! * **selection state** ([`Selection`]) — the score object and the stable layout +//! id that anchors it across relayouts; +//! * the current **render and hit-test query** ([`EditorSession::render`], +//! [`EditorSession::hit_test`], [`EditorSession::click`]); +//! * **operation minting** ([`EditorSession::apply`] and intents like +//! [`EditorSession::transpose_selection`]) — the caller supplies an +//! [`OperationKind`] or an intent, and the session assembles the +//! [`OperationEnvelope`] (id, author, stamp, causal context) so a GUI never +//! hand-rolls the envelope bookkeeping; +//! * **apply / re-render** — reduce the operation onto the score, re-render, and +//! refuse a diagnostic-only (non-renderable) layout, leaving the document +//! unchanged if the edit would not render; +//! * **selection preservation** — re-resolve the selection against the new layout, +//! keeping it when its layout object survives (the cursor does not jump off the +//! edited object) and clearing it when the object is gone. +//! +//! The session is **solver-agnostic**: it holds a `Box`, so a +//! GUI plugs in the real `Engraver`, the stub, or any conformant solver. It +//! produces a [`RenderIR`]; turning that into pixels is the renderer's job. + +use std::fmt; + +use epiphany_core::{OperationId, ReplicaId, Score, TypedObjectId, WallClockTime}; +use epiphany_layout_ir::{ + to_constrained, to_logical, to_render, ConstraintSolver, HitTestMap, LayoutObjectId, Point, + RenderIR, SolverConfig, +}; +use epiphany_ops::{ + AcceptOutcome, AuthorId, CausalContext, HybridLogicalClock, OperationEnvelope, OperationKind, + OperationPayload, OperationSet, OperationStamp, TransposeOp, +}; + +/// The current selection: the score-graph object to act on, plus the stable layout +/// object id that anchors it across relayouts (so it survives an edit's re-render). +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub struct Selection { + /// The score-graph object selected (what an operation targets). + pub source: TypedObjectId, + /// The layout object the selection is anchored on — content-independent, so it + /// survives a relayout of an unchanged source. + pub layout_object: LayoutObjectId, +} + +/// What an [`EditorSession::apply`] did. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub struct EditOutcome { + /// The operation changed the score graph. + pub graph_changed: bool, + /// The selection survived the relayout (its layout object still exists). + pub selection_preserved: bool, +} + +/// An editing error. None of these mutate the session. +#[derive(Clone, PartialEq, Eq, Debug)] +pub enum EditorError { + /// The solver returned a diagnostic-only (non-renderable) layout — the edit is + /// rejected and the document is left unchanged. + NotRenderable, + /// The minted operation was not accepted by the reducer (it was not + /// well-formed — e.g. minted under the reserved [`epiphany_core::ReplicaId::SYSTEM_DERIVED`] + /// identity). The edit is dropped rather than silently no-op'd. + RejectedOperation, + /// An intent needed a selection but none is set. + NoSelection, + /// The selection is not the kind the intent requires (e.g. a transpose needs a + /// pitch selection). + WrongSelection { + /// The kind of object the intent expected. + expected: &'static str, + }, +} + +impl fmt::Display for EditorError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + EditorError::NotRenderable => { + f.write_str("the resulting layout is diagnostic-only and cannot be rendered") + } + EditorError::RejectedOperation => { + f.write_str("the minted operation was not well-formed and was rejected") + } + EditorError::NoSelection => f.write_str("no selection"), + EditorError::WrongSelection { expected } => { + write!(f, "the selection is not a {expected}") + } + } + } +} + +impl std::error::Error for EditorError {} + +/// A headless editor session over a score. A GUI opens one, queries its render and +/// hit-test map to draw and to resolve clicks, and drives edits through it. +pub struct EditorSession { + score: Score, + solver: Box, + render: RenderIR, + map: HitTestMap, + selection: Option, + // Operation-minting context. A real client supplies its own replica/author; + // the counter and clock advance per minted operation so each gets a fresh, + // ordered id. + replica: ReplicaId, + author: AuthorId, + op_counter: u64, +} + +impl EditorSession { + /// Opens a session on `score` with `solver`, rendering immediately. Errors with + /// [`EditorError::NotRenderable`] if the initial layout is diagnostic-only. + pub fn open(score: Score, solver: Box) -> Result { + let (render, map) = + render_score(&score, solver.as_ref()).ok_or(EditorError::NotRenderable)?; + Ok(EditorSession { + score, + solver, + render, + map, + selection: None, + replica: ReplicaId(1), + author: AuthorId(0), + op_counter: 0, + }) + } + + /// Overrides the replica/author the session mints operations under (a GUI sets + /// these to the local editing identity). Defaults to `ReplicaId(1)` / author 0. + pub fn with_identity(mut self, replica: ReplicaId, author: AuthorId) -> Self { + self.replica = replica; + self.author = author; + self + } + + /// The current document. + pub fn score(&self) -> &Score { + &self.score + } + + /// The current render, for the GUI to draw. + pub fn render(&self) -> &RenderIR { + &self.render + } + + /// The current hit-test map, for the GUI to resolve clicks and drags. + pub fn hit_test(&self) -> &HitTestMap { + &self.map + } + + /// The current selection, if any. + pub fn selection(&self) -> Option { + self.selection + } + + /// Resolves a click at a world `point`: selects the **topmost** hit there (what + /// a GUI selects), or clears the selection if the point hits nothing. Returns + /// the new selection. + pub fn click(&mut self, point: Point) -> Option { + self.selection = self.map.hit(point).into_iter().next().map(|r| Selection { + source: r.source, + layout_object: r.layout_object, + }); + self.selection + } + + /// Selects a layout object by id (a programmatic / restored selection), if it is + /// present in the current layout. Returns the new selection. + pub fn select(&mut self, layout_object: LayoutObjectId) -> Option { + self.selection = self + .map + .regions + .iter() + .find(|r| r.layout_object == layout_object) + .map(|r| Selection { + source: r.source, + layout_object, + }); + self.selection + } + + /// Clears the selection. + pub fn clear_selection(&mut self) { + self.selection = None; + } + + /// Builds an [`OperationEnvelope`] for a primitive `kind` under the session's + /// identity at operation `counter` — the bookkeeping (id, author, stamp, causal + /// context) a GUI would otherwise assemble by hand. Pure: it does not advance + /// the session's counter, so a failed [`Self::apply`] consumes no id. + fn envelope_for(&self, counter: u64, kind: OperationKind) -> OperationEnvelope { + let id = OperationId::new(self.replica, counter); + OperationEnvelope { + id, + author: self.author, + stamp: OperationStamp::new( + HybridLogicalClock::new(WallClockTime(counter as i64), 0), + id, + ), + causal_context: CausalContext::new(), + transaction: None, + payload: OperationPayload::Primitive(kind), + } + } + + /// Applies a primitive operation: mints an envelope, reduces it onto the score, + /// re-renders, and re-resolves the selection. **Atomic**: on any error — a + /// rejected (not well-formed) operation, or a diagnostic-only layout — the + /// session is left entirely unchanged, including its operation counter. + pub fn apply(&mut self, kind: OperationKind) -> Result { + let counter = self.op_counter + 1; + let envelope = self.envelope_for(counter, kind); + + // A minted operation that the reducer will not accept (e.g. a reserved + // replica identity) must not silently no-op the edit. + let mut set = OperationSet::new(); + if !matches!(set.accept(envelope), AcceptOutcome::Accepted) { + return Err(EditorError::RejectedOperation); + } + let edited = set.reduce_onto(&self.score).score; + let graph_changed = edited != self.score; + + // Refuse a diagnostic-only layout, still before committing anything. + let (render, map) = + render_score(&edited, self.solver.as_ref()).ok_or(EditorError::NotRenderable)?; + + // Commit (the only mutation point — so an error above leaves all state, + // counter included, untouched). + self.op_counter = counter; + self.score = edited; + self.render = render; + self.map = map; + + let selection_preserved = self.reresolve_selection(); + Ok(EditOutcome { + graph_changed, + selection_preserved, + }) + } + + /// Transposes the selected pitch by `chromatic_steps` (a `+1` is a sharpen). + /// Errors if nothing — or a non-pitch — is selected. + pub fn transpose_selection( + &mut self, + chromatic_steps: i32, + ) -> Result { + let selection = self.selection.ok_or(EditorError::NoSelection)?; + let TypedObjectId::Pitch(pitch) = selection.source else { + return Err(EditorError::WrongSelection { expected: "pitch" }); + }; + self.apply(OperationKind::Transpose(TransposeOp { + targets: vec![pitch], + chromatic_steps, + })) + } + + /// Re-resolves the current selection against the current layout: keeps it + /// (refreshing its source) when its layout object survives, clears it otherwise. + /// Returns whether it survived. + fn reresolve_selection(&mut self) -> bool { + let Some(selection) = self.selection else { + return false; + }; + match self + .map + .regions + .iter() + .find(|r| r.layout_object == selection.layout_object) + { + Some(region) => { + self.selection = Some(Selection { + source: region.source, + layout_object: selection.layout_object, + }); + true + } + None => { + self.selection = None; + false + } + } + } +} + +/// Renders a score with `solver` to its `RenderIR` + hit-test map, or `None` if the +/// solver's report is diagnostic-only (not renderable). +fn render_score(score: &Score, solver: &dyn ConstraintSolver) -> Option<(RenderIR, HitTestMap)> { + let report = solver.solve( + &to_constrained(&to_logical(score)), + &SolverConfig::default(), + ); + if !report.status.is_renderable() { + return None; + } + let render = to_render(&report.layout); + let map = render.hit_test_map(); + Some((render, map)) +} + +#[cfg(test)] +mod tests { + use super::*; + use epiphany_core::generators::valid_score_rich; + use epiphany_layout_ir::{ + ConstrainedLayoutIR, HitShape, InvalidationSet, SolveReport, SolveStatus, SolverState, + SolverTier, SolverVersion, StubSolver, + }; + + fn open_rich(seed: u64) -> EditorSession { + EditorSession::open(valid_score_rich(seed), Box::new(StubSolver)).expect("rich renders") + } + + /// Clicks the centre of the first notehead (a pitch-backed glyph) and returns the + /// resulting selection. + fn click_a_notehead(session: &mut EditorSession) -> Selection { + let click = session + .hit_test() + .regions + .iter() + .filter(|r| r.primitive.is_glyph() && matches!(r.source, TypedObjectId::Pitch(_))) + .find_map(|r| match r.shape { + HitShape::Box(b) => Some(Point::new( + (b.left.0 + b.right.0) / 2.0, + (b.bottom.0 + b.top.0) / 2.0, + )), + HitShape::Segment { .. } => None, + }) + .expect("the rich fixture renders a notehead"); + session.click(click).expect("the click selects a glyph") + } + + #[test] + fn open_renders_and_starts_unselected() { + let session = open_rich(0x5EED); + assert!(!session.render().primitives.is_empty(), "the score renders"); + assert!(!session.hit_test().regions.is_empty(), "with hit regions"); + assert_eq!(session.selection(), None, "nothing is selected at open"); + } + + #[test] + fn a_click_selects_the_topmost_hit() { + let mut session = open_rich(0x5EED); + let selection = click_a_notehead(&mut session); + assert!(matches!(selection.source, TypedObjectId::Pitch(_))); + assert_eq!(session.selection(), Some(selection)); + // Re-selecting that layout object by id resolves to the same thing. + assert_eq!(session.select(selection.layout_object), Some(selection)); + // A click on empty space clears the selection. + session.click(Point::new(-1.0e6, -1.0e6)); + assert_eq!(session.selection(), None); + } + + #[test] + fn the_full_editing_loop_runs_through_the_session() { + let mut session = open_rich(0x5EED); + let before = session.render().clone(); + + // Click a notehead, then sharpen the selected pitch — minting the operation + // is the session's job, not the caller's. + let selection = click_a_notehead(&mut session); + let outcome = session.transpose_selection(1).expect("the sharpen applies"); + + assert!(outcome.graph_changed, "the edit reduced onto the graph"); + assert!( + outcome.selection_preserved, + "the selection survived the relayout" + ); + assert_eq!( + session.selection().map(|s| s.layout_object), + Some(selection.layout_object), + "the selection still anchors the same layout object" + ); + assert_ne!(&before, session.render(), "the re-render shows the edit"); + } + + #[test] + fn transpose_requires_a_pitch_selection() { + let mut session = open_rich(0x5EED); + // Nothing selected. + assert_eq!( + session.transpose_selection(1), + Err(EditorError::NoSelection) + ); + // Select a non-pitch object (a region, present in any score) and try again. + let non_pitch = session + .hit_test() + .regions + .iter() + .find(|r| !matches!(r.source, TypedObjectId::Pitch(_))) + .map(|r| r.layout_object); + if let Some(id) = non_pitch { + session.select(id); + assert_eq!( + session.transpose_selection(1), + Err(EditorError::WrongSelection { expected: "pitch" }) + ); + } + } + + #[test] + fn two_edits_in_a_row_both_apply() { + // Distinct minted op ids, so the second edit is not deduplicated. + let mut session = open_rich(0x5EED); + click_a_notehead(&mut session); + assert!(session.transpose_selection(1).unwrap().graph_changed); + let mid = session.score().clone(); + assert!(session.transpose_selection(1).unwrap().graph_changed); + assert_ne!( + &mid, + session.score(), + "the second edit also changed the graph" + ); + } + + /// A solver whose report is diagnostic-only (`Unsatisfiable`) yet carries a + /// non-empty layout — which the editor must refuse. + struct UnsatisfiableSolver; + + impl ConstraintSolver for UnsatisfiableSolver { + fn tier(&self) -> SolverTier { + SolverTier::Minimal + } + fn version(&self) -> SolverVersion { + SolverVersion(99) + } + fn solve(&self, input: &ConstrainedLayoutIR, config: &SolverConfig) -> SolveReport { + let mut report = StubSolver.solve(input, config); + report.status = SolveStatus::Unsatisfiable; + report.satisfied_hard_constraints = false; + report + } + fn solve_incremental( + &self, + input: &ConstrainedLayoutIR, + _prior: &SolverState, + _invalidations: &InvalidationSet, + config: &SolverConfig, + ) -> SolveReport { + self.solve(input, config) + } + } + + #[test] + fn a_diagnostic_only_layout_is_refused_at_open() { + let opened = EditorSession::open(valid_score_rich(0x5EED), Box::new(UnsatisfiableSolver)); + assert_eq!(opened.err(), Some(EditorError::NotRenderable)); + } + + #[test] + fn a_rejected_operation_is_an_error_not_a_silent_no_op() { + use epiphany_core::ReplicaId; + // Minting under the reserved replica makes every operation ill-formed. + let mut session = EditorSession::open(valid_score_rich(0x5EED), Box::new(StubSolver)) + .unwrap() + .with_identity(ReplicaId::SYSTEM_DERIVED, AuthorId(0)); + click_a_notehead(&mut session); + let before = session.score().clone(); + + // The edit is rejected (not a silent Ok/graph_changed=false), and nothing — + // not even the operation counter — mutates, so a later valid edit still + // works. + assert_eq!( + session.transpose_selection(1), + Err(EditorError::RejectedOperation) + ); + assert_eq!( + &before, + session.score(), + "a rejected edit leaves the document untouched" + ); + } +} diff --git a/crates/epiphany-ops/src/lib.rs b/crates/epiphany-ops/src/lib.rs index 3c96e9e..754bae7 100644 --- a/crates/epiphany-ops/src/lib.rs +++ b/crates/epiphany-ops/src/lib.rs @@ -111,7 +111,7 @@ pub use effect::{ }; pub use envelope::{well_formed, EnvelopeHash, OperationEnvelope, WellFormednessError}; pub use migrate::{migrate_v0_envelope, project_v1_to_v0, MigrationError}; -pub use opset::OperationSet; +pub use opset::{AcceptOutcome, OperationSet}; pub use payload::{ ChangeRegionTimeModelOp, CreateCrossCuttingOp, CreateRegionOp, CreateStaffInstanceOp, CreateVoiceOp, CrossCuttingValue, DeleteCrossCuttingOp, DeleteEventOp, DeleteIdentifiedPitchOp, diff --git a/crates/epiphany-testkit/examples/conformance_suite.rs b/crates/epiphany-testkit/examples/conformance_suite.rs index 80d0d06..53dce4c 100644 --- a/crates/epiphany-testkit/examples/conformance_suite.rs +++ b/crates/epiphany-testkit/examples/conformance_suite.rs @@ -12,8 +12,8 @@ //! first violation. use epiphany_testkit::{ - bundle_harness, convergence, corpus, equivocation, fixtures, generators, layout_stub, negative, - prepass_harness, roundtrip, Rng, + bundle_harness, convergence, corpus, editloop, equivocation, fixtures, generators, layout_stub, + negative, prepass_harness, roundtrip, Rng, }; fn main() { @@ -124,5 +124,34 @@ fn main() { corpus::run_all(); prepass_harness::run_all(scale.max(1)); + // 7c. Track A — the UI seam: the editing-loop vertical slice (hit-test → score + // object → operation → reduce → re-layout → re-render → re-resolve + // selection). Every fixture must drive a click→sharpen→re-render cycle whose + // selection survives the relayout — the contract a GUI's correctness rests + // on. (The harness `epiphany-editor-core` packages as a callable API.) + eprintln!("[7c ] UI-seam gate: editing loop over the corpus"); + for seed in 0..n(48) { + for score in [ + fixtures::ten_measure_single_staff(seed), + generators::graph::valid_score_rich(seed), + ] { + let report = editloop::run_edit_loop(&score).unwrap_or_else(|| { + panic!("seed {seed}: no clickable notehead to drive the editing loop") + }); + assert!( + report.graph_changed, + "edit-loop seed {seed}: graph unchanged" + ); + assert!( + report.selection_preserved, + "edit-loop seed {seed}: selection lost across relayout" + ); + assert!( + report.render_changed, + "edit-loop seed {seed}: edit not visible" + ); + } + } + eprintln!("[8/8] ok: full conformance suite passed (scale {scale})"); }