Repository navigation
feat(core): choose a document's time map by role - #733
Conversation
00bd895 to
9412e38
Compare
azfoo
left a comment
There was a problem hiding this comment.
🤖 Claude
This review comes from two code-review runs plus a read-through.
- The first run: its 13 findings are fixed or decided, in
ff310948andd25e3c2b. Anne decided four of them on 2026-10-14; they're recorded on TimeLineAnnotator/project-management#13 (the measure table). - The second run: it reviewed
d25e3c2b, and its findings are below. I reproduced 1 and 2.
Please fix them before it merges.
Bugs, and two decided changes
-
A media length that isn't a number raises (
table.py:239,_media_length;table.py:244,_ends_by).- The reader keeps
media.lengthas it finds it, and_media_lengthpasses it on unchecked. - With
"length": "20",find_time_mapraisesTypeError: '<=' not supported between instances of 'str' and 'float'. - With
true, it warns that the last beat is after the end of the media at 1 s. - Since this PR, every beat timeline makes that comparison, so one bad length breaks the whole file on the index's read path.
Have
_media_lengthreturnNoneunless the length is what the format allows.- The schema allows a number above zero, or
nullwhen the length is unknown. - The migration already turns an old file's
0, which TiLiA writes when no media is loaded, intonull. - So treat text, a bool, NaN, infinity, 0 and negative lengths alike, as no length: no cap and no warning. That is the same check
_has_timemakes for beat times. - It also stops a NaN length from depending on
min(end, media_length)'s argument order (table.py:224):min(nan, end)would give a NaN closing barline.
Add a test for each.
- The reader keeps
-
One beat past the media: the warning describes a gap that isn't there (
table.py:113,media_warning).- A table with one beat at 5.0 and a media length of 4.0 gets "the last beat (5.0) is at or after the end of the media (4.0); the last measure ends one gap after it".
- Its only measure in fact ends on the beat:
measure_rowsgives(5.0, 5.0).
Leave out the second clause when the table has a single beat.
-
The warning about several claimants isn't logged (
__init__.py:27). It is returned but not logged, unlike the media warnings, so a caller oftime_map_ofloses it. Log it too, once for each call (Anne, 2026-10-14). -
That warning doesn't name the claimants. It says "two" when three claim, and names none of them, while each media warning names its timeline. Name them, in ordinal order, e.g. "timelines A, B and C claim the role time_map; A, the first by ordinal, is used" (Anne, 2026-10-14).
Smaller
- The type of
roles(__init__.py:42,99,108): it isMapping[str, str | None], but a role that is a list claimstime_maptoo. Make the value typestr | list[str] | None,tla.Metadata's, and update the contract. - One chain of checks (
table.py:93,_usable_times):- It repeats
build_time_map's chain (missing_time,broken_row,disorder) without the reasons, andfind_time_mapchecks the chosen table twice. - A
table_problem(timeline) -> str | Nonethat returns the reason would servebuild_rows,media_warningandbuild_time_map, and keep them in step. - "Fewer than two beats" and a disorder can't both apply, so
build_time_map's reasons don't change.
- It repeats
- One constant (
table.py:14):SAME_TIMEduplicatestimemap.EPS, whichbuild_time_mapused for the same test before this PR. Keep one. - Docstrings:
measure_rows(__init__.py:123) leaves out the disorder case;build_rows(table.py:80) says "None" but returns[];_usable_timessaysNonewhen there can be no rows, but returns[]for a missing or empty table.
- Tests:
assert hash(found) == hash(found)(test_timemap_choose.py:218) can't fail oncehashreturns: callinghash(found)is the test.- No test checks that a table out of time order gets no media warning.
- Test comments:
(#7)(test_timemap_choose.py:136) links to desktop's own #7. WriteTimeLineAnnotator/project-management#7 (format in the core).- The comments that start "Anne, 2026-10-14" would read better citing TimeLineAnnotator/project-management#13, where the decisions are recorded.
- Commits:
d25e3c2bholds four decisions andff310948three unrelated fixes. desktop's CLAUDE.md asks for each in its own commit, so that one can be reverted or bisected alone. Splitting them rewrites the pushed branch.
Known, and tracked elsewhere
- Timelines with equal ordinals, and ordinals that aren't whole numbers, keep
Document.timelines' order. The reader sorts that dict by id, so in a real file a tie goes by id, not by the file's order. That's on TimeLineAnnotator/project-management#7.test_choosing_the_time_map's "ties in file order" holds only for documents built in memory. time_map_ofdrops the warnings. The index should callfind_time_mapto report them: TimeLineAnnotator/project-management#157 (fill positions and measures from WP9's time map).
|
🤖 Claude Points 1–10 are fixed in six commits, one for each concern, with the tests written first. Each new test failed before its fix.
Every guarantee still fails a test when its code is removed: 22 rows, in the description. Python 3.10 passes: 251 passed, 7 xfailed. |
find_time_map(document, roles=None) returns a TimeMapFound: the time map of the beat timeline whose role is time_map, else of the first beat timeline by ordinal (guessed), with the chosen timeline's id, the reason when it gives no time map, and warnings. The roles are the caller's when given, else each timeline's metadata["role"]. When several beat timelines claim the role, the first by ordinal wins, with a warning. No other timeline stands in for a chosen one that gives no time map. time_map_of is find_time_map(...).time_map, and measure_rows(document, timeline_id) gives a beat timeline's computed table, folded tables included. The warning that the last beat is at or after the end of the media moves from _closing_barline, which logged it each time a table was built, into TimeMapFound.warnings; find_time_map logs it once. test_timemap_prototype.py's test_choosing_the_time_map runs without its strict xfail, now that find_time_map exists. Part of TimeLineAnnotator/project-management#216 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A frozen result that held a list couldn't be hashed, and could be changed through its list. Part of TimeLineAnnotator/project-management#216 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The closing barline and its warning each made the comparison, one as the other's negation, so a NaN length gave a warning that the barline ignored. Both call _ends_by now. Part of TimeLineAnnotator/project-management#216 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…asure_rows Part of TimeLineAnnotator/project-management#216 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The warning is returned and logged once for each call of find_time_map, which the index makes once when it reads a file. Part of TimeLineAnnotator/project-management#216 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A table whose beats are out of time order, or has two at the same time, gives no rows, as for a beat without a time, so no measure ends before it starts. build_rows and build_time_map share the check (disorder), and build_time_map's reasons don't change. Part of TimeLineAnnotator/project-management#216 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
find_time_map warns about every beat timeline whose last beat is at or after the end of the media, the time map's or not, folded tables too, once for each call; each warning names its timeline. A table without rows gets none. table.media_warning reads the table, so TimeMap's private beats are no longer needed. Part of TimeLineAnnotator/project-management#216 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Metadata values may be lists: a role that is one claims time_map when it holds it. Part of TimeLineAnnotator/project-management#216 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The reader doesn't refuse one yet, so a file with one got a TypeError instead of a time map. A whole number written as a float sorts by its value. Part of TimeLineAnnotator/project-management#216 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
e700adb to
75a1d4f
Compare
|
🤖 Claude Point 11 is done, and the branch is rebased onto
Checks:
|
|
🤖 Claude The two overflow bugs from the third review are fixed in two commits, each with tests that failed before its fix (an
What fails when each fix is removed (
The early return has no test of its own: once Runs: Left alone: |
table_problem gives the reason a table has no rows: a beat without a usable time, a row whose beats don't start at its downbeat, or two beats at the same time or out of time order. build_rows, media_warning and build_time_map share it, so they can't drift apart; the docstrings of build_rows and measure_rows name it. Part of TimeLineAnnotator/project-management#216 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EPS moves to table.py, which timemap.py imports, in place of a second constant with the same value. Part of TimeLineAnnotator/project-management#216 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hash(found) == hash(found) couldn't fail once hash returned. Part of TimeLineAnnotator/project-management#216 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It has no rows, so no closing barline. Part of TimeLineAnnotator/project-management#216 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The reader keeps media.length as it finds it. Text made find_time_map raise TypeError, and true counted as one second. The format allows a number above zero or null, so anything else (text, a bool, NaN, infinity, zero or less) now caps nothing and warns about nothing. A NaN closing barline no longer depends on min's argument order. Part of TimeLineAnnotator/project-management#216 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A table with one beat ends its only measure on the beat, so the warning no longer says the last measure ends one gap after it. media_warning builds the text itself, and past_media goes. Part of TimeLineAnnotator/project-management#216 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The warning names every claimant in ordinal order, instead of saying "two" whatever the count: "timelines A, B and C claim the role time_map; A, the first by ordinal, is used". find_time_map logs it like the media warnings, once for each call, so a caller of time_map_of doesn't lose it. Part of TimeLineAnnotator/project-management#216 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A role that is a list claims time_map, so the mapping's values are str | list[str] | None, as tla.Metadata's are. Part of TimeLineAnnotator/project-management#216 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A bare #7 links to desktop's own issue. The decisions are cited by the issue that records them. Part of TimeLineAnnotator/project-management#216 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JSON reads a whole number of any size as an int, and the reader keeps a length of 400 digits, while it refuses 1e999. math.isfinite raises OverflowError on such an int, so find_time_map raised instead of reading the file without a length. A shared _is_number check now catches it, and media_warning doesn't read the length for a timeline without beats. Part of TimeLineAnnotator/project-management#216 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_has_time called math.isfinite, which raises OverflowError on a whole number too large for a float. Since find_time_map checks every beat timeline's table for the media warning, one such beat in any of them stopped the whole document. It now shares _is_number with the media length, so the beat has no time: its timeline alone has no rows and no time map, with "a beat without a time (measure N)". Part of TimeLineAnnotator/project-management#216 See TimeLineAnnotator/project-management#319 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The expected warning was built by a copy of the code's own formatting, so a wrong join would pass. The tests now give the timelines fixed ids and spell the text out: "timelines A, B and C claim the role time_map; A, the first by ordinal, is used". Part of TimeLineAnnotator/project-management#216 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JSON reads "length": 7 as an int, which the format allows, and no test used one: a length check narrowed to floats would have dropped the cap and the warning for such a file unnoticed. Part of TimeLineAnnotator/project-management#216 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
media_warning ran table_problem, which reads the table's times, then read them again. _checked gives the problem and the times together. Part of TimeLineAnnotator/project-management#216 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The closing barline and the media warning each decided whether the last measure ends one gap after its last beat. _last_gap gives the last beat and the gap to both, so the warning can't describe a gap the barline doesn't have. Part of TimeLineAnnotator/project-management#216 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A timeline without a table, or with an empty one, has no rows and no problem either. Part of TimeLineAnnotator/project-management#216 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_out_of_order moves next to _same_time, so the media test can name both, and a rename shows up where they're used. Part of TimeLineAnnotator/project-management#216 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Part of TimeLineAnnotator/project-management#216 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ec81f9d to
27ae7f7
Compare
azfoo
left a comment
There was a problem hiding this comment.
🤖 Claude
A third code-review run, on the six commits that answered the review of 2026-10-14, as they stood at 75a1d4f0. I reproduced 1. All of it was fixed before the merge; the reply below says where.
Bug
- A whole-number media length too large for a float raises (
table.py,_media_length).- The reader keeps
media.lengthas JSON reads it, so"length": 1followed by 400 zeros is the int10**400. - The new
math.isfinite(length)raisesOverflowErroron it, sofind_time_map,time_map_forandmeasure_rowsall raise. Before these commits they read the file without a length. _has_timehas the same hole for beat times (TimeLineAnnotator/project-management#319 (a beat time too large for a float)). It matters more now thatfind_time_mapchecks every beat timeline's table.
- The reader keeps
Tests
- The claimants' tests compare the code with a copy of itself.
_claimbuilds the expected text with the same join as_several_claim. If both dropped " and ", every test would still pass. Give the timelines fixed ids, and spell the decided text out: "timelines A, B and C claim the role time_map; A, the first by ordinal, is used". - No test uses a valid whole-number media length. Every valid length tested is a float, and the only ints tested are the refused
0andTrue. If the check were narrowed to floats, a file whose JSON says"length": 20would lose its cap and its warning, and nothing would fail. test_a_table_out_of_time_order_gets_no_media_warninglooks its factories up by name withglobals()[make](), only because_out_of_orderis defined further down. A rename would break it only at run time. Move_out_of_ordernext to_same_time, and pass the functions.
Smaller
- Commits:
991d1986holds four points (thetable_problemrefactor, oneEPS, docstrings, two test changes). desktop's CLAUDE.md asks for each in its own commit. - One number check:
_media_lengthrepeats_has_time's check (not a bool, an int or a float, finite). One_is_numberhelper, used by both, fixes point 1 once for both. - One pass in
media_warning: it runstable_problem, which reads the table's times, then reads them again with_times. - One rule for the gap:
media_warningdecides whether there is a gap after the last beat withlen(times) > 1, and_closing_barlinewith whetherbeforeis empty. They agree today, but nothing ties them together, so the warning could come to describe a gap the barline doesn't have. table_problem's docstring says it gives why the table can have no rows, elseNone. But a timeline without a table, or with an empty one, has no rows and getsNone.- Comments: three comment lines in
test_timemap_choose.pyare over 88 columns, one of them two sentences on one line.
|
🤖 Claude All ten points are fixed. The branch is now 27 commits on
Checks:
|
🤖 Claude
Choosing a document's time map by role, in
tilia_core.timemap.What changed
find_time_map(document, roles=None) -> TimeMapFound(timemap/__init__.py):metadata["role"]. A given mapping replaces the file's roles: a timeline it leaves out has no role. A role that is a list claimstime_mapwhen it holds it.time_map, the first byordinalwins. When several claim the role, a warning names them in ordinal order: "timelines A, B and C claim the role time_map; A, the first by ordinal, is used".ordinalis taken,guessed=True. Timelines of other kinds never count, even with the role.ordinalthat isn't a whole number (the reader doesn't refuse one yet) sorts after the others, so nothing raises. A whole float (2.0) sorts by its value.time_mapisNone, withtimeline_idand thereasonfrombuild_time_map.TimeMapFound(None, None, False, None, ()), with no reason, since nothing is wrong with it.warningsand logged once for each call.TimeMapFoundis frozen, and itswarningsare a tuple, so it hashes.table._closing_barline, which logged it each time a table was built, tofind_time_map.find_time_mapchecks every beat timeline, whether or not it is the time map, folded tables included. Each warning names its timeline:Timeline <id>: the last beat (20.0) is at or after the end of the media (19.5); the last measure ends one gap after it._last_gap.time_map_forandmeasure_rowslog nothing about the media.find_time_mapraiseTypeError, and a 400-digit lengthOverflowError._has_timeand_media_lengthshare one check,_is_number, which catches theOverflowErrormath.isfiniteraises on such an int. Its timeline alone has no rows and no time map ("a beat without a time (measure N)").build_rowsgives no rows while two beats are at the same time or out of time order, as it already did for a beat without a time, so no measure ends before it starts.table.table_problem(timeline)gives the reason for all of these, andbuild_rows,media_warningandbuild_time_mapshare it.build_time_map's reasons are unchanged.media_warninggets the reason and the times in one pass.EPSlives intable.py, andtimemap.pyimports it.time_map_of(document, roles=None)returnsfind_time_map(...).time_map.measure_rows(document, timeline_id)gives a beat timeline's computed table throughbuild_rows. That includes folded tables, which have no time map. It gives no rows whentable_problemfinds one, and none for an unknown id or a timeline that isn't a beat timeline._beat_timelineholds the lookup it shares withtime_map_for.__all__lists the names of thetilia_core.timemapAPI that exist so far:MeasureRow,Positions,TimeMap,TimeMapFound,find_time_map,measure_rows,time_map_for,time_map_of.meters_ofstays intimemap.metersfor now, because it takesrows_of's rows, which are private.tests/timemap/test_timemap_choose.py.test_media_ending_at_or_before_the_last_beat_leaves_the_gapnow checks that building rows logs nothing.test_timemap_folded_table_is_refused_before_its_rows_are_builtused the media log to tell whether rows were built, and that log is gone. It now replacesbuild_rows_fromwith a function that fails if called.test_timemap_prototype.py::test_choosing_the_time_map, from Port the prototype's time-map self-test to tilia_core.timemap #734 (port the prototype's time-map self-test), loses its strictxfailnow thatfind_time_mapexists.Tests run
v1-prereleasewith feat(core): time signatures in the measure table, and the shared meter examples #736 (time signatures in the measure table) and feat(core): move the beat pattern parser into tilia_core.timemap #739 (the beat pattern parser). Each of the 27 commits passes the package's tests on its own.uv sync --all-packages, thenuv run --package tilia-core pytest packages/tilia-core -q: 853 passed, 21 skipped, 7 xfailed (Python 3.11). The 7 xfails are Port the prototype's time-map self-test to tilia_core.timemap #734's distance tests, which wait forposition,lengthandunit_seconds.tests/timemapon Python 3.10, against the working tree (--with-editable): 375 passed, 18 skipped, 7 xfailed.python scripts/check_packages.py: all package checks passed.Guarantees: the code removed, and what failed (
tests/timemap: 375 passed, 18 skipped, 7 xfailed)ordinalwarningsfind_time_map_is_numberwithout itsOverflowErrorguard_media_lengthpassing the length on unchecked, as before_closing_barlinebuild_rowschecking onlymissing_timeandbroken_rowmeasure_rowsgives no rows while a beat has no usable timebuild_rows_from(rows_of(...))without the checksmeasure_rowsandtime_map_forgive nothing for a timeline that isn't a beat timeline_beat_timelineremovedTimeMapFoundhasheswarningspassed as a listAfter each mutation the code was restored, and the tests passed again (375 passed, 18 skipped, 7 xfailed). No claimed guarantee is untested.
Part of TimeLineAnnotator/project-management#216
🤖 Generated with Claude Code