Skip to content

feat(core): the measure table's structure and the time map's positions - #707

Merged
azfoo merged 20 commits into
v1-prereleasefrom
v1/211-timemap-positions
Oct 12, 2026
Merged

azfoo merged 20 commits into
v1-prereleasefrom
v1/211-timemap-positions

Conversation

@azfoo

@azfoo azfoo commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

🤖 Claude

Computes a beat timeline's measure table and the time map built on it, in tilia_core.timemap.

What changed

timemap/table.py (new)

  • MeasureRow, a frozen dataclass: id, count, number, label, pass_, movement, cadenza, restart, folded, start, end, beat_ids, beats, source, force_display, next, metadata.
  • build_rows(document, timeline):
    • No rows while a beat has no usable time (a missing, NaN or non-number time), or while a row's beats don't start at its downbeat, which only a broken table has: rows built without them would be wrong. missing_time(timeline) and broken_row(timeline) name the measure, and build_time_map gives the same reason.
    • count is the row's position in the timeline's measure table.
    • pass_ counts the times through a printed number, in timeline order. A cadenza has no pass and doesn't count. A restart starts every count again and starts the next movement, except on the table's first row, which stays in movement 1.
    • folded is set on every row when any row has next (is_folded).
    • The last measure ends one gap after its last beat, or at the media's length if that comes sooner. No beats are added. When the media ends at or before the last beat, the gap applies and a warning is logged with the timeline's id. A quarters file's times aren't compared with the media's length, which is in seconds.
  • FrozenMetadata: each row's metadata as a read-only copy. It is a dict whose changing methods raise TypeError, with its lists as tuples. A built table doesn't follow later edits to the document, nobody can change a shared time map through it, and rows hash, pickle with any protocol, copy, and turn into JSON through dataclasses.asdict.
  • find_rows(rows, number=None, *, label=None, pass_=None):
    • A number finds every pass of that number, leaving out cadenzas.
    • A label finds its rows, cadenzas included.
    • When both are given, both must match. pass_ narrows to one time through.
    • With neither a number nor a label, it finds nothing.

timemap/timemap.py (new)

  • TimeMap, a frozen dataclass built from its rows and beat times, with timeline_id and guessed, and the properties start (the first beat), end (the closing barline) and tolerance (0.1, in the file's unit).
    • It offers measure_rows(), measures() ((start, end, number, label)) and positions(start, end).
    • The beat slots, each beat's measure, each measure's first beat and the downbeats are computed from the rows and beat times in __post_init__, and every lookup is a bisection.
    • Time maps compare by identity, and they pickle.
  • Positions, a frozen dataclass: bar, end_bar, beat, pass_, bar_count, bar_label, bar_beat_count, downbeat, cadenza, movement.
    • Snapping. A time within 0.1 plus a millionth of a beat, or of the closing barline, moves onto the nearer one. So a time exactly 0.1 from a beat is on it, whatever the float rounding.
    • beat is 1-based and fractional, linear between two beats.
    • end_bar. The end point is excluded, so an end on a later downbeat belongs to the bar before. An end that, once both ends are snapped, isn't after the start ends in the start's bar, as a point's does. An end past the closing barline has no end_bar.
    • downbeat tests the unsnapped start against the downbeats, with the same tolerance.
    • Off the map. A start before the first beat or at or after the closing barline gives no Positions.
  • build_time_map(document, timeline, *, guessed=False) returns the time map, or None and the reason there is none:
    • "no measure table" (none, or beats but no rows);
    • "a downbeat without a time (measure 2b)" or "a beat without a time (measure 1)", naming the first such measure by its label. A missing, NaN or non-number time counts as no time;
    • "the measure table doesn't match its beats (measure 2)", for a row whose beats don't start at its downbeat, or that has none;
    • "fewer than two beats";
    • "two beats at the same time (12.5)", for beats less than 1e-6 apart;
    • "beats out of time order (at 2.0)";
    • "the table lists the measures played next (next), which positions don't handle yet", given before any row is built.

timemap/__init__.py: time_map_for(document, timeline_id), and exports MeasureRow, Positions and TimeMap.

tests/timemap/docs.py: beats get the core's kind name, "beat", and pickup_piece() builds a piece both test files use.

Tests

  • New: tests/timemap/test_timemap_table.py and tests/timemap/test_timemap_positions.py. They cover:
    • passes, endings, cadenzas, restarts (including one on the first bar, and one on each song's pickup) and movements;
    • no rows while a beat has no usable time, or for a broken table;
    • folded tables;
    • the closing barline, with and without a media length, and in a quarters file;
    • find_rows;
    • every pass, end points excluded, a point's end_bar, short units and units on the first beat;
    • fractional beats, snapping (including exactly 0.1 from a beat) and downbeats;
    • positions off the map, and labels;
    • every reason for no time map, including beats without a usable time;
    • immutability, pickling with every protocol, copying, JSON, the computed arrays, and a time map not following later edits.
  • uv run --package tilia-core pytest packages/tilia-core -q: 312 passed on Python 3.11, and on 3.10 and 3.13 with uv run --isolated --python <version>.
  • python scripts/check_packages.py, run with Python 3.14.6: all package checks passed.
  • ruff 0.15.0 and black 22.12.0 (the pre-commit versions) pass on the changed files.

Each guarantee, with its code neutralised. For each one, the code that provides it was replaced (the condition by False, or by the code before the fix), the package's tests were run, and the code was restored. Rows up to measure_rows() were run with 291 tests, before the rebase onto the current v1-prerelease; the later ones with 311 or 312:

Guarantee Tests failing without its code
no time map: no measure table 1
no time map: a beat or downbeat without a time 1
a NaN time counts as no time 1
a time that isn't a number counts as no time 1
no time map: a row whose beats don't start at its downbeat 1
no time map: fewer than two beats 1
no time map: two beats less than 1e-6 apart 1
no time map: beats out of time order 1
no time map: a folded table, refused before its rows are built 2
a short unit ends in its start's bar 2
0.1 plus a millionth, when snapping 1
0.1 plus a millionth, for downbeat 1
a start off the map has no position 3
no end_bar past the closing barline 1
no end_bar at or before the first downbeat (_end_bar's guard) 1
the closing barline is capped at the media's end 2
the media's length is used only in seconds 1
a warning when the media ends before the last beat 2
count is the row's position in the table 1 (before the last fix; now index + 1, as no row is left out)
a restart on the first row stays in movement 1 1
a cadenza has no pass 2
find_rows: a number leaves cadenzas out 1
rows don't follow later edits (metadata copied) 4
row metadata can't be changed 3
rows and time maps pickle and copy 3
FrozenMetadata: item assignment refused 2
FrozenMetadata: changing methods refused 1
FrozenMetadata: pickles with any protocol 1
the time map's arrays are computed, not replaceable 1
the rows' beats and the beat times must match 1
measure_rows() hands out a copy of the list 2
no rows while a beat has no usable time 6
no rows for a broken table 2
no time map for a broken table 1
a row without beats counts as broken 2
a NaN time isn't usable (rows and map) 3

Part of TimeLineAnnotator/project-management#211

🤖 Generated with Claude Code

@azfoo azfoo left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Claude

This review comes from a code-review run plus a read-through. I reproduced bugs 1–4 and 8 on 49a05c2; the rest come from reading the code. Please fix them before it merges.

Bugs

  1. A short span, or a point with float noise, can end the bar before it starts (timemap.py:80). A point is recognised only when end == start exactly, and the end is snapped on its own. On a 4/4 map:

    • positions(4.0, 4.05) gives bar 2, end_bar 1;
    • positions(4.0, 4.0 + 1e-12) gives bar 2, end_bar 1.

    The unit then ends before it starts, and a query like bar = 2..2 misses it. Compare the snapped end with the snapped start instead: when the end isn't after the start, end_bar is bar.

  2. A short unit at the first beat is said to run past the end (timemap.py:118). _end_bar returns None whenever the snapped end is at or before the map's start. So positions(0.0, 0.05) gives bar 1 and end_bar None, which is what a unit running past the closing barline gets (FR-032). The fix for 1 covers this too.

  3. Whether a time 0.1 before a beat snaps depends on float rounding (timemap.py:100). 3.9, 0.1 before bar 2, isn't snapped: bar 1, beat 4.9. 7.9, 0.1 before bar 3, is snapped: bar 3, beat 1. Compare with tolerance + EPS.

  4. Rows and time maps can't be pickled or copied (table.py:114). MeasureRow.metadata is a MappingProxyType, so these raise TypeError:

    • pickle.dumps(time_map);
    • copy.deepcopy(row);
    • dataclasses.asdict(row).

    WP12's index may build tables in worker processes, which need pickling. Use a tuple of pairs, or a read-only mapping type that pickles.

  5. Beats out of time order give a wrong map, with no reason (timemap.py:139). Only two neighbouring beats at exactly the same time are refused:

    • Beats at 0, 1, 3 and 2 give an end of 1.0, before the beat at 3.0, and positions(1.5, 1.5) is None.
    • Two beats a rounding error apart, 1.0 and 1.0 + 1e-9, pass. Bar 1 then has three beats, and every beat number after them in that bar shifts.

    Refuse times that don't rise by more than EPS. Give the second case the existing reason, "two beats at the same time (1.0)", and the first "beats out of time order (at 2.0)".

  6. "Fewer than two beats" for a timeline that has many (timemap.py:137). A timeline with no measure table (measures=None) gets this reason, and so does one whose rows rows_of all skipped. WP3's reader doesn't build the table yet (TimeLineAnnotator/project-management#181), so every real file gets this reason today. Give it its own reason: "no measure table".

  7. A skipped measure is absorbed by the one before it (table.py:66). count is the index in rows_of's filtered list. In WP3's table, though, a row's index + 1 is its bar.count. With measures at 0, 2 and 4, where the downbeat at 2 has no time:

    • bar 3 gets count 2;
    • bar 1 ends at 4.0;
    • positions(2.5, 2.5) gives bar 1, beat 2.5.

    Keep WP3's count, and give no time map while a downbeat has no time, with the reason "a downbeat without a time".

  8. A restart on the first bar makes the first movement 2 (table.py:59). Rows 1 (restart), 2, 1 (restart), 2 give movements 2, 2, 3, 3. That follows FR-012 as written, but a file that marks every movement's first bar, the first one included, then has no movement 1. A restart on the first row should leave the movement at 1. FR-012 is being corrected to say so.

Waits on a decision

find_rows ignores the movement (table.py:140). With a restart, find_rows(rows, 1, pass_=1) gives bar 1 of both songs. That question is TimeLineAnnotator/project-management#252 (which bar a number and a pass name). It doesn't block this PR: leave find_rows as FR-061 has it for now.

Smaller

  1. build_time_map works out folded again (timemap.py:142), though build_rows already gives it as MeasureRow.folded (table.py:50). Test rows[0].folded, so the rule is in one place.
  2. TimeMap holds start, end and tolerance as fields (timemap.py:50), but they always equal _slots[0], _slots[-1] and TOLERANCE. Make them properties, so that a dataclasses.replace can't make them disagree with _slots. Its generated __eq__ and __hash__ also walk every row and beat on every call. Use eq=False unless something needs to compare time maps.
  3. The test helper tests/timemap/docs.py makes beats with kind="BEAT", but the core's name is "beat", and canonical_bytes refuses "BEAT". The tests pass because nothing reads a beat's kind yet. Any test that writes a document would fail.

@azfoo

azfoo commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

🤖 Claude

Points 1–11 are fixed in five commits, with the tests written first: each new test failed before its fix. v1-prerelease hadn't moved, so there was nothing to rebase. All 288 tests pass on Python 3.10 and 3.11, and scripts/check_packages.py passes on 3.14. The description now lists, for each guarantee, how many tests fail when its code is neutralised.

Bugs

  1. Short spans (acd96cb). positions now compares the snapped end with the snapped start. When the end isn't after the start, end_bar is bar. positions(4.0, 4.05) and positions(4.0, 4.0 + 1e-12) both give bar 2, end_bar 2. An end before the start does too. An end that snaps onto a later downbeat still belongs to the bar before: positions(0.0, 4.05) gives end_bar 1. Test: test_timemap_short_span_ends_in_its_own_bar.
  2. A short unit at the first beat (acd96cb). This is covered by 1, so _end_bar no longer tests the map's start. positions(0.0, 0.05) and positions(-0.05, 0.05) give bar 1, end_bar 1. Test: test_timemap_short_span_at_the_first_beat.
  3. Exactly 0.1 from a beat (acd96cb). Snapping and downbeat now compare with tolerance + EPS. 3.9, 7.9, 4.1 and 8.1 are all beat 1 of the next bar and a downbeat; 3.8999 isn't. Test: test_timemap_snapping_exactly_the_tolerance_away.
  4. Pickling and copying (34cee4c). MeasureRow.metadata is now FrozenMetadata, a read-only Mapping with its lists as tuples. It hashes, pickles and copies, so pickle.dumps(time_map), copy.deepcopy(row) and dataclasses.asdict(row) all work. It compares equal to a plain dict with the same items. Tests: test_rows_can_be_pickled_copied_and_turned_into_dicts and test_timemap_can_be_pickled.
  5. Beats out of time order (11bf14b). Neighbouring beats less than 1e-6 apart give "two beats at the same time (1.0)", with the earlier time. A beat before the one ahead of it gives "beats out of time order (at 2.0)", with the time out of order. Test: test_timemap_no_time_map.
  6. "No measure table" (11bf14b). Given for measures=None, and for a timeline that has beats but no rows. A timeline with no beats at all still gets "fewer than two beats". Test: test_timemap_no_time_map.
  7. A skipped measure (11bf14b). count is now the row's index in the table + 1, so with measures at 0, 2 and 4 and no time on the downbeat at 2, bar 3 has count 3. The timeline gives no time map, with the reason "a downbeat without a time". Tests: test_count_is_the_row_s_index_in_the_table and test_timemap_no_time_map.
  8. A restart on the first bar (447a14b). It changes nothing now. Rows 1 (restart), 2, 1 (restart), 2 give movements 1, 1, 2, 2. Test: test_a_restart_on_the_first_bar_leaves_it_in_movement_1.

Waits on a decision

find_rows is unchanged, as FR-061 has it, until TimeLineAnnotator/project-management#252.

Smaller

  1. folded (11bf14b). build_time_map reads rows[0].folded and no longer works it out again.
  2. TimeMap's fields (34cee4c). start, end and tolerance are properties now, read from _slots and TOLERANCE, and dataclasses.replace(time_map, end=...) raises TypeError. TimeMap uses eq=False, so maps compare by identity, and nothing in the package compared them. Tests: test_timemap_is_immutable and test_timemap_fields.
  3. The helper's kind (6ea4576). docs.py makes beats of kind "beat". Test: test_the_helper_gives_beats_the_core_s_kind, which checks every kind against the model's COMPONENT_KINDS.

@FelipeDefensor FelipeDefensor left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Claude

Felipe approves this PR. Before merge: tests for seven claims, and the rest of the guarantees table.

Two reviews, run independently, found no wrong bar, beat, pass or movement. One compared positions with a brute-force version on 88,200 random queries: no differences. The other worked through a piece with a pickup, endings, a cadenza and a restart by hand. The suite passes on Python 3.10, 3.12 and 3.14: 288 tests, and 371 merged with #706. The eleven points of Anne's review are fixed as described.

For TimeLineAnnotator/project-management#214. This PR promises that rows hash, and test_timemap_table.py:233 checks it. That task adds meter to MeasureRow, and #706's MeasureMeter holds lists, so hash(row) will raise. Felipe suggests that TimeLineAnnotator/project-management#247 lands first, and says why there.

Before merge

  1. Seven claims in the description have no test. With the code removed, all 288 tests still pass:

    Claim Code A test that would catch it
    downbeat tests the unsnapped start timemap.py:110 Beats at 4.0 s (a downbeat) and 4.15 s: a point at 4.09 s is bar 2, beat 2, with downbeat true
    An end before the start ends in the start's bar timemap.py:98 (<=) 4/4 with a beat every second: positions(4.0, 3.0).end_bar is 2. With == there, it's 1
    A cadenza doesn't count towards its number's passes table.py:97 Bars 1, 2, 2c, 2: the last one has pass 2, not 3
    time_map_for gives no map for a timeline that isn't a beat timeline __init__.py:15 A marker timeline with a measure table. The test's marker timeline has none, so it gets no map anyway
    Time maps compare by identity timemap.py:45, eq=False Two builds of one timeline aren't equal
    Positions is frozen timemap.py:29 Setting bar raises FrozenInstanceError
    The package exports MeasureRow, Positions and TimeMap __init__.py:6-9 Import them from tilia_core.timemap

    The first is P3 on TimeLineAnnotator/project-management#104, still open: such a point is "beat 2" and "on the downbeat" at once. Whichever way P3 goes, a test should pin the rule.

  2. The table leaves out guarantees that the tests do catch. It has 22 rows, and the description claims more. Some, with the tests that fail without their code:

    • passes count per printed number: 11 (table.py:98);
    • a restart starts every count again: 4 (table.py:94), and starts the next movement: 4 (table.py:95);
    • folded is set on every row: 3 (table.py:109);
    • the end point is excluded from end_bar: 3 (timemap.py:143);
    • a time snaps to the nearer of two beats: 1 (timemap.py:126).

    Principle V wants each one with its count. All 101 we checked are below.

Can follow later

  • A start in the last 0.1 s before the closing barline has no position, while the same time as an end is in the last bar. test_timemap_positions.py:195 pins it. When the media caps the barline, that can be up to half of the last beat: with the last beat at 7.0 s and media of 7.3 s, a marker at 7.2 s has no bar. It follows FR-031, FR-032 and the prototype. It's Anne's call, and it needs a line in the spec either way.
  • A beat time that isn't a number makes build_time_map raise TypeError instead of giving a reason. No file reaches it today, since WP3's reader is a stub. It depends on what TimeLineAnnotator/project-management#181's reader guarantees. If it doesn't, rows_of could treat such a time as missing.
  • FrozenMetadata doesn't equal a document dict that holds lists. FrozenMetadata({"tags": ["a", "b"]}) == {"tags": ["a", "b"]} is False, though the reply above (point 4) says it compares equal to a plain dict with the same items. Say so in the docstring, or compare with the lists turned into tuples.
  • A file without media. If WP3 gives it a media_length of 0.0 rather than None, every such file logs "the last beat (…) is at or after the end of the media (0.0)".
  • WP9's data-model.md:50 types beat_ids as list[str]. The code's tuple is right, as Anne noted on TimeLineAnnotator/project-management#211. Change the data model, not the code.
Reproduction

From the repository root at 34cee4c, with Python 3.12: PYTHONPATH=packages/tilia-core/src:packages/tilia-core/tests/timemap python repro.py

import dataclasses
import logging

from docs import beat_timeline, document
from tilia_core.timemap import time_map_for
from tilia_core.timemap.table import FrozenMetadata, build_rows
from tilia_core.timemap.timemap import build_time_map

records = []
handler = logging.Handler()
handler.emit = records.append
logging.getLogger("tilia_core.timemap").addHandler(handler)


def timeline_of(times, downbeats, marks=None):
    """Beats at `times`; those at `downbeats` start a measure, with `marks` {time: mark}."""
    marks = marks or {}
    return beat_timeline(
        [
            {"time": t, "measure": marks.get(t, {}) if t in downbeats else None}
            for t in times
        ]
    )


def time_map(times, downbeats, media_length=None):
    timeline = timeline_of(times, downbeats)
    doc = document(timeline, media_length=media_length)
    found, reason = build_time_map(doc, timeline)
    assert found is not None, reason
    return found


def where(p):
    return None if p is None else f"bar {p.bar}, beat {p.beat:g}, end_bar {p.end_bar}, downbeat {p.downbeat}"


four_four = time_map([float(t) for t in range(12)], {0.0, 4.0, 8.0})

# Before merge, 1: downbeat tests the unsnapped start (G23).
close = time_map([0.0, 1.0, 2.0, 3.0, 4.0, 4.15, 5.0, 6.0, 7.0], {0.0, 4.0})
for t in (4.09, 4.12):
    print(f"1. beats at 4.0 (a downbeat) and 4.15, a point at {t}: {where(close.positions(t, t))}")

# Before merge, 1: time_map_for's kind check (G55), on a timeline that isn't a beat timeline but has a measure table.
timeline = timeline_of([float(t) for t in range(8)], {0.0, 4.0})
marker = dataclasses.replace(timeline, kind="marker")
doc = document(marker)
print("1. time_map_for, a marker timeline with a measure table:", time_map_for(doc, marker.id))
print("1. build_time_map on the same timeline gives a map:", build_time_map(doc, marker)[0] is not None)

# Before merge, 1: an end before the start ends in the start's bar (G17).
print("1. 4/4, positions(4.0, 3.0):", where(four_four.positions(4.0, 3.0)))

# Before merge, 1: a cadenza doesn't count towards its number's passes (G64), bars 1, 2, 2c, 2.
marks = {0.0: {"number": 1}, 4.0: {"number": 2}, 8.0: {"number": 2, "label": "2c", "cadenza": True}, 12.0: {"number": 2}}
timeline = timeline_of([float(t) for t in range(16)], set(marks), marks)
print("1. bars 1, 2, 2c, 2, passes:", [(row.label, row.pass_) for row in build_rows(document(timeline), timeline)])

# Before merge, 1: time maps compare by identity (G40), two builds of one timeline.
timeline = timeline_of([float(t) for t in range(8)], {0.0, 4.0})
doc = document(timeline)
print("1. two builds of one timeline are equal:", build_time_map(doc, timeline)[0] == build_time_map(doc, timeline)[0])

# Later: a start in the last 0.1 before the closing barline.
print("later: 4/4 ending at 12.0, positions(11.95, 12.0):", where(four_four.positions(11.95, 12.0)))
print("later: 4/4 ending at 12.0, positions(11.0, 11.95):", where(four_four.positions(11.0, 11.95)))
capped = time_map([float(t) for t in range(8)], {0.0, 4.0}, media_length=7.3)
print("later: last beat at 7.0, media 7.3, end:", capped.end)
for t in (7.15, 7.2, 7.25):
    print(f"later:   a point at {t}: {where(capped.positions(t, t))}")

# Later: a beat time that isn't a number.
timeline = beat_timeline([{"time": 0.0, "measure": {}}, {"time": "1.0"}, {"time": 2.0}])
try:
    print("later:", build_time_map(document(timeline), timeline))
except Exception as error:
    print("later: build_time_map raises:", type(error).__name__, error)

# Later: FrozenMetadata and a document's lists.
print("later: FrozenMetadata({'tags': ['a', 'b']}) == {'tags': ['a', 'b']}:", FrozenMetadata({"tags": ["a", "b"]}) == {"tags": ["a", "b"]})
print("later: FrozenMetadata({'tags': ['a', 'b']}) == {'tags': ('a', 'b')}:", FrozenMetadata({"tags": ["a", "b"]}) == {"tags": ("a", "b")})

# Later: a media length of 0.0.
records.clear()
time_map([float(t) for t in range(8)], {0.0, 4.0}, media_length=0.0)
print("later: media_length 0.0, warnings:", [r.getMessage() for r in records])

Output:

1. beats at 4.0 (a downbeat) and 4.15, a point at 4.09: bar 2, beat 2, end_bar 2, downbeat True
1. beats at 4.0 (a downbeat) and 4.15, a point at 4.12: bar 2, beat 2, end_bar 2, downbeat False
1. time_map_for, a marker timeline with a measure table: None
1. build_time_map on the same timeline gives a map: True
1. 4/4, positions(4.0, 3.0): bar 2, beat 1, end_bar 2, downbeat True
1. bars 1, 2, 2c, 2, passes: [('1', 1), ('2', 1), ('2c', None), ('2', 2)]
1. two builds of one timeline are equal: False
later: 4/4 ending at 12.0, positions(11.95, 12.0): None
later: 4/4 ending at 12.0, positions(11.0, 11.95): bar 3, beat 4, end_bar 3, downbeat False
later: last beat at 7.0, media 7.3, end: 7.3
later:   a point at 7.15: bar 2, beat 4.5, end_bar 2, downbeat False
later:   a point at 7.2: None
later:   a point at 7.25: None
later: build_time_map raises: TypeError unsupported operand type(s) for -: 'str' and 'float'
later: FrozenMetadata({'tags': ['a', 'b']}) == {'tags': ['a', 'b']}: False
later: FrozenMetadata({'tags': ['a', 'b']}) == {'tags': ('a', 'b')}: True
later: media_length 0.0, warnings: ['Timeline 01a124fa-60f1-7107-973e-8d7083d80251: the last beat (7.0) is at or after the end of the media (0.0); the last measure ends one gap after it.']
Every guarantee we checked: 101, of which 94 are caught and 7 untested

For each one, the code behind it was removed or neutralised, the core's 288 tests run on Python 3.12, and the code put back. Five of the table's counts differ from ours by one, probably because we removed the code differently: G07, G46, G89, G91 and G93. Each row is caught either way.

Guarantee Code Tests failing The PR's count
G01 No time map: no measure table (reason "no measure table") timemap.py:159 1 1
G02 No time map: a downbeat without a time timemap.py:162 1 1
G03 No time map: fewer than two beats timemap.py:165 1 1
G04 No time map: two beats less than 1e-6 apart ("two beats at the same time") timemap.py:168 1 1
G05 No time map: beats out of time order timemap.py:170 1 1
G06 No time map: a folded table (rows[0].folded) timemap.py:173 1 1
G07 A short unit (end snaps onto or before the start) ends in its start's bar timemap.py:98 3 2
G08 Snapping uses tolerance plus a millionth (exactly 0.1 from a beat is on it) timemap.py:121 1 1
G09 downbeat uses tolerance plus a millionth timemap.py:151 1 1
G10 A start off the map (before the first beat, or at/after the closing barline) has no Positions timemap.py:133 3 3
G11 A start before the first beat has no Positions timemap.py:133 1
G12 A start at or after the closing barline has no Positions (the barline is excluded) timemap.py:133 3
G13 No end_bar for an end past the closing barline timemap.py:141 1 1
G14 An end exactly on the closing barline is in the last bar timemap.py:141 3
G15 end_bar excludes the end point: an end on a later downbeat belongs to the bar before timemap.py:143 3
G16 The end is snapped before end_bar is found timemap.py:97 4
G17 An end that is not after the start (even before it) ends in the start's bar, as a point's does timemap.py:98 0, untested
G18 beat is fractional, linear between two beats timemap.py:105 6
G19 beat is 1-based timemap.py:105 8
G20 beat counts from the measure's first beat timemap.py:105 6
G21 The fraction is relative to the beat's own length (linear) timemap.py:136 2
G22 A time on a beat is in that beat (bisect_right in _locate) timemap.py:135 8
G23 downbeat tests the unsnapped start timemap.py:110 0, untested
G24 downbeat is true for a start just before a downbeat timemap.py:149 9
G25 downbeat is true for a start just after a downbeat timemap.py:149 9
G26 downbeat is false for a start that is far from every downbeat, and a start before the first downbeat is handled timemap.py:151 3
G27 Snapping moves a time onto a beat up to 0.1 AFTER it timemap.py:126 7
G28 Snapping moves a time onto a beat up to 0.1 BEFORE it timemap.py:124 8
G29 Snapping moves a time onto the NEARER of two beats timemap.py:126 1
G30 The closing barline is a snap target (a time just before it is moved onto it) timemap.py:120 2
G31 A non-finite time (nan, inf) is no position (_snapped gives None) timemap.py:118 2
G32 The tolerance is 0.1, in the file's unit timemap.py:20 11
G33 Positions.bar is the row's printed number timemap.py:103 5
G34 Positions.pass_ is the row's pass timemap.py:106 3
G35 Positions.bar_count is the row's count timemap.py:107 3
G36 Positions.bar_label is the row's label timemap.py:108 3
G37 Positions.bar_beat_count is the row's beat count timemap.py:109 2
G38 Positions.cadenza is the row's cadenza flag timemap.py:111 1
G39 Positions.movement is the row's movement timemap.py:112 1
G40 TimeMap compares by identity (eq=False) timemap.py:45 0, untested
G41 TimeMap is immutable (frozen) timemap.py:45 1
G42 Positions is immutable (frozen) timemap.py:29 0, untested
G43 MeasureRow is immutable (frozen) table.py:46 1
G44 TimeMap.start is the first beat timemap.py:64 3
G45 TimeMap.end is the closing barline timemap.py:69 7
G46 TimeMap.measure_rows() hands out a copy of the list timemap.py:78 1 2
G47 TimeMap.measures() gives (start, end, number, label) timemap.py:82 2
G48 The map's closing barline is the last row's end timemap.py:179 1
G49 The time map carries its timeline's id timemap.py:176 1
G50 The time map carries guessed as passed timemap.py:177 1
G51 Each beat belongs to its measure (_measure_of) timemap.py:180 13
G52 Each measure's first beat (_first) gives the beat number timemap.py:181 6
G53 Downbeats are the rows' starts timemap.py:182 8
G54 time_map_for: an unknown timeline id gives None __init__.py:15 1
G55 time_map_for: a timeline that is not a beat timeline gives None __init__.py:15 0, untested
G56 time_map_for returns the map (not guessed) __init__.py:18 24
G57 The package exports MeasureRow, Positions, TimeMap __init__.py:6 0, untested
G58 count is the row's position in the timeline's table (kept when a measure is skipped) table.py:84 1 1
G59 A restart on the table's first row stays in movement 1 table.py:93 1 1
G60 A restart starts every pass count again table.py:94 4
G61 A restart starts the next movement table.py:95 4
G62 A cadenza has no pass and doesn't count table.py:97 2 2
G63 A cadenza has no pass (pass_ None), though it may count table.py:97 2
G64 A cadenza doesn't count towards its number's passes table.py:97 0, untested
G65 pass_ counts the times through a printed number, in timeline order table.py:98 7
G66 pass_ counts per printed number (not over all measures) table.py:98 11
G67 folded is set when any row has next table.py:85 3
G68 folded is set on EVERY row when any row has next table.py:109 3
G69 The last measure ends one gap after its last beat table.py:129 19
G70 The gap is taken from the last two beats of the table, even across measures table.py:128 1
G71 The closing barline is capped at the media's length when that comes sooner table.py:142 2 2
G72 The media's length is used only in a seconds file table.py:131 1 1
G73 A warning is logged when the media ends at or before the last beat table.py:134 2 2
G74 The warning names the timeline's id (FR-082) table.py:137 2
G75 When the media ends at or before the last beat, the gap applies (the end is not capped) table.py:136 2
G77 A row ends at the next measure's downbeat table.py:86 3
G78 A row starts at its downbeat's time table.py:110 9
G79 A row's beats is the number of beats tapped table.py:113 15
G80 A row carries its beat ids table.py:112 1
G81 A row carries restart table.py:108 2
G82 A row carries cadenza table.py:107 3
G83 A row carries source table.py:114 1
G84 A row carries force_display table.py:115 1
G85 A row carries next table.py:116 1
G86 A row carries its label and number table.py:104 10
G87 build_rows of a timeline with no rows or no table is [] table.py:82 1
G88 build_rows keeps the rows in the timeline's order table.py:75 25
G89 Rows don't follow later edits: metadata is copied table.py:25 3 4
G90 Row metadata's lists are copied as tuples (a list in the document can't be changed through a row) table.py:26 2
G91 Row metadata can't be changed table.py:33 2 3
G92 Rows and time maps hash table.py:39 1
G93 Rows and time maps pickle and copy table.py:28 2 3
G94 Metadata compares equal to a plain dict with the same items table.py:33 7
G95 find_rows: with neither a number nor a label it finds nothing table.py:155 1
G96 find_rows: a number leaves cadenzas out table.py:162 1 1
G97 find_rows: a label finds its rows, cadenzas included table.py:162 2
G98 find_rows: a number matches rows by number table.py:160 5
G99 find_rows: a label matches rows by label table.py:161 4
G100 find_rows: given both, both must match table.py:161 1
G101 find_rows: pass_ narrows to one time through table.py:163 3
G102 find_rows: a cadenza (no pass) is never found by a pass table.py:163 1

@azfoo

azfoo commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator Author

🤖 Claude

One more commit, 0082bea: the reason "a downbeat without a time" now names the first such measure by its label, "a downbeat without a time (measure 2b)", so the user knows which beat to fix (TimeLineAnnotator/project-management#285). Tested in test_timemap_no_time_map, which fails when the check is removed. All 288 tests pass.

azfoo and others added 17 commits October 12, 2026 11:52
Adds tilia_core.timemap.table with MeasureRow, build_rows(document, timeline)
and find_rows. Each row gets its position in the timeline, its pass (counted
by printed number; none for a cadenza, and every count starts again at a
restart, which also starts the next movement), whether the table is folded,
and its start and end. The last measure ends one gap after its last beat, or
at the end of the media if that comes sooner; no beats are added. find_rows
finds rows by number (every pass, cadenzas left out), by label (cadenzas
included) or both, narrowed by a pass.

Part of TimeLineAnnotator/project-management#211

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

Adds TimeMap, Positions and time_map_for(document, timeline_id). positions(start,
end) gives bar, end_bar, beat, pass, bar.count, bar.label, bar.beat_count,
downbeat, cadenza and movement. A time within 0.1 (in the file's unit) of a
beat or the closing barline is moved onto it; an end on a downbeat belongs to
the bar before, a point's end_bar is its bar, and a start off the map has no
position. A timeline with fewer than two beats, two beats at the same time, or
a table with next gives no time map; build_time_map says why.

Part of TimeLineAnnotator/project-management#211

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ons' tests

Both test files built the same 4/4 piece with a one-beat pickup, in two
different ways; docs.pickup_piece() now builds it once.

Part of TimeLineAnnotator/project-management#211

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The media's length is in seconds, while a quarters file's times are in
quarter notes, so comparing them would cut the last measure short or warn
for nothing.

Part of TimeLineAnnotator/project-management#211

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TimeMap.measure_rows() hands out the map's own rows, so a caller could
change a shared time map through a row's metadata dict, and the dict made
rows and time maps unhashable. The metadata is now a read-only mapping with
its lists as tuples, left out of the hash, in place of a deep copy.

Part of TimeLineAnnotator/project-management#211

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The helper made beats of kind "BEAT", the file's spelling, where the core's
model names the kind "beat". Nothing read a beat's kind yet, so the tests
passed, but a document they built couldn't have been written.

Part of TimeLineAnnotator/project-management#211

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

A beat timeline now gives no time map, with its reason, when:
- two beats are less than 1e-6 apart: "two beats at the same time (1.0)";
- a beat comes before the one ahead of it: "beats out of time order (at 2.0)";
- it has no measure table, or beats but no rows: "no measure table";
- a measure's downbeat has no time: "a downbeat without a time".

Before, beats out of order gave a map whose bisections were wrong, near
duplicates split a measure, and the other two reported "fewer than two
beats". A measure row's count is now its row's position in the table, so a
skipped measure doesn't move the ones after it. Whether a table is folded is
read from its rows, where it is computed.

Part of TimeLineAnnotator/project-management#211

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A file that marks every song's first bar with restart, the first song's too,
had no movement 1: the first restart moved the count on before any movement
had begun. A restart on the table's first row now changes nothing.

Part of TimeLineAnnotator/project-management#211

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

A unit was taken for a point only when its end equalled its start, and its
end was snapped on its own. So an end that snapped onto the downbeat its
start was on, or a point with float noise, gave end_bar the bar before
(4.0 to 4.05: bar 2, end_bar 1), and a short unit on the first beat got no
end_bar, as if it ran past the closing barline. Now an end that, once both
ends are snapped, isn't after the start gives end_bar the start's bar.

A time exactly the tolerance from a beat was snapped or not depending on
float rounding (3.9 stayed in bar 1, 7.9 went onto bar 3's downbeat). The
tolerance is now 0.1 plus a millionth, for snapping and for downbeat.

Part of TimeLineAnnotator/project-management#211

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A row's metadata was a mappingproxy, so pickle.dumps, copy.deepcopy and
dataclasses.asdict of a row or a time map raised TypeError. It is now
FrozenMetadata, a read-only mapping with its lists as tuples, which hashes,
pickles and copies.

TimeMap's start, end and tolerance are now properties read from its beat
slots and TOLERANCE, so dataclasses.replace can't make them disagree, and
TimeMap no longer generates an __eq__ and __hash__ that walk every row and
beat.

Part of TimeLineAnnotator/project-management#211

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The reason now says which measure: "a downbeat without a time (measure 2b)",
by the first such measure's label, so that the user, or TiLiA asking the user,
knows which beat to fix.

Part of TimeLineAnnotator/project-management#211

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A beat that wasn't a downbeat and had no time was dropped with a log line,
and the map was built anyway: the beats after it in its bar were renumbered.
A NaN time passed every check, and a time that wasn't a number made
build_time_map raise. Now every beat of the table is checked first, and a
missing, NaN or non-number time gives "a beat without a time (measure 1)", or
"a downbeat without a time (measure 2)" on a downbeat, naming the measure by
its label. A row whose beats don't start at its downbeat, which only a broken
table has, gives "the measure table doesn't match its beats (measure 2)"
instead of the downbeat's reason.

Part of TimeLineAnnotator/project-management#211

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A row's metadata was a Mapping of its own: dataclasses.asdict kept it as is,
so json.dumps of a row failed, and pickle protocols 0 and 1 refused it. It is
now a dict subclass whose changing methods raise TypeError, with __reduce__
for every pickle protocol, so asdict and json.dumps work. Its lists stay
tuples, so it still hashes, and it compares unequal to a dict holding the same
values as lists.

Part of TimeLineAnnotator/project-management#211

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
build_time_map built every row of a folded table only to discard it, and
could log the media-length warning for a map it never built. Both it and
build_rows now ask is_folded(rows), which keeps the rule in one place, and
build_time_map asks before building anything.

Part of TimeLineAnnotator/project-management#211

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TimeMap took its beat slots, each beat's measure, each measure's first beat
and the downbeats as constructor fields, so dataclasses.replace could make
them disagree with its rows. It now takes only its rows and beat times, and
computes the rest in __post_init__ (init=False fields, which replace
refuses), checking that the rows' beats and the times match.

Part of TimeLineAnnotator/project-management#211

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

positions() only asks _end_bar about an end after the start, but without the
guard any other caller would get the last bar for an end at or before the
first downbeat (bisect - 1 is -1). It gives None again.

Part of TimeLineAnnotator/project-management#211

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dataclasses.replace refuses an init=False field with ValueError before
Python 3.13 and with TypeError from 3.13 on, so the test of the time map's
computed arrays failed on 3.13 and 3.14.

Part of TimeLineAnnotator/project-management#211

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@azfoo
azfoo force-pushed the v1/211-timemap-positions branch from 8550093 to 46ec198 Compare October 12, 2026 10:06
@azfoo

azfoo commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator Author

🤖 Claude

The second code review, on the five fix commits, found more. These are fixed, each with its test written first. The branch is also rebased onto the current v1-prerelease, so every commit id changed: the old ids in my earlier comments are mapped at the end.

  • Any beat without a usable time (d5e61ad). A beat that wasn't a downbeat and had no time was dropped, and the map was built with the beats after it in its bar renumbered. A NaN time passed every check, and a time that wasn't a number raised. Now every beat is checked first: "a beat without a time (measure 1)", or "a downbeat without a time (measure 2b)", naming the measure by its label (e8e6e53). A row whose beats don't start at its downbeat, which only a broken table has, gets "the measure table doesn't match its beats (measure 2)". Decided in TimeLineAnnotator/project-management#285 and in the review that followed.
  • FrozenMetadata is a read-only dict (dc94e4a). json.dumps(dataclasses.asdict(row)) works, and it pickles with every protocol. Its lists stay tuples.
  • A folded table is refused before its rows are built (e580bb4), through one is_folded, so the media warning isn't logged for a map that is never built.
  • The time map's arrays are computed from its rows and beat times (185caf1), in __post_init__, so dataclasses.replace can't make them disagree.
  • _end_bar's guard is back (c213adf). An end at or before the first downbeat gives None, never the last bar.
  • Python 3.13 and later (46ec198): dataclasses.replace raises TypeError there, not ValueError, and the test accepts both.

Left as they are: count re-reading the table (the ids are unique), and the two commits that mix review points. The media warning will be reported once per file through TimeMapFound.warnings in TimeLineAnnotator/project-management#216.

304 tests pass on Python 3.10, 3.11 and 3.13, and scripts/check_packages.py passes on 3.14. The description lists 31 guarantees, each caught by at least one test when its code is neutralised.

Commit ids before and after the rebase

Before After
97341d6 bf3c4ad the table's counts, passes and ends
f932260 04a80b5 bar, beat and pass from the time map
57121d6 e385271 the shared pickup piece
1c8f18c 899e73d the media's length only in seconds
49a05c2 70b710f the row's metadata, a read-only copy
6ea4576 fbdaf5e the helper's beat kind (point 11)
11bf14b c64cd84 reasons, count and folded (points 5, 6, 7, 9)
447a14b 917fd51 a restart on the first bar (point 8)
acd96cb e7a41af short units and exactly 0.1 (points 1, 2, 3)
34cee4c 45d848b pickling and the derived ends (points 4, 10)
0082bea e8e6e53 the measure in the reason

azfoo and others added 2 commits October 12, 2026 12:12
build_rows still built rows for a timeline that gets no time map because a
beat has no usable time: a measure lost the beat, a measure whose downbeat had
none was dropped and the one before stretched over it, and a NaN time could
reach a row's start or end. Imports, plans and stored tables would act on
those rows. build_rows now gives none, and missing_time(timeline), which
build_time_map uses for its reason, says why. A row skipped because its beats
don't start at its downbeat, which only a broken table has, still leaves the
others their count.

Part of TimeLineAnnotator/project-management#211

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A song that starts with a pickup starts at the pickup, so the pickup carries
the restart: two such songs give movements 1, 1, 1, 2, 2, 2, and every pass 1.

Part of TimeLineAnnotator/project-management#211

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

azfoo commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator Author

🤖 Claude

Two more commits, both decided by Anne (2026-10-12):

  • No measure rows while a beat has no usable time (8bd6aa0). The map was already refused, but build_rows still built rows: a measure lost the beat, a measure whose downbeat had none was dropped and the one before stretched over it, and a NaN time could reach a row's start or end. Imports, plans and stored tables would have acted on them. build_rows now gives none, and missing_time(timeline), which build_time_map uses for its reason, says why. Tested for a missing, NaN and text time, on a downbeat and on another beat; the six cases fail when the check is removed. count's test now uses a broken table, the only case left where a row is skipped.
  • A restart goes on a song's first measure, its pickup (4baa5f9): a test of the rule as it stands, two songs starting with pickups giving movements 1, 1, 1, 2, 2, 2.

311 tests pass on Python 3.10, 3.11 and 3.13, and scripts/check_packages.py on 3.14. v1-prerelease hasn't moved since the rebase.

A row whose beats don't start at its downbeat, or that has no beats, was left
out of the rows while the others were kept. Only a broken table has one, and
the time map already refused it. broken_row(timeline) now finds it, and
build_rows gives no rows and build_time_map its reason, as for a beat without
a usable time. Since rows_of then never leaves a row out, a row's count is its
index + 1 again, and the lookup by downbeat id goes.

Part of TimeLineAnnotator/project-management#211

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

azfoo commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator Author

🤖 Claude

One more, decided by Anne (2026-10-12): no measure rows for a broken table either (98b4253). A row whose beats don't start at its downbeat, or that has no beats, was left out while the others were kept. broken_row(timeline) now finds it, build_rows gives no rows, and build_time_map gives its reason ("the measure table doesn't match its beats (measure 2)"). As rows_of then never leaves a row out, count is the row's index + 1 again, and the lookup by downbeat id is gone. 312 tests pass on Python 3.10, 3.11 and 3.13, and scripts/check_packages.py on 3.14; each new check fails a test when removed.

@azfoo
azfoo merged commit f44d037 into v1-prerelease Oct 12, 2026
26 checks passed
@azfoo
azfoo deleted the v1/211-timemap-positions branch October 12, 2026 16:35
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.

2 participants