Skip to content

feat(core): plan and apply time signatures as beat-unit writes - #751

Open
azfoo wants to merge 2 commits into
v1-prereleasefrom
v1/217-timemap-plans
Open

azfoo wants to merge 2 commits into
v1-prereleasefrom
v1/217-timemap-plans

Conversation

@azfoo

@azfoo azfoo commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

🤖 Claude

Adds tilia_core.timemap.plan. Time signatures for some measures, from a source (a score, an import) or a person's edit, become a plan of basic writes, each with its old and new value. A caller can preview the plan, apply it in memory and undo it. Nothing here opens a file: writing the document is the .tla writer's job.

What changed

New packages/tilia-core/src/tilia_core/timemap/plan.py:

  • TimeSignature (parts, denominator):
    • the parts are whole numbers or fractions, greater than 0 and at most 32 characters each, as units are;
    • parse reads what format_time_signature writes (6/8, 2+2+3/8, (4/3)/4, (1/2+1)/8), and raises ValueError for anything else or for a denominator outside 1–128.
  • BarRef (number, label, pass_, movement) and Change (timeline_id, component_id, key, old, new).
  • Plan (changes, unmatched, ambiguous, and basis, what it was made from).
  • plan_time_signatures(document, timeline_id, settings, *, source, after):
    • Matching. A bar is matched with find_rows: a number alone means every time through, and a movement narrows the matches. A downbeat's id names its measure. A bar the table doesn't have goes to unmatched, and a bar found in more than one movement with none given goes to ambiguous; nothing is written for either. Of two settings for one measure, the later wins. In a folded table, a pass counts the measures in written order (MeasureRow.folded).
    • The unit. A measure's own taps share its time signature: each tap is worth the numerator divided by the taps, exactly. So 6/8 over 2, 3 or 4 taps gives 3, 2 or 3/2. With a grouping that has one part per tap, the parts are the units (2+2+3), written as one unit when they are all equal. A unit too long to read back raises ValueError.
    • Where it goes. The unit already starting in the measure is edited; otherwise the new unit goes on the downbeat. It is never assumed.
    • The source. Every measure set records it: score, import, or for an edit edited, unless the measure is tapped. When every measure of the table is set and records the same source, the timeline's measure_source changes instead, and the marks lose their own. A measure the settings don't name is never written.
    • No needless writes. A measure whose governing unit, assumed flag and source are already right gets no write. The governing unit is followed through the table, so bars 1–120 at 3/4, one tap each, get one unit, on bar 1. Planning again after applying gives an empty plan.
    • after is keyword-only, with no default. Only "carry" is implemented: nothing more is written, so the measures after a set run take its unit. "restore" raises ValueError naming TimeLineAnnotator/project-management#194.
  • apply_plan(document, plan):
    • Checks first. Every change's old value is checked against the document as the earlier changes in the plan leave it. Then the plan's basis is checked: the measure table as computed, the sources in the marks, and the timeline's own source. If either differs, it raises PlanOutOfDate and changes nothing. PlanOutOfDate.change names the change, or is None when the basis differs. So a plan is also refused after a beat is added to a planned measure, after the unit carried into one changes, or after the timeline's source changes.
    • Then it writes, in order, so the last write wins. A plan made by hand has no basis and is checked by its old values alone.
    • The measure table follows: the rows' source, and the beat_units of the measures whose beats changed, as the measure table reads them. A row's beat_unit (the unit in force) isn't updated.

New packages/tilia-core/tests/timemap/test_timemap_plan.py, 111 tests. They cover:

  • an assumed 2/4 given 6/8;
  • one tap per bar with 3/4 on bars 1–120, on a table of exactly 120 bars and on one of 121;
  • a bar that reads 3/4 assumed being confirmed;
  • 7/8 as 2+2+3;
  • 6/8 over two, three, four and six taps, and a passage tapped in two and in three;
  • a pickup tapped like a full bar;
  • a person's unit replaced by a source's, and the source's by a later edit;
  • every time through a bar, a pass, a bar the table lacks, a cadenza matched by label only, and movements;
  • the source rules and after;
  • for apply_plan: values changed after planning, a planned measure changed, order, a reverse plan restoring the components, and no file being opened (open monkeypatched to fail).

__init__.py doesn't export the new names yet; the package's public surface is a later task.

What this PR doesn't change. In a timeline whose first measure has no unit (TiLiA and the migration always put one there), the first unit a plan writes changes how the measures before it read. They stay assumed, and they take that unit's values, by the rule for measures before the first unit. The plan writes nothing to them. Whether it should is TimeLineAnnotator/project-management#322.

