Item 6 (part 3): real time-axis behavior (E-A)

The layout time axis was inert: TimeAxisModel carried bare Vec<SpringSlotId>,
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) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-06-21 19:35:09 -04:00
parent f105b53599
commit 691f527e07
4 changed files with 225 additions and 26 deletions

View File

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

View File

@ -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<GlyphObjectId>,
/// 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<StaffId, Vec<GlyphObjectId>> = BTreeMap::new();
let mut margin_members: Vec<GlyphObjectId> = Vec::new();
let mut region_glyphs = Vec::new();
let mut region_placements: Vec<SlotPlacement> = 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<SpringSlotId> =
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

View File

@ -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<SpringSlotId>,
/// 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<SlotPlacement>,
}
/// 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<SpringSlotId>,
pub placements: Vec<SlotPlacement>,
}
/// 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<SpringSlotId>,
pub placements: Vec<SlotPlacement>,
}
/// 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<core::cmp::Ordering> {
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<SpringSlotId>;
/// The spring slots whose time falls in the half-open `range` `[start, end)`.
fn affected_slots(&self, range: TimeRange) -> Vec<SpringSlotId>;
}
@ -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<SlotPlacement>) -> 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<SpringSlotId> {
self.placements().iter().map(|p| p.slot).collect()
}
fn affected_slots(&self, _range: TimeRange) -> Vec<SpringSlotId> {
self.slots().to_vec()
fn affected_slots(&self, range: TimeRange) -> Vec<SpringSlotId> {
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());
}
}

View File

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