Repository navigation
Move #621's beat unit rules into tilia_core.timemap, and compute each measure's meter - #706
Conversation
azfoo
left a comment
There was a problem hiding this comment.
🤖 Claude
This review comes from a code-review run plus a read-through, on 3472048. I reproduced the first bug and the #250 case; the rest come from reading the code.
Waits on TimeLineAnnotator/project-management#250 (the measures before the first beat unit)
This PR does #250's option 1: the measures before the first unit are N/4, assumed. #250 is still open, and it blocks #213. Once it's decided:
- with option 2,
meters_ofchanges (meters.py:64); - with either option, add a test for the case. No test has a first measure without a unit followed by a unit in a later measure: every test has a unit on beat 0, or none at all.
A related case is now on #250 too: a timeline with no readable unit anywhere (meters.py:75). TiLiA makes a default unit on the first beat, so its measure 1 has starts_here=True and a governing unit. meters_of gives measure 1 starts_here=False and beat_unit_id=None.
Bugs
MeasureMetercan't be hashed (units.py:95). It is frozen but has list fields, sohash(meter)raisesTypeError: unhashable type: 'list', and the lists can still be changed in place. Once #214 (time signatures) puts the meter into the frozenMeasureRow, hashing a row fails. Use tuples, and make the same change in desktop#621's copy so that the two stay the same.- A beat unit in a skipped measure is lost (
meters.py:54). Whenrows_ofskips a measure because its downbeat has no time, a unit set in that measure goes too. Later measures keep the unit before it, and nothing says so. Take beats 0–5 in measures of 2, with{4, "1"}on beat 0 and{8, "3"}on beat 2, and beat 2 without a time: measure 3 comes out 2/4 instead of 6/8. Carry the skipped measure's unit on to the measures after it, as TiLiA would, and warn. - Whether a long unit can be read depends on the Python version (
units.py:61).parse_unitsusesFraction(item). On Python 3.10.7 and later, that refuses a string of more than 4300 digits; on 3.10.0–3.10.6, it doesn't. The same file then gets different meters on different Pythons, and the error message holds the whole item. Refuse items over a fixed length, in both copies, and shorten the item in the message.
Smaller
- Units are checked in two places (
meters.py:84)._rows._beat_unitrefuses only units whose structure is broken. It keeps units whoseunitsordenominatorcan't be read, andmeters_ofchecks those again in_readable. Check once, in_rows. ThenRowIn.unitsholds only readable units for everything that reads it, such as #214's table and FR-023a's rows, and the warnings have one format, with the timeline's id. - The warning for an unreadable unit repeats (
meters.py:93). It is logged on every call tometers_of, and it names only the beat. Once the table callsmeters_offor each time map, one bad unit is logged once per query. Log it once, where the rows are read (with 4), naming the timeline. units.pyis a copy of desktop#621'stilia/timelines/beat/units.py. TimeLineAnnotator/project-management#220 (shared meter examples in the app) will run the same examples against both copies, which catches a change to the rules on one side. Nothing needs changing here for that.
|
🤖 Claude Point by point, as of TimeLineAnnotator/project-management#250 (the measures before the first beat unit). Nothing changes until it is decided. The test for that case comes with the decision.
|
|
🤖 Claude TimeLineAnnotator/project-management#250 (the measures before the first beat unit) is decided, and
The review's other points stand as in my reply above:
|
FelipeDefensor
left a comment
There was a problem hiding this comment.
🤖 Claude
Felipe approves this PR. Before merge: two tests, and a few changes to the guarantees table.
Two reviews, run independently, found no wrong meter. meters_of gave the same meters as a hand port of #621's loop on 3,000 and 4,000 random timelines, and units.py is #621's file byte for byte. The suite passes on Python 3.10, 3.12 and 3.14: 310 tests, and 371 merged with #707. Anne's earlier points are done as described, or recorded on TimeLineAnnotator/project-management#247.
Before merge
- No test checks that the measures before the first unit take the first unit's values.
- Search the rows in reverse at meters.py:54, and all 310 tests still pass. Each test in
TestBeforeTheFirstUnithas only one readable unit after the gap. - A test that would catch it: 8 beats in measures of 2, with
{8, "3"}on beat 4 and{4, "1"}on beat 6. Measures 1 and 2 must be{8, "3"}, assumed. With the search reversed, they are{4, "1"}.
- Search the rows in reverse at meters.py:54, and all 310 tests still pass. Each test in
- No test checks that a
beat_unitthat isn't a mapping is left out.- Catch only
KeyErrorat _rows.py:107, and all 310 tests still pass. Then"beat_unit": "4/4"raises out ofrows_of:TypeError: string indices must be integers, not 'str'. "4/4",["1"]and5intest_a_beat_unit_desktop_621_refuses_is_left_outwould cover it.
- Catch only
- The table leaves out claims that the tests do catch. Principle V wants each one with its count:
- the warning names the timeline: 14 tests fail without it (_rows.py:112);
- the warning names the beat: 14 (_rows.py:113);
- with no readable unit, a measure of N taps is N/4: 2 for the denominator and 2 for the units (meters.py:56).
- The table should name what no test catches. With the code removed, all 310 tests still pass:
- "
meters_ofparses each unit once per call" (meters.py:92). It's also one off: the first unit is parsed twice, at line 54 and again in the loop, so three units take four calls. Reusingfirstwould make it true. - Three in
units.py:parse_units's message for blank text (line 45), its message for an empty item such as"1+"(line 51), and"/","1/"or"1//2"refused rather than raised (line 62, theValueErrorhalf). Their tests belong in #621, and come over with the next copy (TimeLineAnnotator/project-management#247).
- "
MeasureMeter can't be hashed. Nothing to change here, since the file stays #621's. Felipe suggests that TimeLineAnnotator/project-management#247 lands before TimeLineAnnotator/project-management#214, and says why there.
Can follow later
meters_ofraises on a row thatrows_ofdidn't make. A hand-builtRowInwithunits="x"givesZeroDivisionError: integer modulo by zero. The function is public. Say in its docstring that rows come fromrows_of, or skip such units.- A long unit pattern makes
meters_ofslow. For every measure, it copies the pattern (meters.py:71) andis_fit_ambiguousbuilds a set of it (units.py:92). On 50,000 beats in measures of 4, it took 0.2 s with a pattern of 10 entries and 1.9 s with 100. Real patterns have 1 to 5 entries, but a damaged file with thousands would stall a corpus scan. Working both out once per governing unit would fix it. #621's loop has the same cost.
Reproduction
From the repository root at 8649ac0, with Python 3.12: PYTHONPATH=packages/tilia-core/src:packages/tilia-core/tests/timemap python repro.py
import logging
import time
from fractions import Fraction
from docs import beat_timeline
from tilia_core import tla
from tilia_core.timemap import meters as meters_module
from tilia_core.timemap._rows import RowIn, rows_of
from tilia_core.timemap.meters import meters_of
def piece(count, per_measure, units):
"""`count` beats a second apart, in measures of `per_measure`, with units on some beats."""
return beat_timeline(
[
{
"time": float(i),
"measure": {} if i % per_measure == 0 else None,
"beat_unit": units.get(i),
}
for i in range(count)
]
)
# MeasureMeter can't be hashed, and its lists can be changed in place.
meter = meters_of(rows_of(piece(4, 4, {0: {"denominator": 4, "units": "1"}})))[0]
try:
hash(meter)
except TypeError as error:
print("hash(meter):", type(error).__name__, error)
meter.units.append(Fraction(9))
print("units changed in place on a frozen meter:", meter.units)
# Before merge, 1: two units after the gap; the measures before take the FIRST one's values.
timeline = piece(8, 2, {4: {"denominator": 8, "units": "3"}, 6: {"denominator": 4, "units": "1"}})
meters = meters_of(rows_of(timeline))
print(
"1. measures 1-4 (denominator, units, assumed):",
[(m.denominator, [str(u) for u in m.units], m.is_assumed) for m in meters],
)
# Before merge, 2: a beat_unit that isn't a mapping is left out, with a warning.
records = []
handler = logging.Handler()
handler.emit = records.append
logging.getLogger("tilia_core.timemap._rows").addHandler(handler)
for bad in ("4/4", ["1"], 5):
records.clear()
rows = rows_of(piece(4, 4, {0: {"denominator": 4, "units": "1"}, 2: bad}))
print(f"2. beat_unit {bad!r}: units kept {len(rows[0].units)}, warnings {len(records)}")
# Before merge, 4: how often meters_of parses a unit, for three units.
calls = 0
real = meters_module.parse_units
def counting(text):
global calls
calls += 1
return real(text)
meters_module.parse_units = counting
meters_of(rows_of(piece(12, 4, {i: {"denominator": 4, "units": "1"} for i in (0, 4, 8)})))
meters_module.parse_units = real
print("4. three units, parse_units calls in meters_of:", calls)
# Later: a hand-built row with a unit rows_of would have left out.
row = RowIn(
id="a",
number=1,
label="1",
beat_ids=("a", "b"),
beat_times=(0.0, 1.0),
units=(("a", tla.BeatUnit(denominator=4, units="x", assumed=False)),),
cadenza=False,
restart=False,
force_display=False,
next=None,
source="tapped",
)
try:
meters_of([row])
except Exception as error:
print("later: meters_of on a hand-built row:", type(error).__name__, error)
# Later: a long unit pattern, one unit on beat 0, 50,000 beats in measures of 4.
for entries in (10, 100):
timeline = piece(50_000, 4, {0: {"denominator": 8, "units": "+".join(["1"] * entries)}})
rows = rows_of(timeline)
start = time.perf_counter()
meters_of(rows)
print(f"later: {entries} entries: meters_of took {time.perf_counter() - start:.1f} s")Output:
hash(meter): TypeError unhashable type: 'list'
units changed in place on a frozen meter: [Fraction(1, 1), Fraction(9, 1)]
1. measures 1-4 (denominator, units, assumed): [(8, ['3'], True), (8, ['3'], True), (8, ['3'], False), (4, ['1'], False)]
2. beat_unit '4/4': units kept 1, warnings 1
2. beat_unit ['1']: units kept 1, warnings 1
2. beat_unit 5: units kept 1, warnings 1
4. three units, parse_units calls in meters_of: 4
later: meters_of on a hand-built row: ZeroDivisionError integer modulo by zero
later: 10 entries: meters_of took 0.2 s
later: 100 entries: meters_of took 1.9 s
Every guarantee we checked: 40, of which 35 are caught and 5 untested
For each one, the code behind it was removed or neutralised, the core's 310 tests run on Python 3.12, and the code put back. All 12 of the table's counts match ours.
| Guarantee | Code | Tests failing | The PR's count | |
|---|---|---|---|---|
| G01 | unreadable units are left out of RowIn.units | _rows.py:120 |
19 | 19 |
| G02 | units that aren't text are left out | _rows.py:123 |
2 | 2 |
| G03 | units parse_units rejects are left out |
_rows.py:124 |
12 | 12 |
| G04 | a denominator outside 1-128 is left out of RowIn.units | _rows.py:125 |
5 | 5 |
| G05 | the warning shortens the unit | _rows.py:111 |
1 | 1 |
| G06 | the first unit set in a measure governs it | meters.py:66 |
2 | 2 |
| G07 | a second unit makes the measure conflicting | meters.py:81 |
2 | 2 |
| G08 | a unit carries forward to later measures | meters.py:65 |
10 | 10 |
| G09 | beat_unit_id is the governing unit's beat |
meters.py:78 |
15 | 15 |
| G10 | before the first unit, the measures take its denominator and units | meters.py:58 |
3 | 3 |
| G11 | before the first unit, the measures are assumed | meters.py:82 |
4 | 4 |
| G12 | measure 1 always has starts_here |
meters.py:79 |
4 | 4 |
| G13 | format_units writes canonical '2+3' form (agrees with #621's cases) |
units.py:73 |
1 | |
| G14 | fit_units repeats the pattern over a longer measure |
units.py:83 |
4 | |
| G15 | is_fit_ambiguous flags a measure that doesn't hold a whole number of patterns |
units.py:92 |
6 | |
| G16 | validate_denominator: upper bound 128 |
units.py:29 |
3 | |
| G17 | validate_denominator: lower bound 1 (zero and negatives refused) |
units.py:29 |
3 | |
| G18 | validate_denominator: a bool is not a denominator |
units.py:28 |
1 | |
| G19 | validate_denominator: a non-int is refused |
units.py:27 |
2 | |
| G20 | the warning names the timeline | _rows.py:112 |
14 | |
| G21 | the warning names the beat | _rows.py:113 |
14 | |
| G22 | a measure of N taps with no readable unit is N/4: denominator 4 | meters.py:56 |
2 | |
| G23 | a measure of N taps with no readable unit is N/4: units '1' | meters.py:56 |
2 | |
| G24 | is_assumed follows the governing unit's assumed flag |
meters.py:82 |
2 | |
| G25 | each measure's beat values are in quarter notes (quarter_length) |
units.py:121 |
6 | |
| G26 | the numerator is the sum of the beat values | units.py:114 |
17 | |
| G27 | the assumed flag on a unit in the file is read | _rows.py:105 |
3 | |
| G28 | a beat_unit that isn't a mapping (TypeError) is ignored with a warning, not raised |
_rows.py:107 |
0, untested | |
| G29 | a beat_unit missing its denominator/units (KeyError) is ignored with a warning, not raised |
_rows.py:107 |
1 | |
| G30 | parse_units: blank text gives the 'Enter at least one unit.' error |
units.py:45 |
0, untested | |
| G31 | parse_units: an empty item ('1+') gives the 'Missing unit around +' error |
units.py:51 |
0, untested | |
| G32 | parse_units: decimals, signs, exponents are refused |
units.py:58 |
2 | |
| G33 | parse_units: '1/0' is refused, not raised |
units.py:62 |
1 | |
| G34 | parse_units: '/', '1/', '//' (Fraction ValueError) are refused, not raised |
units.py:62 |
0, untested | |
| G35 | parse_units: units must be greater than 0 ('0', '0/3') |
units.py:64 |
3 | |
| G36 | meters_of parses each unit once per call |
meters.py:92 |
0, untested | |
| G37 | a measure whose downbeat has no time is skipped whole | _rows.py:68 |
1 | |
| G38 | a beat without a time is left out of the row | _rows.py:55 |
2 | |
| G39 | units are kept in beat order | _rows.py:67 |
2 | |
| G40 | each measure has one beat value per tapped beat | meters.py:77 |
28 |
Moved from tilia/timelines/beat/units.py (desktop#621), unchanged: the file is the same blob as at a17bbd8. TestParseUnits and TestFitUnits are ported case for case to test_timemap_units.py, with a test of validate_denominator. Part of TimeLineAnnotator/project-management#213 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
meters_of(rows) is the loop of BeatTimeline._compute_measure_meters (desktop#621), reading the units set on each row's beats: the first unit in a measure governs it and is carried forward, any other makes it conflicting, and before any unit a measure of N taps is N/4, assumed. beat_unit_id is the id of the beat that carries the governing unit, since a beat unit in a .tla file has no id of its own. A unit desktop#621 refuses to create (units parse_units rejects, a denominator out of range) is logged and ignored, so the unit before it carries on, as when TiLiA drops it from a file it loads. TestMeasureMeters is ported to test_timemap_meters.py, apart from the cases about editing in the app, with tests of the default, of assumed, of unreadable units and of the acceptance scenarios of the time signatures. Part of TimeLineAnnotator/project-management#213 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ning meters_of parsed the governing unit's text again for every measure it governs; it is now parsed once, when it is checked, and each meter gets its own list. The tests copy each unit's dict into the timeline, check that the warning names the ignored beat, and cover an unreadable unit before a readable one in the same measure. Part of TimeLineAnnotator/project-management#213 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rows_of now leaves out a beat unit desktop#621 (beat units and time signatures) would refuse to create: units parse_units rejects, or a denominator outside 1-128. Every reader of RowIn.units sees only readable units, and the warning names the timeline and the beat once per read, with the unit shortened. meters_of no longer checks or logs. Part of TimeLineAnnotator/project-management#213 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…assumed Before the first unit meters_of can read, the measures now take that unit's denominator and units, assumed, as desktop#621 (beat units and time signatures)'s loader gives them: it keeps a unit in the first measure (_ensure_first_beat_unit), copied from the first unit. With no unit at all, a measure of N taps is still N/4, assumed, as #621's default unit is. Either way measure 1 has starts_here true, as in TiLiA, and no beat_unit_id, since no unit sits on its beats. Tests cover a timeline whose first measures have no unit (or only one that can't be read) and a unit later, and one with no readable unit at all. Part of TimeLineAnnotator/project-management#213 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8649ac0 to
27c0a5d
Compare
🤖 Claude
Moves #621 (beat units and time signatures)'s rules into
tilia-core, so that the core computes each measure's meter as the app does.tilia_core.timemap.unitsis feat: beat patterns, beat units, time signatures and quarter stamps #621'stilia/timelines/beat/units.pyata17bbd83, unchanged: the same git blob. It hasDEFAULT_DENOMINATOR,DEFAULT_UNITS,MAX_DENOMINATOR,validate_denominator,UnitsParseResult,parse_units,format_units,fit_units,is_fit_ambiguousandMeasureMeter. The app'stilia/timelines/beat/units.pyis not touched.tilia_core.timemap.meters.meters_of(rows) -> list[MeasureMeter]runs the loop of feat: beat patterns, beat units, time signatures and quarter stamps #621'sBeatTimeline._compute_measure_metersover theRowIns fromrows_of, reading the units set on each row's beats:beat_unit_idis the id of the beat that carries the governing unit, because a beat unit in a.tlafile has no id of its own.starts_heretrue, and those measures have nobeat_unit_id, since no unit sits on their beats in the file.meters_ofdiffers from_compute_measure_meters, which never meets such a timeline._ensure_first_beat_unitkeeps a unit in the first measure, copied from the first unit or the default.assumedflag. Until feat: beat patterns, beat units, time signatures and quarter stamps #621 marks its copy assumed, the core and TiLiA differ inassumedon these files, which TiLiA never writes. Their time signatures already agree.parse_unitsrejects, or a denominator outside 1–128) is left out ofRowIn.unitsbyrows_of, with a warning that names the timeline and the beat. The unit before it carries on, as when TiLiA drops such a unit from a file it loads.meters_ofparses each unit once per call.MeasureMeter.beat_unit_idkeeps feat: beat patterns, beat units, time signatures and quarter stamps #621's annotation,int | None, although the core puts beat ids (str) there. The file stays as feat: beat patterns, beat units, time signatures and quarter stamps #621 has it, so the annotation can change in both copies at once.timemap/__init__.pydoesn't export the new names yet. Nothing is stored or written, andtilia_core/tla/is unchanged.Tests
test_timemap_units.pyports feat: beat patterns, beat units, time signatures and quarter stamps #621'sTestParseUnitsandTestFitUnitscase for case, plus a test ofvalidate_denominator.test_timemap_rows.pygains tests of the unitsrows_ofleaves out, and of the shortened warning.test_timemap_meters.pyports feat: beat patterns, beat units, time signatures and quarter stamps #621'sTestMeasureMeters, except the two tests about editing in the app (barlines moving, the cache). It adds tests of:is_assumedfollowing the governing unit;(4/3)/4, with each beat's length in quarter notes.a17bbd83, with the same expectations, and agree. feat: beat patterns, beat units, time signatures and quarter stamps #621's owntest_beat_units.pypasses there (81 tests). The cases before the first unit agree on time signatures, but not yet onassumed; see above.uv sync --all-packages, thenuv run --package tilia-core pytest packages/tilia-core -q: 310 passed.python scripts/check_packages.py: all package checks pass.Guarantees. I removed or neutralised the code behind each one, ran the suite (310 tests), then restored the code:
RowIn.units_rows._readablereturnsTrueisinstancecheckparse_unitsrejects are left outis_validcheckvalidate_denominatorcheck%.60r→%rin_measure[0]→in_measure[-1]is_conflicting=Falsebeat_unit_idis the governing unit's beatbeat_unit_id=Nonestarts_hereor index == 0removedUntested: none of the above.
units.pyis #621's code, tested by the ported cases.Part of TimeLineAnnotator/project-management#213
🤖 Generated with Claude Code