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>
This commit is contained in:
Levi Neuwirth 2026-06-27 16:47:34 -04:00
parent bdab6dee1f
commit a28d888c8e
6 changed files with 546 additions and 3 deletions

9
Cargo.lock generated
View File

@ -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"

View File

@ -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.

View File

@ -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

View File

@ -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<dyn ConstraintSolver>`, 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<dyn ConstraintSolver>,
render: RenderIR,
map: HitTestMap,
selection: Option<Selection>,
// 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<dyn ConstraintSolver>) -> Result<Self, EditorError> {
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<Selection> {
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<Selection> {
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<Selection> {
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<EditOutcome, EditorError> {
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<EditOutcome, EditorError> {
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"
);
}
}

View File

@ -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,

View File

@ -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})");
}