util: bound what equation() renders, and make rendering it in full cheap (fixes #582) - #603
Merged
Merged
Conversation
…eap (fixes #582, fixes #602) A derivation has no bound -- a data-driven `total = total + row` loop records one step per iteration -- and equation() rendered every one of them. 100,000 steps produced a 500,001-character first line in 58.8 s (clang 22.1.8, -O1 under ASan+UBSan); a caller printing it emitted a half-megabyte line. #574 called that out ("an explanation 200,000 steps deep is not an explanation") and deferred it, because capping changes a documented output contract rather than fixing a defect. So this changes the contract, deliberately and in the spec first. equation() takes `maxSteps`, defaulted to kDefaultEquationSteps (100). Past it a sub-derivation is elided: it renders as `eK` in the formula, as its value in the substitution, and takes a legend line of its own, `e1 = 99900 (elided at the 100-step limit)` -- self-describing, and it names the limit, so a caller meeting an `eK` can tell it from an ordinary value without reading the spec. The result line is untouched: eliding changes the account of how a value was reached, never the value. Same input, same build: 502 characters in 0.057 s. The limit is a parameter rather than a constant because how much of a derivation is worth reading belongs to the layer doing the reading, not to the value type -- an audit log and a tooltip do not want the same answer. Its default sits at the human end of the range rather than at the affordable end, because the two errors are not symmetric: a default too low costs one argument, a default too high costs everyone a line nobody can read and never knew was unbounded. kEquationStepsUnlimited restores the old rendering; 0 gives the formatted value alone. The second half of #582 is the quadratic cost, and it wanted its own answer. `combine` now appends to its left operand instead of concatenating both sides into a fresh string, so the left-leaning chain an accumulate loop records is linear in its depth: rendering 70,000 steps in full went from 27.7 s to 0.11 s under ASan+UBSan, and from past ctest's 120 s timeout to 1.3 s at -O0 under TSan. That is what lets the #574 depth regression test keep its evidence -- it now asks for kEquationStepsUnlimited, because under the default limit the renderer stops 100 steps in and would pass against the recursive code it exists to catch. It also retires #590's `[slow]` tag and 600 s timeout exception: every test is back under one 120 s cap. A right-leaning chain and a chain of unary negations still copy the big operand per level and are still quadratic; the step limit bounds those, and the spec says so. #602, found on the way and fixed here because the limit depends on it: assignLabels was the one walk without a visited set, so a node reachable by k displayed paths was walked k times. On a DAG that is exponential -- 31 nodes built by repeated `q = q + q` have 2^30 paths and took 10.3 s to render 33 short lines. Without the set the step budget would have been spent on repeat visits and the limit would fire on derivations far smaller than 100 distinct steps. Measured, clang 22.1.8, -O1 under ASan+UBSan, `total = total + one` n times then equation(); median of three runs: n before after (default) after (unlimited) 40,000 7.544 s/200,001c 0.020 s/502c 0.074 s/200,001c 70,000 27.720 s/350,001c 0.024 s/502c 0.112 s/350,001c 100,000 58.837 s/500,001c 0.057 s/502c 0.157 s/500,001c The new tests were checked against a mutant whose budget never exhausts: four assertions fail, all of them the size of the output. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GS5K2vqZtC4xbRiGJHT7jH
Yaraslaut
force-pushed
the
fix-582-equation-step-limit
branch
from
September 20, 2026 01:55
a5f5c5b to
5b2e909
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
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.
Fixes #582. Fixes #602.
#582 asked for the depth cap #574 deferred. #574 deferred it because it changes
equation()'s documented output contract rather than fixing a defect — so theengineering here is the small half. The decisions are below, and they are in
docs/spec/util/quantity_type.md("The rendering is bounded; the derivation isnot") rather than only in this description.
1. What the limit is, and what N is
equation(std::size_t maxSteps = kDefaultEquationSteps), withkDefaultEquationSteps == 100.A step is one operation node written out. Atoms (leaves, conversions) and
named nodes render as a single token and cost nothing; only a node whose
operation is spelled out does. The budget is spent once per call, across the
formula and the legend together, in the pre-pass that already assigns
cNplaceholders — so the symbolic rendering, the substituted rendering and every
legend line consult one shared decision and cannot disagree about what was
written out. A per-rendering budget could not guarantee that, because the legend
renders subtrees the formula stops short of.
Why 100, and why that is not an arbitrary constant. It is a readability
bound, not a cost bound, and the two would give very different numbers:
formula stops being one long before it stops being affordable — 100 written-out
steps is already more than anyone reads, and two orders of magnitude above any
derivation in this repository's examples. Setting the default where the cost
becomes intolerable instead (thousands of steps, on the measurements below)
would keep producing output nobody can use, which closes the ticket without
fixing the thing it is about.
the limit is a parameter, a default set too low costs a caller one argument. A
default set too high costs everyone the half-megabyte line, and they cannot opt
out of what they never knew was unbounded. So the default sits at the human end
of the range.
I did not come back for product input on N, and the reasoning above is why:
the question "what is the right N" only needs a product answer if N is fixed.
Making it a parameter turns it into "what default is safest", which is
answerable from the asymmetry, and leaves the actual policy where invariant 3
puts it — see §3.
2. What the collapsed output looks like, exactly
Past the limit a sub-derivation is elided:
eKin the formula (e1,e2, …,numbered in first-appearance order exactly as
cNis), its value in thesubstitution, and one legend line of its own. Real output, 70,000-step chain,
default limit:
an
e1for the first time can tell it from an ordinary value without readingthe spec. That is the whole point of not simply truncating to the value.
was reached, never the value.
gets an
eK, not acN, because acN's legend line would expand the verysubtree the limit just declined to render. (This is also a correctness
constraint, not only taste —
atomRenderinghas to test elision beforeisPlaceholder, or it looks up acNlabel that was deliberately neverassigned.)
every node: reuse is a property of the derivation, not of how much gets
printed, and a value shown once must not earn a placeholder because the limit
hid its other uses.
left-before-right pre-order, so what survives is the steps nearest the result
and what collapses is the deep end. On the running-total shape that is the
right way round — the last 100 additions stay legible, the accumulated history
folds into one number.
kEquationStepsUnlimitedrestores the pre-util: equation() renders an unbounded derivation in full, quadratically — #574's deferred depth cap #582 rendering.0returns theformatted value alone — the same one-element answer a tracing-off build gives,
which is the only coherent reading of "show me no steps".
3. Configurable, and the reasoning rather than the assertion
Configurable. The triage skill's invariant 3 — the value type reports the fact,
the layer decides the policy — is the argument for it, and I think it holds
here rather than being invoked: how much of a derivation is worth reading is a
property of who is reading. An audit log wants all of it; a tooltip wants a
line.
Quantitycannot know which it is talking to, and a fixed constant wouldmake that guess on every caller's behalf, permanently.
The argument against, weighed: a default argument is API surface, and an API
that asks a question most callers do not want to answer is a cost. That is real,
and it is why this is a defaulted parameter and not a required one — the
common caller writes
equation()and gets a sensible answer, exactly as before.It is also why there is no options struct, no builder and no global setting: one
std::size_twith a default is the smallest thing that moves the policy to thelayer that owns it.
4. Whether the cap is the whole fix — no, and the issue was right about that
#582 says there are two problems and they may want different answers. They do.
bounded the moment a caller asks for more. So
combinenow takes its leftoperand by value and appends to it instead of concatenating both sides
into a fresh string. The left operand of a left-leaning chain — the shape
total = total + rowrecords — is the whole expression rendered so far, andcopying it once per level was the quadratic term. Appending makes that shape
linear.
I did not reserve, which was the issue's other suggestion: reserving cannot help,
because the cost is the copy itself, not the reallocation. And the append does
not fix every shape — a right-leaning chain (
a + (b + (c + …))) and a chainof unary negations still copy the big operand per level and are still quadratic,
because prepending is inherently O(n). The step limit is what bounds those, and
the spec says so rather than leaving it implied.
Measurements
Reproduced the issue's own numbers first, then measured the same cases after.
clang 22.1.8,
-O1, ASan+UBSan,total = total + onen times thenequation(); median of three runs. "chars" is the length of the first outputline.
be64026a(the base these were taken on; rebased since ontofbb90181, which touches nothing inmorph::units)kEquationStepsUnlimitedReal output at n = 100,000, after:
The unlimited column is the append's doing alone, not the limit's: 27.7 s → 0.11 s
at 70,000 with byte-identical output.
Non-vacuity: proved by mutation, not asserted
The step limit. Mutated
EquationRenderer's constructor so the budget neverexhausts (
stepBudget(SIZE_MAX)), leaving every other line of the feature inplace, and rebuilt. Four assertions fail, and every one of them is about the
size of the output:
The sections that keep passing under the mutant are the ones that assert the
absence of elision (at exactly the limit, below it, unlimited, zero) — they
should, and they are the guard against the limit firing early.
The assertions spell the whole expected formula out rather than matching a
prefix. A
starts_with("e1 + c1")check would pass just as happily against a350,001-character line, which is precisely the failure this is about.
The append. No test asserts linearity directly, and I am not going to
pretend otherwise — a timing assertion is flaky. What guards it is ctest's 120 s
timeout on the uncapped 70,000-step test, and that guard is real rather than
nominal: reverting
combineto the copying form takes that render from 0.11 s to27.7 s under ASan+UBSan and from 1.3 s to over 120 s at
-O0under TSan, whichis how #589 found it in the first place. Locally, non-sanitized, the same mutation
moves the test binary from 0.376 s to 0.956 s. It is a coarse guard (120 s
against 1.3 s), and it is the only one.
#590's 70,000-node test
Two things happened to it, and the first matters more than the timing.
It had to be changed or it would have gone vacuous. Under the default limit
the renderer stops 100 steps in and never walks deep enough to overflow
anything — so the test would have passed against the recursive code it exists to
catch. It now asks for
kEquationStepsUnlimitedexplicitly, with the reason in acomment above the call. This is the AGENTS.md "would the check still pass if the
feature did nothing" question applied to the old test rather than the new ones,
and the answer was yes.
The timeout can be tightened, so it is. #590 gave the test a
[slow]tag anda 600 s ctest TIMEOUT because #582's quadratic cost pushed it past 120 s under
TSan. Measured after the append, same shape, harness at
-O0under TSan:1.26 s (median of three) against a >120 s failure before. Confirmed against the
real CI build rather than only the harness —
cteston theclang-tsanpresetreports that test at 1.14 s, and on
clang-asanat 1.11 s:So the
[slow]tag andthe separate
catch_discover_testsregistration it keyed on are gone, andtests/CMakeLists.txtis back to one call with one 120 s cap — a ~95× margin.Removing the tag and the
TEST_SPEC "~[slow]"filter together is deliberate:leaving the filter behind with no tagged test would silently drop any future
[slow]test from registration.#602, filed and fixed here
Found while building this:
assignLabelswas the one walk inequation()without a visited set, so a node reachable by k displayed paths was walked k
times. On a DAG that is exponential — 31 nodes built by repeated
q = q + qhave 2³⁰ root-to-leaf paths and took 10.3 s to render 33 short lines,
measured on master. Filed separately as #602 with its own evidence, per
AGENTS.md, rather than buried in this description.
It is fixed in this PR rather than deferred because the step limit depends on
it: without the visited set the budget is spent on repeat visits of the same
nodes, and the limit would fire on derivations far smaller than 100 distinct
steps — a 31-node DAG would elide. That makes it part of this change rather than
a separate finding folded in. The fix is behaviour-preserving (a second visit can
only re-assign labels the node already holds), and the evidence for that is
byte-identical output before and after; 61 nodes / 2⁶⁰ paths now render instantly.
tests/test_quantity.cppgains a 41-node regression case whose failure signal isthe 120 s timeout, as the #574 cases' is a segfault.
Review notes
No Copilot seats on this org, so the review reasoning is here. What I looked at,
in the code rather than in the diff:
labelIndex.at()throws. BothatomRenderingmodes stop at exactly the same node set — named, placeholder,leaf, conversion, elided — and the one asymmetry (
expandThison a named node)cannot arise, because
expandThisis set only for the root and for legendlines,
equation()returns early on a named root, and a named node never earnsa label. Elision is decided once in the pre-pass and read by both, so they
cannot drift.
refCount.at()throw on a new path? It is total for children, and theroot is the only node not in it.
isPlaceholderis still reached only behind!expandThis. The elision check is placed before it precisely so areused-and-elided node does not take the placeholder path.
renderdescendsinto a strict subset of what
assignLabelsdescends into (it also stops atplaceholders), so no.
N+1 entries. Without one it is the old behaviour, which is iterative and was
the point of Fix two shipped value-type defects: provenance chains that overflow the stack, and locale group separators that are stripped instead of validated #581.
combine's move safe at the call site?top.leftis moved from andthe frame is popped on the next statement;
finishedis passed as theconst-ref right operand and assigned only from the returned prvalue.
-DMORPH_QUANTITY_PROVENANCE=0syntax-checks clean; the parameter is
[[maybe_unused]]there, since thatbuild has no derivation to limit.
Verification
ctestonclang-tsanand onclang-asan, with CI's own-E "OomInjector|morph#108"exclusion: 1524/1524 pass on each. (Withoutthe exclusion both legs fail the five
OomInjectorcases andmorph#108—pre-existing and why CI excludes them: the injector's
operator newoverrideis compiled out under a sanitizer.)
ctestonclang-debug: 1530/1530 pass (1529 before this branch; threenew test cases, one of which registers as several sections).
clang-tidy-diffovermaster...HEAD, changed lines, clang 22.1.8: clean.Three findings on the first pass (
rvalue-reference-param-not-moved,identifier-length,pro-bounds-avoid-unchecked-container-access) fixedrather than suppressed —
combinetakes its operand by value,itbecameelided, and the legend loop no longer indexes.clang-format --dry-run --Werroron the changed sources: clean.WARN_AS_ERROR = FAIL_ON_WARNINGS: builds.scripts/check_spec_sync.shagainst the real diff:10 sub-domain(s) classified; every touched header sub-domain has a matching spec change.scripts/check_spec_citations.sh,check_catch_test_names.sh,check_ctest_name_collisions.sh: clean.fastcache-cc(verified inbuild.ninja), not ccache.scripts/branch_partial_allowlist.jsonis untouched — no entry cites aline in either changed header, and
lineis a hint rather than the key, sonothing needed refreshing.
🤖 Generated with Claude Code
https://claude.ai/code/session_01GS5K2vqZtC4xbRiGJHT7jH