Skip to content

util: equation() renders an unbounded derivation in full, quadratically — #574's deferred depth cap #582

Description

@Yaraslaut

Split out of #574, which fixed the crash but deliberately left this. #574's own fix list had a fourth item — "Consider a depth cap: past N nodes, collapse to a named leaf holding the value. An explanation 200,000 steps deep is not an explanation." — and PR #581 did not do it, because it changes equation()'s documented output contract rather than fixing a defect. Filing it so closing #574 does not lose it, together with the cost measurement #574 did not have.

Verification status: reproduced

Revision: fix-574-quantity-provenance-locale @ 3a1effd0 (PR #581), i.e. after the iterative-traversal fix. Nothing below is a crash any more; this is purely about what the output costs and what it is worth.

equation() builds its formula by repeated concatenation in EquationRenderer::combine, so rendering a chain of n derivation nodes copies O(n²) bytes, twice (once symbolic, once substituted). Measured on clang 22.1.8, running total = total + one n times and then calling equation():

n wall, -O1 + ASan+UBSan first output line
40,000 11.2 s 200,001 chars
70,000 32.3 s 350,001 chars
100,000 82.8 s 500,001 chars
$ ./prov_san 100000
equation() lines=4 first=500001 chars
destroyed cleanly
 100000  16.87s user 65.47s system 99% cpu 1:22.81 total

The shape of that first line is 0 + c1 + c1 + c1 + ... repeated 100,000 times, with a three-line where c1 = 1 legend under it. It is returned as a std::vector<std::string> of "print-ready lines", so a caller that prints it emits a single half-megabyte line.

Why it matters

Two separate things, and they may want different answers:

  1. It is not an explanation. docs/spec/util/quantity_type.md describes equation() as "the worked formula as print-ready lines" — something a domain user reads to see why a number is what it is. Half a megabyte of + c1 is not that. util/render: two reproduced value-type defects that ship by default — Quantity provenance chains and unvalidated locale group-separator stripping #574's suggestion was to collapse past some depth to a named leaf holding the value.
  2. It is quadratic, and the depth can come from wire input. The same "loop bound from a ledger replay or a batch of rows" argument util/render: two reproduced value-type defects that ship by default — Quantity provenance chains and unvalidated locale group-separator stripping #574 made about the retained chain applies to anything that calls equation() on the result. Since Fix two shipped value-type defects: provenance chains that overflow the stack, and locale group separators that are stripped instead of validated #581 the walk no longer overflows the stack, so a deep chain now reaches this instead of crashing — which is the right trade, but it means the cost is reachable where the crash used to be.

A depth cap fixes both. Reserving the output would fix only the second (the string still has to be built).

What would change the verdict

Not verified

  • Whether reserving in combine (instead of capping) brings the cost down enough to matter — the quadratic copying is inherent to building one string per level, so probably not, but it was not measured.
  • What a good cap would be. "200,000 steps is not an explanation" is clear; "50 is" is not obviously true.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: utilSubsystem: utilenhancementNew feature or requesttriage: validWell-framed; implement as written

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions