Skip to content

Fix two shipped value-type defects: provenance chains that overflow the stack, and locale group separators that are stripped instead of validated - #581

Merged
Yaraslaut merged 7 commits into
masterfrom
fix-574-quantity-provenance-locale
Sep 19, 2026
Merged

Yaraslaut merged 7 commits into
masterfrom
fix-574-quantity-provenance-locale

Conversation

@Yaraslaut

@Yaraslaut Yaraslaut commented Sep 19, 2026 •

Copy link
Copy Markdown
Member

Closes #574.

Both defects reproduced first, with the issue's own commands, then fixed, then re-run. The Rational INT64_MIN work (#537 / PR #561) is deliberately untouched.

Part A — the provenance chain

Reproduced

$ ./prov0 200000                      # clang 22.1.8, -O0, 8 MiB stack
total = 200000EUR (chain of 200000 provenance nodes retained)
destroying...
$ echo $?
139                                   # SIGSEGV in the destructor chain
walk build last depth that returned first that segfaulted
destruction clang -O0 20,800 21,000
destruction clang -O2 200,000 none found
equation() clang -O0 24,000 25,000
equation() clang -O2 50,000 60,000
equation() gcc 16.2.1 -O2 — 40,000

The memory and time figures reproduce too: 54,056 KB / 0.034 s with the default against 12,236 KB / 0.006 s with MORPH_QUANTITY_PROVENANCE=0, for a 200,000-iteration running total at -O2.

The issue flagged equation() as unverified. It overflows too — gdb puts the crash in EquationRenderer::renderSymbolic, and unlike the destructor it has no -O2 escape, only a higher limit. gcc's limit is lower than clang's unoptimised one.

After

$ ./prov0_fixed 200000; echo $?       # same binary, fixed headers
total = 200000EUR (chain of 200000 provenance nodes retained)
destroying...
0
$ ./prov0_fixed 2000000 >/dev/null; echo $?
0
$ ./eq0_fixed 100000
built chain of 100000 nodes; calling equation()...
equation() returned 4 line(s), first line 500001 chars
  • Fix (2), iterative destruction. ~ASTNode detaches its children into a local worklist and unlinks a node's own children only when that pop holds the last reference, so every ~ASTNode the loop reaches has null children and cannot recurse.
  • Fix (3), iterative equation(). All four traversals run over an explicit stack. The symbolic and substituted renderings differed only in how they stop, so they became one stack machine (render, selected by a RenderMode) with a three-stage frame — one traversal to get right instead of two. Output is byte-identical; the existing 62 [quantity]/[render]/[locale] cases pass unchanged.
  • Fix (4), depth cap: not done. It would change equation()'s documented output contract, which is a design decision for the type's owner rather than a bug fix. Now that depth cannot crash, it is a readability question.

Fix (1) — the default: not flipped, and this is the finding

docs/spec/util/quantity_type.md promises default-on in four places, including a section heading ("Provenance — a build-time toggle, on by default") and the sentence "The default is on: every Quantity is traceable". The issue's own "what would change the verdict" says to close A(1) if provenance-by-default is a deliberate product decision. The spec says it is, so both readings are recorded in the spec rather than one being picked silently:

  • For flipping: nobody opts into a cost they do not know about, and every build that never calls equation() pays it.
  • Against, and this is the decision: the toggle changes observable behaviour, not just performance — the spec's own limitations section says so. With it 0, equation() collapses to the bare value and named() discards the name. Flipping the default would silently empty both for every existing build that did not set the macro. And provenance is not an incidental extra: the spec opens by naming it as the third of three things a Quantity knows and the reason to reach for the type at all.

The measurement is now in the spec and on the macro, with "a bulk path that never calls equation() should set this to 0" stated outright — so the cost is documented rather than discovered.

Per the issue, (2) and (3) landed regardless of (1): a stack overflow is not an acceptable failure mode for either setting, and it is now fixed for both.

Part B — locale group separators

Reproduced, C++ and the QML mirror

The issue flagged the QML mirror as unverified. I lifted normalizeLocaleNumber out of src/qt/forms/qml/DynamicForm.qml and ran it on the same inputs. It produced byte-identical wrong answers:

                                          C++        QML
normalize("1.5",     dec=",", grp=".")  = "15"       "15"    <-- de-DE, US decimal typed
normalize("1.50",    dec=",", grp=".")  = "150"      "150"
normalize("1.2.3.4", dec=",", grp=".")  = "1234"     "1234"
normalize("1,5",     dec=".", grp=",")  = "15"       "15"    <-- en-US mirror image
normalize("1.5",     dec=".", grp=".")  = "15"       "15"    <-- separators equal

