From 691f527e0776b59ca4d1ce37f2e5a265a8b687a1 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 21 Jun 2026 19:35:09 -0400 Subject: [PATCH] Item 6 (part 3): real time-axis behavior (E-A) The layout time axis was inert: TimeAxisModel carried bare Vec, project()/affected_slots() ignored their arguments (returning the first slot / all slots), nothing populated it, and nothing consumed it. Now it carries ordered SlotPlacement { time, slot } entries and has real behavior: - project(time) returns the slot covering a time (greatest placement at or before it; first when the query precedes them all); - affected_slots(range) returns the slots in a half-open time range; - slots() lists them in time order; - with_placements populates and sorts the axis from resolved spring slots. The spacing stage (to_constrained) now populates each region's axis from its spring slots and carries the populated axis on ConstrainedLayoutRegion, so the axis is a real, consumed artifact. Tests cover project/affected_slots semantics and that spacing produces a per-region axis whose project() is a genuine function of the queried time. DECISIONS updated. This completes item 6 (and the whole v0 follow-up list, items 1-6 / M1-M5). (The slot times are still the prototype's wall-clock spacing columns; mapping a metric region's measure/beat grid to musical times is the next layer.) Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/epiphany-layout-ir/DECISIONS.md | 15 ++ crates/epiphany-layout-ir/src/constrained.rs | 51 +++++- crates/epiphany-layout-ir/src/time_axis.rs | 182 ++++++++++++++++--- crates/epiphany-testkit/src/layout_stub.rs | 3 +- 4 files changed, 225 insertions(+), 26 deletions(-) diff --git a/crates/epiphany-layout-ir/DECISIONS.md b/crates/epiphany-layout-ir/DECISIONS.md index 8b4a007..b8d420f 100644 --- a/crates/epiphany-layout-ir/DECISIONS.md +++ b/crates/epiphany-layout-ir/DECISIONS.md @@ -192,6 +192,21 @@ object is covered); the provenance-preservation contract itself is unchanged. required for correct incremental-layout cache invalidation (Chapter 7 §"Incremental Layout"). +- **The time axis has real behavior (M5 follow-up).** Previously the + `TimeAxisModel` carried bare `Vec` and `project`/`affected_slots` + ignored their arguments (returning the first slot / all slots) — inert payload. + Each axis now holds ordered `SlotPlacement { time, slot }` entries: + `project(time)` returns the slot *covering* a time (the greatest placement at or + before it), `affected_slots(range)` returns the slots in a half-open time range, + and `slots()` lists them in time order. The spacing stage (`to_constrained`) + populates each region's axis from its resolved spring slots + (`TimeAxisModel::with_placements`), and the populated axis is carried on + `ConstrainedLayoutRegion`, so the axis is a real, consumed artifact rather than + an empty placeholder. (The slot *times* are still the prototype's wall-clock + spacing columns; mapping a metric region's measure/beat grid to musical times + is the next layer, but the axis machinery now genuinely consumes whatever times + the spacing assigns.) + ## Pass 11 candidates (ambiguities for the spec, not resolved in code) 1. **Agent E's stated dependency set vs. the edit-barrier types.** The QUICKSTART diff --git a/crates/epiphany-layout-ir/src/constrained.rs b/crates/epiphany-layout-ir/src/constrained.rs index d4bc3c2..7f2cd96 100644 --- a/crates/epiphany-layout-ir/src/constrained.rs +++ b/crates/epiphany-layout-ir/src/constrained.rs @@ -21,7 +21,7 @@ use crate::logical::{LogicalLayoutIR, ScoreVersion}; use crate::provenance::{manifestation_layout_id, LayoutObjectId, Provenance}; use crate::solver::SpringSlotId; use crate::spatial::{BoundingBox, Point, Rect, StaffSpace}; -use crate::time_axis::TimePoint; +use crate::time_axis::{SlotPlacement, TimeAxisModel, TimePoint}; use crate::vertical_band::{inter_staff_gap_id, VerticalBand, VerticalBandId}; /// A stable identifier for a glyph-level object (Chapter 7: `GlyphObjectId`). @@ -78,10 +78,14 @@ pub struct GlyphStyle { pub rgba: u32, } -#[derive(Clone, PartialEq, Eq, Debug)] +#[derive(Clone, PartialEq, Debug)] pub struct ConstrainedLayoutRegion { pub provenance: Provenance, pub glyphs: Vec, + /// The region's time axis, populated with the time→slot placements of this + /// region's spring slots (Chapter 7 §"The Time Axis"): `time_axis.project` + /// maps a musical/wall-clock time to the slot covering it. + pub time_axis: TimeAxisModel, } #[derive(Clone, PartialEq, Debug)] @@ -414,15 +418,17 @@ pub fn try_to_constrained( let mut staff_members: BTreeMap> = BTreeMap::new(); let mut margin_members: Vec = Vec::new(); let mut region_glyphs = Vec::new(); + let mut region_placements: Vec = Vec::new(); for (provenance, staff) in specs { let band = band_of(staff); let glyph = make_glyph(provenance, column, band); column += 1; let gid = glyph.id(); + let time = TimePoint::WallClock(WallClockTime(column - 1)); horizontal_slots.push(SpringSlot { id: glyph.horizontal_slot, - time: TimePoint::WallClock(WallClockTime(column - 1)), + time: time.clone(), min_width: StaffSpace(1.0), preferred_width: StaffSpace(1.5), max_width: None, @@ -430,6 +436,10 @@ pub fn try_to_constrained( compress_factor: 1.0, members: vec![gid], }); + region_placements.push(SlotPlacement { + time, + slot: glyph.horizontal_slot, + }); region_glyphs.push(gid); match staff { Some(s) => { @@ -463,6 +473,9 @@ pub fn try_to_constrained( constrained_regions.push(ConstrainedLayoutRegion { provenance: region.provenance.clone(), glyphs: region_glyphs, + // The region's kind-only logical axis, now populated with the real + // time→slot placements resolved during spacing. + time_axis: region.time_axis.clone().with_placements(region_placements), }); } @@ -513,9 +526,41 @@ fn make_glyph(provenance: &Provenance, column: i64, band: VerticalBandId) -> Gly mod tests { use super::*; use crate::logical::to_logical; + use crate::time_axis::TimeAxis; use epiphany_core::generators::valid_score_rich; use std::collections::BTreeSet; + #[test] + fn spacing_populates_a_consumable_time_axis_per_region() { + let c = to_constrained(&to_logical(&valid_score_rich(11))); + assert!(!c.regions.is_empty()); + for region in &c.regions { + // The axis is no longer inert: it carries one placement per slot the + // region produced, and the slots come back in time order. + let region_slots: Vec = + region.glyphs.iter().map(|g| SpringSlotId(g.0)).collect(); + assert_eq!(region.time_axis.slots(), region_slots); + + // project() consumes the time argument: each slot's own time projects + // back to that slot (the covering placement) — not a constant. + for placement in region.time_axis.placements() { + assert_eq!( + region.time_axis.project(placement.time.clone()), + placement.slot + ); + } + // A non-trivial region distinguishes its slots by time (so project is + // genuinely a function of the query, not "always the first slot"). + if region.time_axis.placements().len() >= 2 { + let p = region.time_axis.placements(); + assert_ne!( + region.time_axis.project(p[0].time.clone()), + region.time_axis.project(p[1].time.clone()) + ); + } + } + } + /// Band membership is a correct partition: every glyph names an existing /// band, no glyph is a member of two bands, and a glyph's `vertical_band` /// equals the band that lists it — so a glyph is never placed in another diff --git a/crates/epiphany-layout-ir/src/time_axis.rs b/crates/epiphany-layout-ir/src/time_axis.rs index 534e629..efcb61a 100644 --- a/crates/epiphany-layout-ir/src/time_axis.rs +++ b/crates/epiphany-layout-ir/src/time_axis.rs @@ -37,24 +37,35 @@ pub enum TimeRange { }, } -/// Metric-axis projection data. The prototype populates slots during spacing. -#[derive(Clone, PartialEq, Eq, Debug, Default)] -pub struct MetricTimeAxis { - pub slots: Vec, +/// One placement on the time axis: the spring slot occupying a given time. +/// Placements are held in ascending time order, so the axis maps a queried time +/// to the slot covering it (the greatest placement at or before the query). +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct SlotPlacement { + pub time: TimePoint, + pub slot: SpringSlotId, } -/// Proportional-axis projection data. +/// Metric-axis projection data: the time→slot placements, populated during +/// spacing (a region's measure/beat grid resolves to ordered spring slots). +#[derive(Clone, PartialEq, Eq, Debug, Default)] +pub struct MetricTimeAxis { + pub placements: Vec, +} + +/// Proportional-axis projection data: horizontal position is linear in +/// wall-clock time, and the placements map each slot's time onto the axis. #[derive(Clone, PartialEq, Debug)] pub struct ProportionalTimeAxis { pub duration_ns: i64, pub space_per_second: StaffSpace, - pub slots: Vec, + pub placements: Vec, } -/// Aleatoric-axis projection data. Slot order is topological layer order. +/// Aleatoric-axis projection data. Placement order is topological layer order. #[derive(Clone, PartialEq, Eq, Debug, Default)] pub struct AleatoricTimeAxis { - pub slots: Vec, + pub placements: Vec, } /// The canonical representation of a region's time axis (Chapter 7). The @@ -83,12 +94,26 @@ pub enum TimeAxisKind { Registered(TimeAxisRegistryId), } +/// Compares two [`TimePoint`]s of the *same* kind; mixed kinds are +/// incomparable (`None`), which a uniform region never produces. +fn time_cmp(a: &TimePoint, b: &TimePoint) -> Option { + match (a, b) { + (TimePoint::Musical(x), TimePoint::Musical(y)) => Some(x.cmp(y)), + (TimePoint::WallClock(x), TimePoint::WallClock(y)) => Some(x.cmp(y)), + _ => None, + } +} + /// Dynamic time-axis interface used by spacing implementations. The tagged /// [`TimeAxisModel`] remains the canonical representation. pub trait TimeAxis: Send + Sync { fn kind(&self) -> TimeAxisKind; + /// The spring slot covering `time`: the placement with the greatest time at + /// or before `time` (or the first placement if `time` precedes them all). fn project(&self, time: TimePoint) -> SpringSlotId; - fn slots(&self) -> &[SpringSlotId]; + /// The spring slots in time order. + fn slots(&self) -> Vec; + /// The spring slots whose time falls in the half-open `range` `[start, end)`. fn affected_slots(&self, range: TimeRange) -> Vec; } @@ -102,6 +127,38 @@ impl TimeAxisModel { TimeAxisModel::Registered(id, _) => TimeAxisKind::Registered(*id), } } + + /// The axis's time→slot placements, in ascending time order. + pub fn placements(&self) -> &[SlotPlacement] { + match self { + TimeAxisModel::Metric(axis) => &axis.placements, + TimeAxisModel::Proportional(axis) => &axis.placements, + TimeAxisModel::Aleatoric(axis) => &axis.placements, + TimeAxisModel::Registered(_, _) => &[], + } + } + + /// Returns this axis populated with `placements` (sorted into ascending time + /// order). A `Registered` axis is opaque and returned unchanged. This is how + /// the spacing stage drives the axis from its resolved spring slots. + pub fn with_placements(self, mut placements: Vec) -> Self { + placements.sort_by(|a, b| time_cmp(&a.time, &b.time).unwrap_or(core::cmp::Ordering::Equal)); + match self { + TimeAxisModel::Metric(mut axis) => { + axis.placements = placements; + TimeAxisModel::Metric(axis) + } + TimeAxisModel::Proportional(mut axis) => { + axis.placements = placements; + TimeAxisModel::Proportional(axis) + } + TimeAxisModel::Aleatoric(mut axis) => { + axis.placements = placements; + TimeAxisModel::Aleatoric(axis) + } + other @ TimeAxisModel::Registered(_, _) => other, + } + } } impl TimeAxis for TimeAxisModel { @@ -109,21 +166,47 @@ impl TimeAxis for TimeAxisModel { TimeAxisModel::kind(self) } - fn project(&self, _time: TimePoint) -> SpringSlotId { - self.slots().first().copied().unwrap_or(SpringSlotId(0)) + fn project(&self, time: TimePoint) -> SpringSlotId { + let placements = self.placements(); + // The greatest placement at or before `time` (the slot covering it)... + let covering = placements.iter().rfind(|p| { + matches!( + time_cmp(&p.time, &time), + Some(core::cmp::Ordering::Less | core::cmp::Ordering::Equal) + ) + }); + // ...or the first placement when `time` precedes them all. + covering + .or_else(|| placements.first()) + .map(|p| p.slot) + .unwrap_or(SpringSlotId(0)) } - fn slots(&self) -> &[SpringSlotId] { - match self { - TimeAxisModel::Metric(axis) => &axis.slots, - TimeAxisModel::Proportional(axis) => &axis.slots, - TimeAxisModel::Aleatoric(axis) => &axis.slots, - TimeAxisModel::Registered(_, _) => &[], - } + fn slots(&self) -> Vec { + self.placements().iter().map(|p| p.slot).collect() } - fn affected_slots(&self, _range: TimeRange) -> Vec { - self.slots().to_vec() + fn affected_slots(&self, range: TimeRange) -> Vec { + let (start, end) = match range { + TimeRange::Musical { start, end } => { + (TimePoint::Musical(start), TimePoint::Musical(end)) + } + TimeRange::WallClock { start, end } => { + (TimePoint::WallClock(start), TimePoint::WallClock(end)) + } + }; + self.placements() + .iter() + .filter(|p| { + let at_or_after_start = matches!( + time_cmp(&p.time, &start), + Some(core::cmp::Ordering::Greater | core::cmp::Ordering::Equal) + ); + let before_end = matches!(time_cmp(&p.time, &end), Some(core::cmp::Ordering::Less)); + at_or_after_start && before_end + }) + .map(|p| p.slot) + .collect() } } @@ -136,7 +219,7 @@ pub fn time_axis_of(region: &Region) -> TimeAxisModel { RegionTimeModel::Proportional(p) => TimeAxisModel::Proportional(ProportionalTimeAxis { duration_ns: p.duration.0, space_per_second: StaffSpace(1.0), - slots: Vec::new(), + placements: Vec::new(), }), RegionTimeModel::Aleatoric(_) => TimeAxisModel::Aleatoric(AleatoricTimeAxis::default()), } @@ -156,7 +239,7 @@ mod tests { TimeAxisModel::Proportional(ProportionalTimeAxis { duration_ns: 42, space_per_second: StaffSpace(1.0), - slots: vec![], + placements: vec![], }) .kind(), TimeAxisKind::Proportional @@ -171,4 +254,59 @@ mod tests { TimeAxisKind::Registered(r) ); } + + #[test] + fn project_and_affected_slots_consume_placements() { + // Built out of time order; `with_placements` sorts by time. + let axis = TimeAxisModel::Metric(MetricTimeAxis::default()).with_placements(vec![ + SlotPlacement { + time: TimePoint::WallClock(WallClockTime(10)), + slot: SpringSlotId(1), + }, + SlotPlacement { + time: TimePoint::WallClock(WallClockTime(30)), + slot: SpringSlotId(3), + }, + SlotPlacement { + time: TimePoint::WallClock(WallClockTime(20)), + slot: SpringSlotId(2), + }, + ]); + + // Slots come back in ascending time order. + assert_eq!( + axis.slots(), + vec![SpringSlotId(1), SpringSlotId(2), SpringSlotId(3)] + ); + // project() returns the covering slot (greatest time <= query)... + assert_eq!( + axis.project(TimePoint::WallClock(WallClockTime(25))), + SpringSlotId(2) + ); + assert_eq!( + axis.project(TimePoint::WallClock(WallClockTime(30))), + SpringSlotId(3) + ); + // ...and the first slot when the query precedes every placement. + assert_eq!( + axis.project(TimePoint::WallClock(WallClockTime(5))), + SpringSlotId(1) + ); + // affected_slots() respects the half-open range [10, 30): excludes 30. + assert_eq!( + axis.affected_slots(TimeRange::WallClock { + start: WallClockTime(10), + end: WallClockTime(30), + }), + vec![SpringSlotId(1), SpringSlotId(2)] + ); + + // An empty axis projects to the default slot and has no slots. + let empty = TimeAxisModel::Metric(MetricTimeAxis::default()); + assert_eq!( + empty.project(TimePoint::WallClock(WallClockTime(0))), + SpringSlotId(0) + ); + assert!(empty.slots().is_empty()); + } } diff --git a/crates/epiphany-testkit/src/layout_stub.rs b/crates/epiphany-testkit/src/layout_stub.rs index 3cb2de5..c39f230 100644 --- a/crates/epiphany-testkit/src/layout_stub.rs +++ b/crates/epiphany-testkit/src/layout_stub.rs @@ -54,7 +54,7 @@ pub fn gen_time_axis_model(rng: &mut Rng) -> TimeAxisModel { 1 => TimeAxisModel::Proportional(ProportionalTimeAxis { duration_ns: rng.range(0, 1 << 40) as i64, space_per_second: gen_staff_space(rng), - slots: vec![], + placements: vec![], }), 2 => TimeAxisModel::Aleatoric(AleatoricTimeAxis::default()), _ => TimeAxisModel::Registered( @@ -536,6 +536,7 @@ pub fn gen_constrained_layout_region(rng: &mut Rng) -> ConstrainedLayoutRegion { glyphs: (0..rng.range_usize(0, 3)) .map(|_| gen_glyph_object_id(rng)) .collect(), + time_axis: gen_time_axis_model(rng), } }