Found while implementing #582's step limit, and filed separately because it is a
different defect with different evidence: #582 is about a derivation that is
long, this one is about a derivation that is shared, and the cost here is
exponential rather than quadratic.
EquationRenderer::assignLabels (include/morph/detail/quantity_equation.hpp)
is the one walk in equation() with no visited set. countRefs has seen;
render stops at a cK placeholder, a name, or an atom. assignLabels stops
at none of those — it labels a placeholder and then descends through it anyway:
if (!frame.expandThis && isPlaceholder(node) && !labelIndex.contains(node)) {
labelIndex.emplace(node, placeholderOrder.size() + 1);
placeholderOrder.push_back(node);
}
// Both the labelled and the unlabelled arm descend exactly when the
// node is not an atom, so the two cases share one exit.
if (isAtomNode(*node)) {
continue;
}
pending.push_back(LabelFrame{.node = node->right.get(), .expandThis = false});
pending.push_back(LabelFrame{.node = node->left.get(), .expandThis = false});
So a node reachable by k displayed paths is walked k times, not once. The
derivation is a DAG, not a tree — the spec says so ("the derivation is a
shared DAG", docs/spec/util/quantity_type.md) — so the path count can be
exponential in the node count.
Verification status: reproduced
Revision: master be64026a. Reproduced with a standalone harness, clang 22.1.8,
-O1, no sanitizers. q = q + q repeated k times: k + 1 distinct nodes,
2^k root-to-leaf paths.
k=20 nodes=21 equation=0.006s lines=23 first=7 chars
k=22 nodes=23 equation=0.025s lines=25 first=7 chars
k=24 nodes=25 equation=0.099s lines=27 first=7 chars
k=26 nodes=27 equation=0.723s lines=29 first=7 chars
k=28 nodes=29 equation=2.653s lines=31 first=7 chars
k=30 nodes=31 equation=10.322s lines=33 first=7 chars
Four times the time for two more nodes — 2^k, cleanly. A 31-node derivation
takes 10.3 s to render. The output it produces is 33 lines, none longer than
about 50 characters:
[0] c1 + c1
[1] = 536870912 + 536870912
[2] = 1073741824
[3] where c1 = c2 + c2 = 268435456 + 268435456 = 536870912
[4] c2 = c3 + c3 = 134217728 + 134217728 = 268435456
That linear output next to exponential time is what localises the defect without
a profiler: every walk that emits text is bounded here, so the time is being
spent in the one walk that emits nothing. Confirmed by mutation — adding a
visited set to assignLabels alone (no other change, step limit explicitly
disabled so it cannot be what helps) takes k=30 to 0.000 s with byte-identical
output, and k=60 — 2^60 paths — also renders instantly:
k=30 nodes=31 equation=0.000s lines=33 first=7 chars (step limit unlimited)
k=60 nodes=61 equation=0.000s lines=63 first=7 chars (step limit unlimited)
Why the fix is safe
The walk exists to assign cK labels in first-appearance order. A second visit
to a node can only re-assign labels it already holds (the code guards that with
!labelIndex.contains(node)) and re-walk a subtree it already walked, so
skipping it changes nothing observable — which the byte-identical output above
is the evidence for.
Reachability
q + q is contrived; the realistic shape is the one the spec's own example
builds — a sub-value reused in several places, (x - y) / x — nested. Each
level of nesting doubles the walk. Whether a real application nests reuse deeply
enough to notice is not established here: this is reproduced as a cost
property of the code, not observed in a caller.
What would change the verdict
- Close when
assignLabels carries a visited set and a nested-reuse
derivation renders in time proportional to its node count.
- Re-open if any other
equation() walk is found to lack one.
Not verified
Found while implementing #582's step limit, and filed separately because it is a
different defect with different evidence: #582 is about a derivation that is
long, this one is about a derivation that is shared, and the cost here is
exponential rather than quadratic.
EquationRenderer::assignLabels(include/morph/detail/quantity_equation.hpp)is the one walk in
equation()with no visited set.countRefshasseen;renderstops at acKplaceholder, a name, or an atom.assignLabelsstopsat none of those — it labels a placeholder and then descends through it anyway:
So a node reachable by k displayed paths is walked k times, not once. The
derivation is a DAG, not a tree — the spec says so ("the derivation is a
shared DAG",
docs/spec/util/quantity_type.md) — so the path count can beexponential in the node count.
Verification status: reproduced
Revision: master
be64026a. Reproduced with a standalone harness, clang 22.1.8,-O1, no sanitizers.q = q + qrepeated k times: k + 1 distinct nodes,2^k root-to-leaf paths.
Four times the time for two more nodes — 2^k, cleanly. A 31-node derivation
takes 10.3 s to render. The output it produces is 33 lines, none longer than
about 50 characters:
That linear output next to exponential time is what localises the defect without
a profiler: every walk that emits text is bounded here, so the time is being
spent in the one walk that emits nothing. Confirmed by mutation — adding a
visited set to
assignLabelsalone (no other change, step limit explicitlydisabled so it cannot be what helps) takes
k=30to 0.000 s with byte-identicaloutput, and
k=60— 2^60 paths — also renders instantly:Why the fix is safe
The walk exists to assign
cKlabels in first-appearance order. A second visitto a node can only re-assign labels it already holds (the code guards that with
!labelIndex.contains(node)) and re-walk a subtree it already walked, soskipping it changes nothing observable — which the byte-identical output above
is the evidence for.
Reachability
q + qis contrived; the realistic shape is the one the spec's own examplebuilds — a sub-value reused in several places,
(x - y) / x— nested. Eachlevel of nesting doubles the walk. Whether a real application nests reuse deeply
enough to notice is not established here: this is reproduced as a cost
property of the code, not observed in a caller.
What would change the verdict
assignLabelscarries a visited set and a nested-reusederivation renders in time proportional to its node count.
equation()walk is found to lack one.Not verified
depth was.
q = q + qis a hand-written shape; nothing here shows a data-drivenloop producing it.