Tests run

  • uv run --package tilia-core pytest packages/tilia-core/tests/timemap/test_timemap_plan.py -q: 111 passed.
  • uv run --package tilia-core pytest packages/tilia-core -q: 905 passed, 21 skipped, 8 xfailed. Before this PR: 794 passed, 21 skipped, 8 xfailed.
  • python scripts/check_packages.py: all package checks passed.
  • ruff 0.15.0 and black 22.12.0 (the pre-commit versions): clean.
  • Planning and applying every bar of a 2,000-bar timeline: under 0.04 s each.

Guarantees: what removing their code does

For each guarantee, its code in plan.py was neutralised, test_timemap_plan.py (111 tests) was run, and the code was restored:

Guarantee Code neutralised Result
A change whose old value differs is refused the old-value check made always true 8 failed
A plan whose measures changed is refused the basis check removed 3 failed
Everything is checked before anything is written each change written as soon as it is checked 41 failed
A change is checked against the changes before it each check made against the document alone 3 failed
Changes to the timeline's source are checked as one value the component id kept in the check's key 1 failed
A missing component, mark or timeline is refused a missing value let through 2 failed
Old values are copies, so an in-place edit the table doesn't see is caught the beat unit's old not copied 1 failed
No needless unit writes every set measure's unit written 21 failed
The timeline's source changes only when every measure is set "every measure is set" dropped from the condition 33 failed
An ambiguous bar writes nothing its matches written as well 2 failed
A unit too long to read back is refused its check removed 1 failed
after="restore" is refused, naming TimeLineAnnotator/project-management#194 its check removed 1 failed
An unknown source is refused its check removed 2 failed
A timeline that isn't a beat timeline is refused its check removed 1 failed
Parts are whole numbers or fractions its check removed 3 failed
Parts are at most 32 characters its check removed 1 failed
Parts are greater than 0 its check removed 2 failed
The denominator is from 1 to 128 its check removed 4 failed
parse: a fraction in the numerator needs brackets its check removed 1 failed
parse: the denominator is digits only (+8, 1_0) its check removed 2 failed
No file is opened an open() added to apply_plan 1 failed

Every guarantee in this PR is caught by at least one test.

Part of TimeLineAnnotator/project-management#217

🤖 Generated with Claude Code

azfoo and others added 2 commits October 19, 2026 11:26
tilia_core.timemap.plan turns time signatures for some measures, from a
source or an edit, into a plan of basic writes with their old and new
values, and applies a plan to a document in memory.

plan_time_signatures matches each setting's bar with find_rows (a number
alone means every time through; a movement narrows it) or by downbeat id.
A bar the table doesn't have goes to unmatched, one found in more than one
movement with none given to ambiguous, and nothing is written for either.
Each measure's own taps share its time signature: the numerator divided by
the taps, exactly, or a grouping's parts where there is one per tap. The
unit goes on the beat of the unit already starting in the measure, else on
the downbeat, not assumed. Every measure set records the source; when
every measure would end with the same one, the timeline's measure_source
changes and the set measures' marks lose their own. A measure whose
governing unit, assumed flag and source are already right gets no write,
so planning again after applying gives an empty plan. after="carry" is the
only mode: "restore" raises ValueError until
TimeLineAnnotator/project-management#194's part B is implemented.

apply_plan checks every old value first, against the document as the
changes before it leave it, and raises PlanOutOfDate naming the first that
differs, changing nothing. It then applies the changes in order and brings
the measure table's rows (sources, and the beat units of measures whose
beats changed) in line. It opens no file.

TimeSignature.parse reads what format_time_signature writes.

Part of TimeLineAnnotator/project-management#217

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…view fixes

A plan's correctness depends on more than the values it writes: the taps
of each measure, the unit carried into it, and the timeline's source. A
plan now records what it was made from (Plan.basis: the measure table as
computed, the sources in the marks, and the timeline's), and apply_plan
refuses it with PlanOutOfDate once that has changed, after checking every
change's old value. Adding a beat to a planned measure, changing the unit
carried into one, or changing the timeline's source after planning used
to apply without error and write wrong values. A plan made by hand has no
basis and is checked by its old values alone. PlanOutOfDate takes a
message and the change, which is None when the basis differs.

The timeline's measure_source changes only when every measure of the
table is set and records the same source, as FR-071 says; it used to
change whenever every measure would end with the same source, set or not.

TimeSignature takes whole numbers and fractions only, at most 32
characters each, as a unit: a float or a string gave units nobody can
read. A unit too long to read back raises ValueError when planning.

Smaller fixes:
- the timeline's source is read in one place, as stored or else as its
  table has it, so the plan, its old values and the table agree;
- apply_plan checks changes to the timeline's source against each other
  whatever their component id;
- the table's beat_units list the units the measure table reads, through
  _rows' own helpers;
- bars are matched against the rows with their number or label only.

Part of TimeLineAnnotator/project-management#217

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant