Mixed-depth class hierarchy · revenue by class
One question. Three answers. One is right.
When invoice lines are tagged at different depths of a class tree, some to a parent, some to a child, some to a grandchild, the question “what is this segment’s revenue?” has three plausible answers. Two are wrong, in opposite directions, and neither announces itself. Every figure below is computed on the server from 45 invoice lines across 15 classes, on every request.
Segment focus
Ask the same segment three times
Pick one of the four segments. The three cards are the three ways a report can answer, computed from the same lines. The error is not a rounding artefact; it is structural.
Descendant revenue never rolls up.
Subtotals get added to the details they contain.
Every line counted once, at one class.
Reconciliation
The test a rollup either passes or fails
Add the four segment totals together. A correct model returns the general-ledger total exactly. Switch the reporting mode and watch the gap open. This refetches /api/rollupeach time, so the arithmetic is the server’s, not the page’s.
Closure rollup (correct)
subtree(n) = direct(n) + the subtree of each child. Every line is counted exactly once, at exactly one class, and rolls up its ancestor chain once.
Class tree
Where the revenue actually sits
Interior classes carrying revenue are marked. That mark is the whole problem: a tree where only leaves are tagged has no rollup bug at all, which is why leaf-level spot checks come back clean.
GET /api/rollup?mode=closure| Class | Tagged directly | Reported | Correct | Error |
|---|---|---|---|---|
| Aerospace Componentstagged | $640,000 | $5,180,000 | $5,180,000 | $0 |
| ···· Actuators | $2,240,000 | $2,240,000 | $2,240,000 | $0 |
| ···· Fastenerstagged | $380,000 | $2,300,000 | $2,300,000 | $0 |
| ········ Titanium | $1,920,000 | $1,920,000 | $1,920,000 | $0 |
| Aftermarket Servicestagged | $95,000 | $2,315,000 | $2,315,000 | $0 |
| ···· Field Service | $1,340,000 | $1,340,000 | $1,340,000 | $0 |
| ···· Spare Parts | $880,000 | $880,000 | $880,000 | $0 |
| Industrial Pumpstagged | $1,240,000 | $8,410,000 | $8,410,000 | $0 |
| ···· Distribution | $1,510,000 | $1,510,000 | $1,510,000 | $0 |
| ···· OEMtagged | $420,000 | $5,660,000 | $5,660,000 | $0 |
| ········ Centrifugal | $3,180,000 | $3,180,000 | $3,180,000 | $0 |
| ········ Positive Displacement | $2,060,000 | $2,060,000 | $2,060,000 | $0 |
| Process Valvestagged | $210,000 | $2,810,000 | $2,810,000 | $0 |
| ···· Ball | $1,470,000 | $1,470,000 | $1,470,000 | $0 |
| ···· Gate | $1,130,000 | $1,130,000 | $1,130,000 | $0 |
Proof
Twelve invariants, recomputed on demand
Nothing here is compared against a stored expected value. Each check recomputes from the transaction list and asserts a property that must hold for any correct rollup over any hierarchy. This is the harness that would ride along with the real fix.
Method
What this is, and what it is not
The data is invented
Fifteen classes, 45 invoice lines, and a $18,715,000 book of revenue that I made up. There is no ERP connection here and no customer data of any kind. The dataset exists to make the bug reproducible.
The engine is the point
Correct aggregation over a mixed-depth tree is tool-independent. The closure recurrence and the invariant suite transfer to a Dataset, a Workbook, a warehouse view, or a SQL CTE without changing shape.
Currency is integral
Every amount is stored and summed as integer cents, so no rounding drift can enter a rollup and be mistaken for a hierarchy error. integer-cents asserts it.
Verify it yourself
Open the network tab, switch reporting modes, and watch /api/rollup recompute server-side. Then call /api/selftest directly.