Consistently wrong is still wrong, so both edges are fixed.

After

normalize("1.5",       dec=",", grp=".") = NULLOPT
normalize("1.50",      dec=",", grp=".") = NULLOPT
normalize("1.2.3.4",   dec=",", grp=".") = NULLOPT
normalize("1,5",       dec=".", grp=",") = NULLOPT
normalize("1.5",       dec=".", grp=".") = NULLOPT
normalize("1.050,25",  dec=",", grp=".") = "1050.25"      <-- unchanged
normalize("1.000.000,25", dec=",", grp=".") = "1000000.25"
normalize("1050,25",   dec=",", grp=".") = "1050.25"      <-- ungrouped, unchanged
normalize("1 050,25",  dec=",", grp=" ") = "1050.25"      <-- U+202F too
normalize("1234.5",    dec=".", grp="")  = "1234.5"       <-- identity transform

A group separator is dropped only where one can legally be: preceded by one to three digits, followed by exactly three, never after the decimal separator. A 47-case differential over both implementations confirms the two edges agree on every input, before and after.

Equal separators are rejected through the return value, not an assert — deliberately, and against the issue's suggestion. An assertion would make a control edge behave differently in Debug and Release, and would be untestable in the build where it fires; that is the same signature as the Part A defect in this very ticket. Reported like any other malformed entry instead.

The negativeSign parameter the issue mentions in passing is not in this PR: it is an API change to both edges and all their call sites, and it is not what makes the value wrong. Happy to file it separately if wanted.

The two traps, addressed

1. The -O0 destruction test. Confirmed to fail on unfixed code before claiming it covers anything — headers reverted to origin/master, suite rebuilt, run:

tests/test_quantity.cpp:704: FAILED:
  {Unknown expression after the reported line}
due to a fatal error condition:
  SIGSEGV - Segmentation violation signal

and the same for the equation() case at :729. Both pass on fixed headers. The test comment carries all five measured depths and says plainly that the destruction case is load-bearing in an unoptimised build only (vacuous at -O2, where no depth fails at all), naming the CI legs that are -O0: gcc-debug, clang-debug, the three sanitizer legs, cl-debug.

The equation() case is priced rather than copied from the ticket: at 100,000 nodes it costs 83 s under ASan against 32 s at 70,000, for the same evidence — equation() concatenates the whole chain into one string, so its cost is quadratic in depth. 70,000 sits above every measured survival depth (highest: 50,000 under clang -O2), so it is not vacuous in an optimised build. An earlier draft used 40,000 and would have been; that is in the history as its own commit, because it is exactly the failure mode AGENTS.md is about.

2. The 18 single-locale cases. All 18 passed with the stripping and with the validation alike. Four new cases: normalize("1.5", ",", "."), equal separators, group placement, and a control that every well-formed entry still normalises. Three of the four fail on unfixed code (16 assertions); the fourth is the control and passes either way by design. On the QML side, test_foreignDecimalSeparatorIsRejectedNotAbsorbed fails on the unfixed mirror and passes on the fixed one, verified by reverting DynamicForm.qml and rebuilding.

Specs and docs

The render spec-sync gate is new as of #576 and does apply here; scripts/check_spec_sync.sh on this branch's file list reports OK.

  • docs/spec/util/quantity_type.md — the depth table, the iterative-walk requirement as a property of the type, the measured provenance cost, and both readings of the default.
  • docs/spec/forms/forms.md, Locale data formatting — grouping validated not stripped, the equal-separator rule with the reason it is not an assertion, and "both edges or neither".
  • examples/ledger/README.md — this was listed as an open ladder finding; it now records the fix and keeps the separate double-division display item.
  • CHANGELOG.md — one entry each.

Review notes (done inline, no /code-review)

  • ~ASTNode and thread safety. The use_count() == 1 check is the only race-sensitive point. If another owner exists the count is ≥ 2 and dropping the handle cannot destroy the node, so nothing is unlinked; if the count is 1 we are the sole owner. Either way the nested ~ASTNode runs with null children. Matches what the spec says about reading a completed derivation from several threads.
  • Traversal equivalence. countRefs counts each node once under a seen set, so worklist order cannot change the counts. assignLabels is a left-before-right pre-order, so the right child is pushed first and the left popped first. atomRendering preserves the original short-circuit order exactly, including that a named node wins over a placeholder in symbolic mode and that isPlaceholder is never reached for a named node. The proof that this is right is that equation()'s existing output assertions did not move.
  • The scan refactor is behaviour-preserving, not just believed to be: the 47-case corpus output is byte-identical before and after the split into groupingIsWellPlaced, and still identical to the QML mirror.
  • detail/quantity_equation.hpp was not self-contained. clang-tidy on the file alone reported 20 unknown type name 'ASTNode' errors and a cascade of bogus findings ("method countRefs can be made static" — it touches three members), because the header is included from quantity.hpp after ASTNode exists. Changing lines in it would have lit up the clang-tidy-diff gate. It now includes util/quantity.hpp back, which #pragma once makes free; checked with clang++ -fsyntax-only -x c++-header on the file alone.

One gate this change broke, and why the repair is not a workaround

scripts/test_check_install_export.sh proves that INTERFACE_HEADER_SETS_TO_VERIFY HEADERS is load-bearing by deleting it and requiring the build to break. It broke because detail/quantity_equation.hpp was not self-contained. Making it self-contained left no broken detail/ header in the tree — every other one already compiled standalone, checked one by one — so the mutation stopped breaking anything and CI said so:

error: NOT caught: the verified header sets widened back to include detail/
       -- the gate passed a tree it should reject

That is the self-test doing its job. Borrowing a real header's brokenness made the case depend on it staying broken, so the next person to fix one would have hit the same wall. The mutation now plants its own detail/selftest_not_standalone.hpp and adds it to the detail/ FILE_SET, so the property still has something to keep out and deleting it still pulls something in. Same three needles, all still toolchain-independent. The CMakeLists comment justified the exclusion by that header being broken; it now states the rule it actually encodes — standalone compilation is a promise about public headers — and notes that every detail/ header compiling standalone today is a convenience, not the rule.

Verification run locally

  • morph_tests: 1500 cases, 22,329 assertions, all pass (one expected failure, pre-existing).
  • morph_forms_qml_tests (Qt 6.11.2, offscreen): 268 passed, 0 failed.
  • clang-tidy-diff (clang-tidy 22, origin/master...HEAD): clean.
  • clang-format --dry-run -Werror on every changed C++ file: clean.
  • scripts/check_spec_sync.sh, check_spec_citations.sh, check_catch_test_names.sh, check_automoc_includes.sh: all OK.
  • Doxygen -DMORPH_BUILD_DOCUMENTATION=ON target doc (FAIL_ON_WARNINGS): builds.
  • ASan + UBSan probe over both new paths at 100,000 nodes: no findings.
  • scripts/test_check_install_export.sh: all seven cases caught, clean tree passes.
  • Branch coverage, the gate's own pipeline (llvm-cov export → aggregate_lcov_branches.py → the floors in check_branch_coverage.py), morph_tests alone: include/morph/render 100.00% (94/94, floor 97), include/morph/detail 100.00% (106/106, floor 97), include/morph/util 96.77% (360/372, floor 93), no partial lines in render or detail. Two of the new locale cases and the shape of the two iterative walks exist to keep those two at 100 — guarding the child pushes instead of handling null on pop left root != nullptr with an arm nothing could take.

Filed rather than folded in

Two parts of #574 are deliberately not in this PR, and since this PR closes #574 they are filed so they are not lost with it:

The one other adjacent defect I met (double division in the shipped forms renderer's money display) was already recorded in examples/ledger/README.md, and that record is kept.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DbGrZGkr2WqcAs2DJyMym6

Yaraslaut and others added 7 commits September 19, 2026 08:41
A running total records one `ASTNode` per iteration, chained through `left`,
and nothing collapses that chain. Every walk over it was recursive, so a deep
enough derivation ran the stack out. Measured on clang 20 and clang 22 at
`-O0` with an 8 MiB stack:

    $ ./prov0 200000
    total = 200000EUR (chain of 200000 provenance nodes retained)
    destroying...
    $ echo $?
    139                 # SIGSEGV in the destructor chain

Destruction crossed the limit between 20,800 nodes (exit 0) and 21,000
(SIGSEGV). The same binary at `-O2` survived 200,000, because clang rewrites
that particular chain into a loop -- crashes in Debug, survives in Release.

morph#574 flagged `equation()` as unverified. It overflows too, and with no
optimiser escape, since its frames hold live `Rendered` strings across the
call. Reproduced at `-O0`: returns normally at 24,000 nodes, SIGSEGV at
25,000, in `EquationRenderer::renderSymbolic`.

`~ASTNode` now detaches its children into a local worklist and releases them
one at a time, unlinking a node's own children only when that pop holds the
last reference -- so every `~ASTNode` the loop reaches has null children and
cannot recurse. All four `equation()` traversals run over an explicit stack;
the symbolic and substituted renderings, which differed only in how they stop,
became one stack machine selected by a `RenderMode`, so there is one traversal
to get right instead of two. Output is byte-identical: the existing 62
`[quantity]`/`[render]`/`[locale]` cases pass unchanged.

The two regression tests build a 100,000-node chain. What makes them evidence:
on unfixed code the destruction case is SIGSEGV at `-O0` (verified -- it is
vacuous at `-O2`, and the test says so and says which CI legs are Debug), and
the `equation()` case is SIGSEGV at every optimisation level.

`MORPH_QUANTITY_PROVENANCE` keeps its default of `1`. The measured cost is
real -- 54,056 KB and 0.034 s against 12,236 KB and 0.006 s for a
200,000-iteration total -- but the toggle changes observable behaviour, not
just cost: with it `0`, `equation()` collapses to the bare value and `named()`
discards the name, so flipping it would silently empty both for every build
that never set it. `docs/spec/util/quantity_type.md` promised the default in
four places; the spec now carries the measurement, both readings of the
argument, and the note that the crash was never the toggle's business, since
a stack overflow is not an acceptable failure mode for either setting.

Refs morph#574.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbGrZGkr2WqcAs2DJyMym6
…them

`normalizeLocaleNumber` dropped every occurrence of the group separator
unconditionally, before the decimal check and with no check on placement.
So a German user typing the US form into a price field submitted ten times
what they meant, and nothing downstream could tell -- the result is a
perfectly valid number:

    normalize("1.5",     dec=",", grp=".")  = "15"
    normalize("1.50",    dec=",", grp=".")  = "150"
    normalize("1.2.3.4", dec=",", grp=".")  = "1234"
    normalize("1,5",     dec=".", grp=",")  = "15"     <-- the en-US mirror
    normalize("1.5",     dec=".", grp=".")  = "15"     <-- separators equal

The QML mirror in src/qt/forms/qml/DynamicForm.qml was flagged as unverified.
It is verified now: lifted out and run on the same inputs, it produced those
five answers byte for byte. Consistently wrong is still wrong, so both edges
are fixed, and a 47-case differential over both implementations confirms they
agree on every one.

A group separator is now dropped only where one can legally be: preceded by
one to three digits, followed by exactly three, never after the decimal
separator. All five lines above are `std::nullopt` and the caller can tell the
user to fix the entry. Equal separators are rejected too -- one string in both
roles has no defensible reading of "1.5" -- through the return value rather
than an `assert`, deliberately: an assertion would make a control edge behave
differently in Debug and Release, and would be untestable in the build where
it fires.

    normalize("1.050,25",   dec=",", grp=".")  = "1050.25"
    normalize("1.000.000,25", dec=",", grp=".") = "1000000.25"
    normalize("1050,25",    dec=",", grp=".")  = "1050.25"
    normalize("1 050,25",   dec=",", grp=" ")  = "1050.25"   (U+202F too)

The existing suite had 18 cases, every one of them single-locale, so all 18
passed with the stripping and with the validation alike. The four new cases
are cross-locale, equal-separator, group-placement, and a control that every
well-formed entry still normalises; three of the four fail on unfixed code (16
assertions). On the QML side `test_foreignDecimalSeparatorIsRejectedNotAbsorbed`
fails on the unfixed mirror and passes on the fixed one, verified by reverting
the file and rebuilding.

examples/ledger/README.md listed this as an open ladder finding; it now
records the fix and keeps the separate `double`-division display item.

Refs morph#574.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbGrZGkr2WqcAs2DJyMym6
Two entries under Fixed: the locale group-separator validation, with the
wrong values it used to produce, and the provenance-chain stack overflow,
with the measurement behind leaving MORPH_QUANTITY_PROVENANCE at 1.

Refs morph#574.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbGrZGkr2WqcAs2DJyMym6
Three things clang-tidy-diff would have reported on the changed lines, all
worth doing on their own terms:

- `normalizeLocaleNumber` reached a cognitive complexity of 38 against a
  threshold of 25, because the grouping rule was extra state threaded through
  the normalising scan. "The grouping is well placed" and "the digits convert"
  are two separate statements about an entry, and reading them as one made
  neither clear. The placement rule is now `detail::groupingIsWellPlaced`, a
  pass of its own that can be read without the scan around it. Verified
  behaviour-preserving: the 47-case differential over both control edges is
  byte-identical before and after, and still identical to the QML mirror.

- `detail/quantity_equation.hpp` was not self-contained -- it is included from
  `util/quantity.hpp` after `ASTNode` exists, so a tool that opens it on its
  own saw `unknown type name 'ASTNode'` on every line. Local clang-tidy on the
  unmodified file reports 20 such errors and a cascade of bogus findings
  ("method 'countRefs' can be made static" -- it accesses three members). It
  now includes `util/quantity.hpp` back, which `#pragma once` makes free and
  which nothing in that header past the include point can notice. Checked with
  `clang++ -fsyntax-only -x c++-header` on the file alone.

- `misc-const-correctness` on the worklist handle in `~ASTNode`.

Refs morph#574.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbGrZGkr2WqcAs2DJyMym6
… bites

The first version of these two cases used 100,000 nodes for both, on the
ticket's wording rather than on a measurement. Two things came out of
measuring:

- `equation()` renders the whole chain into one string by repeated
  concatenation, so its cost is quadratic in the depth: 83 s under ASan at
  100,000 against 32 s at 70,000, for the same evidence.
- The claim that the `equation()` case "has no optimiser escape" was too
  strong. Optimisation shrinks the frames rather than eliminating the
  recursion: unfixed code returns normally at 50,000 and SIGSEGVs at 60,000
  under clang 22 `-O2`, where at `-O0` it SIGSEGVs at 25,000. gcc 16 `-O2`
  SIGSEGVs already at 40,000. So a 40,000-node case would have passed
  vacuously in a clang Release leg -- the exact shape of check this repository
  keeps finding.

70,000 sits above every measured survival depth and keeps the cost down. The
destruction case stays at 100,000: it is linear, and the ticket asked for that
depth. The comment now carries all five measurements and says plainly that the
destruction case is load-bearing in an unoptimised build only, naming the CI
legs where that is true.

Verified after the change: both cases still SIGSEGV on unfixed headers in the
clang-debug suite, and both pass on fixed ones.

Refs morph#574.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbGrZGkr2WqcAs2DJyMym6
The spec and the two header comments carried a single -O0 number and an
assertion that equation()'s recursion had no optimiser escape. Both are now
the measured table: destruction has no failing depth at all under clang -O2,
while equation()'s limit only moves with optimisation (25,000 at clang -O0,
60,000 at clang -O2, 40,000 under gcc 16 -O2). Also corrects the compiler
these were measured on -- clang 22.1.8 and gcc 16.2.1, not clang 20.

Refs morph#574.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbGrZGkr2WqcAs2DJyMym6
CI caught this, which is the gate working. `test_check_install_export.sh`'s
"the verified header sets widened back to include detail/" case proves
`INTERFACE_HEADER_SETS_TO_VERIFY HEADERS` is load-bearing by deleting it and
requiring the build to break. It broke because `detail/quantity_equation.hpp`
was not self-contained. This branch made it self-contained, every other
detail/ header already was, and with no broken header left in the tree the
mutation stopped breaking anything:

    error: NOT caught: the verified header sets widened back to include detail/
           -- the gate passed a tree it should reject

Borrowing a real header's brokenness made that case depend on it staying
broken, so the next person to fix one would have hit this wall too. The
mutation now plants its own `detail/selftest_not_standalone.hpp` -- a header
calling an undeclared `morphSelfTestUndeclaredHelper()` -- and adds it to the
detail/ FILE_SET, so the unmutated property keeps it out of the verified set
and deleting the property pulls it in. Same three needles as before (the
checker's own wording, the header path, the identifier), all still
toolchain-independent. The two paths go on one line because a portable `sed`
replacement cannot insert a newline, and `FILES` accepts that.

The CMakeLists comment justified the exclusion by quantity_equation.hpp being
non-self-contained, which is now false. The rule it encodes is not "these
happen to be broken" but "standalone compilation is a promise about public
headers", so it says that instead, and notes that every detail/ header
compiling standalone today is a convenience rather than the rule.

Also here, two branch-coverage repairs for the same reason
(`include/morph/detail` and `include/morph/render` both carry a 97% branch
floor in scripts/check_branch_coverage.py, measured at 100%):

- `countRefs` and `assignLabels` push children unconditionally and handle null
  on pop, as the recursive form did on entry. Guarding the push instead left
  `root != nullptr` with an arm nothing could take, since `equation()` checks
  for a null root before calling.
- Two locale cases for arms nothing else reaches: a grouping locale whose
  entry carries no separator at all, and a non-digit inside one.

Refs morph#574.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbGrZGkr2WqcAs2DJyMym6
@codecov

codecov Bot commented Sep 19, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

util/render: two reproduced value-type defects that ship by default — Quantity provenance chains and unvalidated locale group-separator stripping

1 participant