Fix TS guess execution times zeroing on restart, relative energies compounding on re-report, and align the TS guess report - #988
Open
calvinp0 wants to merge 3 commits into
Open
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #988 +/- ##
==========================================
+ Coverage 64.60% 64.64% +0.03%
==========================================
Files 119 119
Lines 39785 39830 +45
Branches 10307 10322 +15
==========================================
+ Hits 25703 25748 +45
- Misses 11105 11111 +6
+ Partials 2977 2971 -6
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
calvinp0
force-pushed
the
fix_ts_guess_time_reporting
branch
from
August 16, 2026 18:02
2320126 to
e41a070
Compare
calvinp0
force-pushed
the
fix_ts_guess_time_reporting
branch
2 times, most recently
from
August 22, 2026 06:45
d52b1c5 to
5f281e5
Compare
calvinp0
marked this pull request as ready for review
August 22, 2026 08:53
calvinp0
force-pushed
the
fix_ts_guess_time_reporting
branch
from
August 23, 2026 08:55
5f281e5 to
f806aab
Compare
…ion time timedelta_from_str() claimed to invert str(datetime.timedelta) but implemented a '1hr2m3s' grammar instead. Every group in that regex was optional, so the pattern also matched the empty string: any input in the real str(timedelta) form matched zero characters and the function returned timedelta(0). TSGuess.from_dict() is the only caller, and it feeds it exactly that form - TSGuess.as_dict() writes str(self.execution_time), and the gcn, goflow, kinbot and rits scripts all write str(datetime.datetime.now() - t0). So every TS guess's execution time silently became zero on restart. ARC's own restart fixture arc/testing/restart/5_TS1/restart.yml stores '0:00:05.357294' for seven heuristics guesses, all of which restored as zero. TS guess cost per method is a reported benchmark quantity, so this corrupted data rather than only a log line. Both grammars are kept. Nothing in the repository or its history produces or persists the '1hr2m3s' form - the regex arrived with the function in bd168da and never had a producer - but the two grammars are disjoint (one uses colons, the other letters), so accepting both costs nothing and avoids silently removing behaviour from a public helper in arc/common.py. The str(timedelta) grammar is tried first and is anchored at both ends, which is what stops the empty match. Unparseable input now returns None with a logged warning rather than raising. The caller assigns the result straight to TSGuess.execution_time, where None is already a first-class value - as_dict() guards it with `is not None` and scheduler.py str()s it - whereas raising would abort restart of an otherwise valid project. That is not hypothetical: arc/testing/restart/2_restart_rate/ restart.yml persists a legacy execution_time of '0', which no duration grammar accepts. The warning is what keeps the failure visible; returning a plausible-looking zero is what hid this bug. An exact zero stays distinguishable from a parse failure: '0:00:00' parses to timedelta(0), unparseable input yields None. The existing test asserted only isinstance(result, datetime.timedelta), which timedelta(0) satisfies, so it passed forever while enshrining the bug. It now asserts parsed values, and a round-trip test covers sub-second, multi-day, zero and negative durations. Two rendering helpers are added here as well, for the TS guess report that the following commit rewrites as a table. format_table() sizes each column to its own widest entry, header or cell, and supports a multi-line header so a units line can sit under a title. It validates its inputs and raises InputError for a ragged row, an alignment character other than '<', '>' and '^', and a non-string cell, rather than letting those surface as a TypeError or a ValueError from inside the renderer; a column whose multi-line header is empty and which has no rows now renders at width zero instead of raising from max() on an empty sequence. Its docstring states that widths are counted in characters, which is the contract a caller needs in order to know that alignment holds for single-width text; no display-width measurement is implemented, because the only cell that could carry a full-width or combining character is Status, built from tsg.errors, which is ASCII as an ESS emits it. format_duration() reports a duration in the largest unit it fills, to one decimal place ('3.4 s', '47.2 m', '13.1 h', '2.1 d'). Most TS guesses finish in seconds and per-method guess cost is a reported benchmark metric, so the report has to resolve a 3.4 s heuristics guess from an 18.1 s AutoTST one, which a whole-minute format cannot. One decimal place in a self-naming unit keeps that resolution and spans seconds to days while staying short enough to sit in a column without a unit line of its own. The unit loop covers the three bounded units and days are the terminal return after it, rather than a fourth entry whose limit is a None sentinel: the sentinel entry always matched, so the function could only return from inside the loop even though it is annotated `-> str`, which is what CodeQL's py/mixed-returns flags. Structuring it as "three bounded units, then days as the unbounded fallback" removes the implicit fall-through without leaving a line after the loop that can never execute. time_lapse() is corrected in the same file. It computes its day count with divmod() on a float, so `str(d)` rendered it as '1.0' and the function returned '1.0 days, 06:00:00' - which matches neither the "D HH:MM:SS" format its own docstring claims nor Python's own str(timedelta). Formatting the count with '.0f' makes it a whole number. Sub-day output is untouched. This also removes an inconsistency inside the module this commit restructures: arc/common.py now holds one duration parser, and timedelta_from_str(), newly anchored, rejected the day form that time_lapse() produced. Nothing feeds one into the other today, so the defect was latent, which is also why no test caught it - there was no coverage above 24 hours at all. There is now, and it asserts the round trip through timedelta_from_str() as well as the rendered string. Searched arc/common.py, arc/plotter.py and the rest of arc/ for an existing column, table or padding helper (def .*(pad|align|column|width|table|format), ljust/rjust/center, tabulate/PrettyTable, "max(len(") and for a duration formatter (time_lapse, timedelta_from_str, convert_to_hours). No table helper exists; the only comparable code is the Arkane thermo table in arc/statmech/arkane.py, which hand-rolls its widths inline. Rather than add a second copy of that, the renderer goes in arc/common.py, which is where the duration formatter belongs too. arkane.py is left alone because converting it would change thermo log output this change is not otherwise concerned with; it is the obvious next caller. format_duration() does not carry a duration grammar of its own. It delegates the string case to timedelta_from_str(), so the module holds exactly one parser for the str(timedelta) form and the two cannot drift apart - which they already had: a parser written privately against the broken timedelta_from_str() allowed no sign on the days field and so failed on '-1 day, 23:59:59', which timedelta_from_str() reads. Delegating is only correct once timedelta_from_str() is fixed; against the old implementation every colon-form duration would have rendered as '0.0 s'. The contract seam is preserved in both directions. timedelta_from_str() returns None for unparseable input and format_duration() returns '' for it, so the composition still yields ''. An exact zero stays distinguishable from a failure: '0:00:00' renders '0.0 s', garbage renders ''. Negative durations now parse, where the private regex rejected them outright, and format_duration() still reports them as '' - a negative guess duration is not worth displaying, and that was the behaviour before. An empty or blank string is treated as an absent duration and short-circuits before the parser, so a missing execution time does not log a parse warning once per report row; a non-blank string that is genuinely malformed still warns, which is the signal worth keeping.
The per-guess lines logged by determine_most_likely_ts_conformer() were prose
with embedded labels, and they did not line up: three of the four fields ahead
of the free-text tail were rendered at their natural width. The method string
is 7-24 characters, longer still when a clustered guess adds an "(also: ...)"
suffix; the execution time varies; and the index format spec hard-coded a
width of 2, so an index of 100 or more shifted the whole line. Only the
relative energy was padded, at a fixed width of 8 that was both wider than the
data needs and able to overflow.
The block is now a table with a header, a rule, and one row per guess:
TS Guess, Method, Rel. Energy (kJ/mol), Guess Time, Img Freq (cm-1), and
Status. Each column is sized to its own widest entry, computed over the
guesses that are actually reported, i.e. those that pass the "success and
energy is not None" filter, so a guess that is filtered out cannot leave a
permanently over-wide column. Sizing them needs the rendered cells before the
first line is emitted, so the guesses are collected in the existing loop,
which already mutates tsg.energy into a relative energy, and logged in a
second pass.
The whole table is emitted before any structure is drawn. plotter.draw_3d()
opens with an unconditional logger.debug('not drawing 3D!'), so drawing each
guess right after its own row put that line between the rows and destroyed the
alignment whenever ARC runs at DEBUG - and verbose is a documented user-facing
ARC input, persisted to the restart dict when it is not INFO, so DEBUG is a
supported mode rather than a developer-only one. Splitting the loop keeps the
table contiguous. The draws themselves are unchanged: one per reported guess,
in row order, still with method='draw_3d', because for a TS only draw_3d() may
be used - show_sticks() infers connectivity and gets it wrong for a structure
with partial bonds.
The guess time cell comes from format_duration(), which reports the duration in
the largest unit it fills to one decimal place. Most TS guesses finish in
seconds and per-method guess cost is a reported benchmark metric, so the column
has to resolve a 3.4 s heuristics guess from an 18.1 s AutoTST one; the previous
code truncated str(tsg.execution_time) at one decimal place, which was legible
only because it happened to be sub-minute, and a whole-minute format would
collapse every row of a real block to one value.
The Status column carries tsg.errors, which was previously appended to the end
of the prose line, and is added only when a reported guess actually has an
error: errors are recorded exclusively on guesses with success False (see the
loop at the end of troubleshoot_ess), which this block filters out, so the
column would otherwise always be blank. The imaginary frequencies, previously
spelled out mid-sentence, are now just the Img Freq column.
The relative-energy conversion is also made idempotent, which is a pre-existing
defect this commit fixes rather than one it introduces - the same subtraction,
with the same asymmetry, is on main. e_min is the lowest energy of ANY guess,
successful or not, but `tsg.energy -= e_min` was applied only to the successful
ones. A second invocation for the same label therefore saw a set in which the
successful guesses already held relative energies while the unsuccessful ones
still held absolute ones; if the global minimum sat on an unsuccessful guess,
e_min stayed at that absolute value and every successful energy shifted again.
Measured on main, for guesses at +10 and +20 that succeeded and one at -100 that
did not: [110.0, 120.0, -100.0] after the first call, [210.0, 220.0, -100.0]
after the second. Repeat invocation is reachable from switch_ts() and from two
call sites in arc/job/pipe/pipe_coordinator.py.
The subtraction is now applied to every guess that has an energy. This keeps the
reference point exactly where it was - the lowest energy of any guess - so no
energy reported on a first invocation changes, which matters because those
numbers are physical and feed the selection. It also makes the set minimum
exactly zero, so a second invocation subtracts zero and is a no-op. Computing
e_min over only the successful guesses would have been idempotent too, but it
moves the reference point whenever the global minimum sits on an unsuccessful
guess, and so changes reported energies; that was rejected for exactly that
reason.
The values newly mutated are the energies of unsuccessful guesses. Two things
read them. plotter.save_conformers_file() takes the energies of all guesses and
re-baselines them against their own minimum, so a uniform offset leaves its
output unchanged; it was previously handed a mixture of relative and absolute
values, and now receives one consistent baseline. TSGuess.almost_equal_tsgs()
compares two energies with isclose(abs_tol=0.1), a difference, which a uniform
offset also leaves unchanged. as_dict() persists the energy, so a restart now
restores an already-relative set whose minimum is zero, on which the conversion
is again a no-op.
TSGuess.from_dict() parsed ts_dict['execution_time'] near the top and then, for a 'user guess' method, overwrote the result with timedelta(seconds=0) at the bottom. Now that an unparseable duration is reported rather than silently read as zero, that ordering makes ARC warn about values it throws away: loading arc/testing/restart/2_restart_rate/restart.yml, whose four TS guesses are all 'user guess' entries carrying a legacy execution_time of '0', emitted three "Could not interpret '0' as a time delta" warnings per restart for a field whose restored value cannot depend on it. The method is now resolved before the execution time, and the execution time is not parsed at all when the method is a user guess, since the later branch is the authority on that value. Nothing else moves, and the warning is untouched for every guess whose execution time is actually kept - an unparseable duration on a kinbot or heuristics guess is a real loss of benchmark data and still says so. TSGuess.as_dict() writes str(self.execution_time) and from_dict() reads it back, so that pair is the path on which every TS guess's execution time was silently reset to zero on restart. Nothing tested the pair itself: the parser had a unit test that asserted only isinstance(result, datetime.timedelta), which timedelta(0) satisfies, so the defect survived a green suite. The new TestTSGuess cases round-trip a sub-second duration, a multi-day one and an exact zero through as_dict()/from_dict() and assert the restored value equals the original, load the restart fixture and assert it produces no warning, and assert that a non-user-guess entry with the same unparseable value still warns. That pins the behaviour at the level a restart actually exercises rather than at the level of the regex.
calvinp0
force-pushed
the
fix_ts_guess_time_reporting
branch
from
August 23, 2026 12:14
f806aab to
7b1ac74
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Three defects in how ARC records, computes and reports TS guess execution times and relative energies. They are in one PR because they meet in the same two files, and the second cannot be fixed properly without the first.
1. Every TS guess's execution time is zeroed on restart
arc/common.py::timedelta_from_str()documents itself as the inverse ofstr(datetime.timedelta). It is not. It implements a'1hr2m3s'grammar:Every group is optional, so the pattern also matches the empty string.
re.matchon a real duration matches zero characters, produces no groups, and returnstimedelta(0). Measured onmain:TSGuess.from_dictis the sole production caller, and it is fed exactly thestr(timedelta)form thatTSGuess.as_dictand the gcn / goflow / kinbot / rits scripts write. So every restored TS guess reports zero execution time, silently, with no warning and no failed job.Reproducible from ARC's own shipped test data:
arc/testing/restart/5_TS1/restart.ymlcarries 7×execution_time: '0:00:05.357294', every one of which restores as zero.Nothing in ARC has ever produced the
1hr2m3sform — the regex arrived with the function and never had a producer.Why it was never caught. The test asserted only
isinstance(result, datetime.timedelta), whichtimedelta(0)satisfies. It passed indefinitely and looked like coverage.The fix
Anchored parse of the
str(timedelta)grammar first (days, negative days, fractional seconds), with the compacthr/m/sgrammar retained as an anchored secondary requiring at least one component. The two grammars are disjoint, so keeping the second costs nothing and avoids removing behaviour from a public helper.Unparseable input returns
Nonewith a logged warning rather than a plausible-looking zero.Nonerather than raising, because the caller already treatsNoneas first-class andarc/testing/restart/2_restart_rate/restart.ymlpersists a legacyexecution_time: '0'that no duration grammar accepts — raising would abort restart of an otherwise valid project. Exact zero ('0:00:00') stays distinguishable from a parse failure.2. The successful-TS-guess log block does not line up
Real output from a benchmark run:
Method:runs 7–24 characters wide, so every later column starts at a different offset.Two further latent defects in the same statement:
f'{tsg.index:2}'raisesTypeErroroutright when the index isNone— andarc/testing/restart/2_restart_rate/restart.ymlpersists 4×index: null. That is a live crash in the reporting path onmain:The energy width was also fixed at 8, simultaneously wider than real data and able to overflow.
The fix
Column widths are derived from the guesses that are actually reported — those passing
success and energy is not None— so a filtered-out guess cannot leave a permanently over-wide column.for TS0.is dropped from every row; it is already in the block header.The Status column is conditional, appended only when a reported guess carries an error.
tsg.errorsis written only wheresuccessis False, which is exactly the set this block filters out, so an unconditional column would be blank essentially always.Adds
arc/common.py::format_table()andarc/common.py::format_duration().3. Relative energies compound when a TS is re-reported
determine_most_likely_ts_conformer()converts each guess's absolute energy into a relative one in place, withtsg.energy -= e_min.e_minis computed over every guess that has an energy, but the subtraction is applied only to guesses that aresuccess and energy is not None.On a repeat invocation for the same label the two sets disagree: the successful guesses already hold relative energies while the unsuccessful ones still hold absolute ones. If the global minimum sits on an unsuccessful guess,
e_minstays large and every reported energy shifts again:Repeat invocation is reachable —
switch_tsin the same file, and two call sites inarc/job/pipe/pipe_coordinator.py. Where the minimum happens to sit on a successful guess the conversion is idempotent, which is why this has gone unnoticed.This is pre-existing on
mainand untouched by the two defects above; it is fixed here because it lives in the same function and the same commit.The fix
The conversion is made idempotent without moving the reference point, so the energies reported on a first invocation are unchanged and a repeat invocation is a no-op. This constraint rules out the otherwise natural repair of computing
e_minover only the successful guesses: that is idempotent by construction, but it silently changes every reported energy whenever the global minimum sits on an unsuccessful guess.Covered by a test that invokes the report twice with the minimum on an unsuccessful guess — the arrangement under which the bug actually manifests — and by an assertion that first-invocation energies are unchanged, so a reference-point shift cannot pass unnoticed.
Why these are one PR
format_durationrenders a duration for the table. Its input is the samestr(timedelta)form that defect 1 could not parse, so onmainit would have to carry its own private copy of that grammar — and a duplicate parser for one grammar in one module is how two copies drift.format_durationtherefore delegates to the correctedtimedelta_from_str, which is only possible once defect 1 is fixed.An earlier iteration of this work did carry both parsers, and they had already diverged before merging: the formatter's
(?P<days>\d+)rejected'-1 day, 23:59:59', which the parser accepted.Defect 3 is in the same function as defect 2 and therefore the same commit.
Commits are split by concern so they can be read separately — the parser fix and helpers, then the report, then the restart regression test. Each file is touched by exactly one commit.
A note on the duration format
An earlier revision of this branch rendered durations as
DD:HH:MM, rounded up to the minute. That collapsed every distinct timing in the source log to00:00:01— heuristics at 3.4 s and AutoTST at 18.1 s became the same cell, erasing a 5× per-method cost difference. Most TS guesses are sub-minute, so this was the common case rather than an edge case.The current format uses the largest unit the duration fills, to one decimal (
3.4 s,16.8 s,1.1 h,2.0 d), so sub-second resolution survives and every cell names its own unit.Checks
Every new and changed test proved to fail against
mainbefore the fix: the restart round-trip, thestr(timedelta)round-trip, the table cases,format_duration, the "both entry points read a string identically" test, and the repeat-invocation energy test.Parity, same command and environment, per touched file:
mainarc/common_test.pyarc/scheduler_test.pyarc/species/species_test.pyFull suite on the branch: 2765 passed, 36 skipped, 5 failed — the 5 are
torch_ani_test.pyand fail identically onmain.The delegation was checked at the seam rather than assumed:
'0:00:00'renders0.0 swhile unparseable input renders'', so exact zero stays distinguishable from a parse failure. Fractional seconds are right-padded, so'0:00:03.4'is 3.4 s and not 3 s plus 4 microseconds. A brute-force sweep of 90,271 durations found no value rendered at or above its own unit limit.One deliberate narrowing:
format_duration('100:00:00')returns'', because the anchored grammar bounds hours at two digits.str(timedelta)never emits hours >= 24 — it splits days off. An earlier revision of this branch accepted it;mainhas no such function.Two of the tests replaced here were of the kind that cannot fail for the right reason — one asserted a return type, the other an output format. Both are now assertions on the property the value carries.
Known and deliberately not addressed
time_lapse()renders'1.0 days, 06:00:00'for durations over a day, which the newly anchored parser rejects. Nothing feedstime_lapseoutput intotimedelta_from_str, so this is latent; it is noted rather than fixed to keep this PR bounded.format_table()measures width withlen(), so a cell containing full-width or combining characters would misalign. The only cell that could carry one is Status, andmain's prose line had the same property.