Skip to content

Calculations and worksheets, decimal display with std::format, and application-declared base dimensions - #6

Merged
christianparpart merged 80 commits into
masterfrom
prototype-catch-up
Sep 29, 2026
Merged

christianparpart merged 80 commits into
masterfrom
prototype-catch-up

Conversation

@christianparpart

@christianparpart christianparpart commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

This brings the capabilities of the latest calculation-engine prototype into formula-cpp, built the formula-cpp way: exact rationals, dimensions checked at compile time, errors as values, and every result traceable.

What it adds

Calculations and worksheets (calculation.hpp, guide: docs/calculations.md)

  • define<Q>(expression) binds a quantity to its formula, and calculation(...) groups the definitions. The dependency graph is known at compile time: static_assert(depends_on<Total, FridgeW>(bill)), plus dependencies_of, dependents_of, upstream_of, affected_by, inputs_of and calculation_order. Each mistake (a cycle, a self-reference, a duplicate, a dimension mismatch, …) gives one formula: compile error.
  • worksheet(calc, environment(...)) holds a calculation's values and computes each one only when asked.
    • calculate returns one result or several, in a checked or a throwing form.
    • set recalculates only what a change reaches, and a value that recalculates to the same answer stops the change there (early cutoff).
    • with() makes a what-if copy.
    • An entered() value overrides a calculated one by hand.
    • recomputed() and reused() count the work.
  • explain_worksheet and render_derivation give one derivation block per named value. A derivation is re-run against the worksheet's current values, so it is never stale, even after an early cutoff.
  • render(calc), describe_graph, to_dot (Graphviz) and document(calc) write the calculation as text.
  • examples/electricity_bill.cpp is the prototype's household bill: 10 inputs and 15 calculated values, declaring its own currencies. It prints the prototype's totals and counts (118.26 / 95.02 / 98.00 / 85.14 EUR; recomputed 15/0, 5/0, 0/0, 3/0, 2/8; a what-if copy recomputes 9). After the fridge swap its derivation shows fridge_w = 400 W, where the prototype showed a stale value.

Decimal display and std::format (number_text.hpp, format.hpp, guide: docs/display.md)

  • NumberStyle covers fractions (the default, unchanged), exact decimals, and approximations. An approximation must name a rounding mode, and it always carries ≈.
  • Traces (TraceRenderOptions::numbers), render() and document() (RenderOptions), and derivations all take the style. A number the author typed, and both sides of a stated comparison, are never approximated.
  • number_text, decimal_text and exact_decimal_text need neither allocation nor <format>, and work in static_assert. typed_number_style(vocabulary) is the style a consumer's own render_node uses for a number the author typed.
  • In a trace, a computed value in a unit nobody declared keeps 3 places when approximated; if that would read ≈0 for a nonzero value, it shows its first significant digit (up to 18 places) instead, still marked ≈.
  • #include <formula-cpp/format.hpp> (opt-in) adds std::formatter for Rational and Measured<Q>: {}, {:/}, {:.2HalfEven}, {:~.3HalfEven}, fill, align and width in code points. A bad spec in a literal format string is a compile error that names the problem; through std::vformat it throws std::format_error("formula: …"). The guide's spec table, README's sample and the quoted compile error's source line are checked against the code in CI.

Application-declared base dimensions (base_dimension, guide: docs/dimensions.md)

  • formula::base_dimension("EUR") declares a dimension the SI does not have. EUR + a ratio, or EUR + JPY, fails to compile, and kWh × EUR/kWh = EUR. A dimension carries up to four such bases.
  • No currencies are shipped: an application declares its own. An exchange rate is data, a quantity in JPY/EUR, never a built-in conversion.

Units: Watt, Kilowatt, WattHour, KilowattHour (with dim::Power) and Fahrenheit.

Cost of named base dimensions

Measured on cl and clang-cl, before (ccb7796) and after:

before after
sizeof(Dimension) 56 B 152 B
sizeof(Unit) 152 B 248 B
sizeof(Step<Rational>) 1008 B 1296 B
clean formula-cpp-tests build, cl 19.06 s 19.61 s (+2.9 %)
clean formula-cpp-tests build, clang-cl 14.48 s 14.59 s (+0.8 %, noise)

Trace-heavy object files grow by about 25 %. Most of that is longer mangled names, because a unit's template argument now spells four name slots. On the heaviest test object, twice as many symbols exceed cl's 4096-character limit and are hashed.

Compatibility

  • Existing output is unchanged: fractions stay the default everywhere, and docs/gallery.md is untouched (only comments in its generator changed).
  • detail::number_text(Rational) is removed, so no surface prints a Rational without choosing a style.
  • Unqualified calls can now find the library's new free functions (number_text, fraction_text, define, worksheet, …) by argument-dependent lookup, so a consumer's own helper of the same name may become ambiguous; the CHANGELOG lists this under Changed.
  • README and docs/index.md list this work as "next release".

Verification

  • Locally, on the final tip: the full suite on all eight presets (cl and clang-cl, debug and release; GCC; Clang debug, release and UBSan), Doxygen 1.9.8 with no warnings, and mkdocs build --strict.
  • CI: every job of the build and package workflows.
  • Every guide's quoted output and code is checked against its example by the docs.*-output / docs.*-snippets tests, now including docs/dimensions.md.
  • Every new compile-time refusal has a negative test, registered with a wrong expected message first and then checked by deleting the guard it pins.

Follow-ups (not in this PR)

  • A computed trace step states its value in the coherent unit with no unit symbol (Trace: arithmetic on single values prints bare coherent-SI numbers with no unit #2).
  • {:~Mode} on a value with a very large denominator, in a unit declaring negative decimals, refuses (formula: … overflow) even where the rounded result is 0. It is a refusal, never a wrong number.
  • Some 0.1.0 headers still carry internal stage wording in comments.

Christian Parpart added 30 commits September 29, 2026 09:10
The units an energy calculation needs, and a second affine temperature
unit, with the tests and documentation that list shipped units.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
…enheit

An energy calculation needs the units an electricity bill is written in, and
none of them existed: there was an energy dimension but no power one, no
watt or kilowatt-hour, and degrees Celsius was the only affine unit.

dim::Power is energy per time. unit::Watt and unit::Kilowatt measure it;
unit::WattHour (3600 J) and unit::KilowattHour (3600000 J) measure energy,
so a power times a time converts into kilowatt-hours exactly and a power
cannot be added to an energy. unit::Fahrenheit is a 5/9 K degree with an
offset of 45967/180 K, exact in both directions: 32 degrees is 273.15 K,
212 degrees is 100 degrees Celsius, and 98.6 degrees is exactly 37.

Nothing outside unit.hpp and dimension.hpp changes: every offset rule keys on
a non-zero offset, not on Celsius. The tests pin the new units in the shipped
table, the symbol list and the round-trip pairs, convert them in constant
expressions, evaluate a heater into kilowatt-hours and a Fahrenheit reading
into Celsius, and pin the trace lines of a kilowatt-hour product and of a
series of Fahrenheit readings.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
The comment on Celsius said a difference of one degree is a difference of one
kelvin. That is true of the physics and false of this library, which converts
points and never differences: one degree Celsius converts to 274.15 kelvin, as
checked_convert's own comment says two hundred lines below. It now says what
the library does.

The comment on Fahrenheit and the changelog entry called every conversion
"exact". The factors are exact rationals and nothing rounds, but checked_convert
returns an error rather than a value when an intermediate does not fit, and 100
degrees Fahrenheit, exactly 340/9 degrees Celsius, is not a terminating decimal.
Both now say "no conversion rounds" and show the fraction. The comment on
checked_convert names Fahrenheit beside Celsius as the units that need an offset.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
The guide still counted fifty-one units, listed no power dimension, and said
this library ships one affine unit. It now counts fifty-six, names Power among
the derived dimensions, explains the watt-hour family (a kilowatt-hour is
exactly 3600000 J, and a power is a different dimension from an energy), and
gives degrees Fahrenheit its own rows in the affine section: a point converts
affinely, a difference does not, and 100 degF is 340/9 degC because converting
divides by 9, kept as a fraction rather than a rounded 37.78 that would not
round-trip. The expressions guide's "ships one" becomes two.

The two citations that named test line numbers, both already stale, now quote
the assertions they mean, so they cannot go stale by an edit above them.

The dimensions example prints and checks -40 degF = -40 degC, 100 degF = 340/9
degC and 1 kWh = 3600000 J, and the guide's output blocks are copied from a real
run. Its overflow-census row changes (3600000 needs 22 bits), so the census page
is regenerated on cl; its remark that clang and gcc read one bit fewer for this
example no longer holds, measured with g++ 13.3, g++ 14, clang++ 20 and 22 and
clang-cl, which all print the same row as cl now, and is narrowed to the
expressions example, which still does.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
Comments and guides written in English mixed decimal commas ("273,15 K",
"0,45 m3", "1,25 -> 1,3") with decimal points, which reads as a list to anyone
used to the point and made the two conventions look like two different numbers.
Every decimal in the guides, the examples and the header comments now uses a
point. Diagnostics, code and thousands separators are untouched: the compiler
positions quoted in the lookup-table guide and "89,300 N" in the headroom page
are not decimals.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
Several guides pointed at the project's build stages ("phase 4's quantity
type", "a later phase's constraints", "the phase-11 spike") which mean nothing
to a reader of the library. Each now names the feature or links the guide.

Two of the sentences were also stale, not only labelled: expressions.md said
nothing produces a verdict or an invalidation yet, when outlier rejection and
bounded retry report a verdict through Outcome, and statistics.md said reading
another test's record and retrying were still to come, when they are the
records and retry guides.

The headroom page's remark about clang and gcc now says what was measured on the
expressions example: not one bit fewer but one bit more headroom (clang-cl and
g++ print 1/2/0/61 where cl prints 3/2/0/60, so two numerator bits fewer).

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
The Celsius comment said this library does not convert differences. It is
checked_convert that does not: a trace deliberately shows the spread of two
readings in kelvin. The comment now says so.

The dimensions example called kelvin one of "the scales the library has" while
describing the two affine ones, and said 100 degF "is a fraction of a degree".
It now says both affine scales, and that the number of degrees Celsius is a
fraction.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
The comments in three test files still wrote decimals with a comma ("273,15 K",
"98,6 degF", "0,18 m3", "3,04e9"), which the guides and headers no longer do.
Only comments change: no test logic and no expected value or string is touched.
Commas that are not decimals stay: index pairs such as (0,1), interval
endpoints such as [13.9,27.1), the template arguments in "RequireTagDeclaredOnce<1,3,",
and the thousands separator in "200,000".

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
Let an application declare base dimensions the SI does not have, so
that euros cannot be added to a bare ratio, euros and yen never convert
into each other, and kWh times EUR/kWh is EUR.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
A Dimension is about to carry base dimensions that an application names
itself, and each of those names is a Symbol -- the same 16-byte structural
string a Unit uses for its symbol. dimension.hpp cannot include unit.hpp to
reach it, since unit.hpp includes dimension.hpp, so the type moves down a
level.

Moved verbatim from unit.hpp: SymbolCapacity, Symbol,
detail::formula_unit_symbol_too_long, symbol(), view() and the deleted
view(Symbol&&). unit.hpp includes dimension.hpp, so every existing spelling
keeps compiling unchanged; unit.hpp drops <cstddef> and <cstdlib>, which only
the moved code used, and dimension.hpp gains them with <string_view>.

Two comments pointed at the old home: band.hpp cited the Bounds struct by a
unit.hpp line number that no longer holds it, and now names Bounds instead;
measured.hpp's cross-reference to view(Symbol&&) now says dimension.hpp.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
A Dimension held the seven SI exponents and nothing else, so an
application that measures money had to call a currency dimensionless --
and the dimension system then let euros be added to a bare ratio. A
Dimension now also holds up to four base dimensions the application names
itself, and base_dimension("EUR") makes one. Each name is a dimension of
its own: euros and yen never convert into each other, since an exchange
rate is data, a quantity in yen per euro, and energy times euros per
energy is euros.

The operators keep the named bases canonical -- sorted by name, packed,
every unused slot equal to NamedBase {} -- by merging the two operands'
lists in one pass, so equal dimensions stay equal objects and therefore
one template argument, in every translation unit. A product that would
need a fifth base does not compile and names
formula_dimension_has_too_many_named_bases; base_dimension refuses an
empty name, one of 16 bytes or more, one that is not a letter followed by
letters or digits, and the symbol of an SI base unit, each naming its
rule. It is consteval, so a bad name can never reach run time.

Two g++ defects shape the code, both measured on g++ 13.3 and 14.2 and
absent on cl, clang-cl and clang++. With namedBases default-initialised
as {}, a constant evaluation that copies a dimension and writes the
copy's last slot corrupts the original, so the four elements are spelled
out. And a slot built from a const local keeps the const in its value
and becomes a different template argument, so the merge builds each slot
from a non-const one. The dimension tests fail on g++ if either is undone.

Dimension grows from 56 to 152 bytes, Unit from 152 to 248 and
Step<Rational> from 1008 to 1296 on 64-bit builds, and the pin on a
step's size moves with it. At that size g++ warns about a range loop
that copies a Dimension per iteration, so the one in opaque.hpp binds a
reference instead. RequireSameDimension's message now says the named
bases follow the seven exponents in the vectors it points at.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
power and nth_root build their named-base slots themselves rather than
through the merge, and their tests compared values only. Equality cannot
see the g++ fault the merge's comment describes, where a slot built from
a const local becomes a different template argument: rewriting either
loop that way passed every test while making power(EUR, -1) a different
type from Scalar / EUR on g++ 13.3 and 14.2. Both now have
type-identity checks, including one over two named bases, and each fails
on both g++ versions when its loop is rewritten that way.

The division's capacity guard was unpinned, since both negative cases
for too many named bases multiply. A new case divides four bases by a
fifth; without the guard that quotient compiles and silently drops the
fifth base.

Comments: the merge's example of cancelling before counting is now one
that fits only because of the cancellation; the note on the non-const
local says what was measured rather than more; the EXPECT_COUNT
explanation in test/CMakeLists.txt covers only the base-name cases it is
about; and a comment moved into dimension.hpp no longer refers to
dimension.hpp from inside it.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
A dimension can now carry base dimensions an application names itself,
and two parts of the trace predate them. coherent_unit_text, which spells
the unit an opaque output is shown in when no input's unit fits, knew
only the seven SI units, so a tariff in euros per joule would have read
s^2/(m^2 kg). It now spells each named base by its name, ahead of the SI
units on its side of the slash -- EUR s^2/(m^2 kg), 1/JPY, EUR/JPY,
EUR^(1/2) -- and escapes each name as author text, since a hand-filled
Dimension can hold any bytes. A dimension without named bases is spelt
exactly as before.

unit_quotient divided two units' dimensions with operator/, at run time,
where that operator's guard against a fifth named base ends the program.
It now merges them with detail::merged_dimension and offers no quotient
when they do not fit, as it already offers none when a symbol or a
magnitude would not fit; its one caller, opaque_output_unit, then falls
back to the coherent unit.

The places that ask whether a dimension is a pure number need no change,
because a currency is not one. Two tests pin that: a critical value's
count in euros is refused as not dimensionless, where a currency declared
as a bare ratio compiled; and a series of prices scaled by a ratio reads
in euros, where a bare-ratio currency lost its unit.

The comments on coherent() and on Unit now say that the coherent unit is
the SI unit times one of each named base, and the note in
merged_dimension names exactly the const local that was measured
unaffected.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
unit_quotient's comment said two units carrying more than four named
bases between them have no quotient a Dimension can hold. That is false:
a name both units carry cancels before the count, so a credit, a bonus
and a coupon over a credit, a stamp and a token -- five names between
them -- has a quotient with four. The comment now states the rule
merged_dimension applies, and the unit_quotient test pins that case
beside the one that does not fit.

escaped_author_text's comment listed every place that applies it and
ended "nowhere else"; coherent_unit_text, which now escapes a named
base's name, joins the list. unit.hpp's file comment now says the
coherent unit is the SI unit times one of each named base, as the other
comments do, the changelog says how a trace spells a named base, and the
new negative case's registration is set off by a blank line like its
neighbours.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
unit_quotient's comment said a quotient is refused when it needs more
than four named bases once the names both units carry have cancelled.
A shared name cancels only when its exponents do: credits squared over
credits leave credits, so that quotient keeps five names and is refused.
The comment now says a shared name counts once, or not at all when its
exponents cancel, and the unit_quotient test pins the squared case
beside the one that cancels.

escaped_author_text's comment said it is applied by step_line to a
step's fields and nowhere else, but several lines escape author text the
step does not hold: a rejection's verdict label, an opaque operation's
and its outputs' names, a retry's verdict label and a named base's name.
It now names those, and says every author text a trace line states is
escaped on the way from step_line.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
Named base dimensions exist so that money is checked like any other
quantity. These tests follow a currency through every layer, with units
of the tests' own, since the library ships none: euros, a cent as a
hundredth of a euro, yen, and a tariff in euros per kilowatt-hour over
the library's own unit::KilowattHour.

Units: 250 EUR is exactly 25000 ct and back; euros never convert to yen,
refused as a domain error both in a constant expression and at run time;
yen round to whole yen and euros to the cent. A measurement in cents
converts to euros exactly, and one in euros is refused for a quantity in
yen, with or without a value. Formulas: 150 kWh at 3/10 EUR/kWh is
exactly 45 EUR, the product's dimension being euros while the formula
compiles; 10 EUR + 250 ct is 25/2 EUR; and 100 EUR at an exchange rate
of 16235/100 JPY/EUR, supplied as a quantity like any other input, is
16235 JPY. A tariff unit spelt three ways links across two translation
units.

Four cases must not compile: a price plus a pure number, euros plus
yen, a conversion from euros to yen, and a sum of euros evaluated into a
quantity in yen. In each, the two sides' SI exponents are the same, so
only the named base dimension explains the refusal; each is refused
once.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
… a slot built apart

The tariff unit's three spellings all went through the merge, so a
difference between slots the merge builds and slots built any other way
-- the g++ 13.3 and 14.2 fault merged_dimension's comment describes --
would have split none of them. The definition in unit_cross_tu_b.cpp now
combines two equal names in the merge and then raises the result to the
first power, which builds every slot again outside it. With the merge's
local made const again, both g++ versions then fail to link the call,
the declaration's mangled name holding an `Exponent const`; clang++ and
the unchanged code link. The test's comment says what fails where: a
call spelt as another type does not compile, a definition spelt as one
does not link.

unit_quotient's comment also says that an exponent overflow is not
judged: merged_dimension combines exponents through reduced, as the
division operator always did, and its guard still ends the program for
units whose exponents approach 2^31.

Smaller: the evaluate test's comment claims a wrong factor on one of the
two units is caught, not on both; the dimension constants read EuroAmount
and YenAmount; and a comment in trace_render.hpp is wrapped evenly.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
The composition example had to declare its euro as a bare ratio and say
so: the dimension system "will not stop you adding euros to a bare
ratio". The euro is now declared with base_dimension("EUR"), the caveat
is gone, and a static_assert states that its dimension is not the
scalar one, pointing at the negative test that pins the refusal of a
price plus a pure number. The example's arithmetic, output and trace are
unchanged byte for byte, so the guide that quotes them stays true.

The dimensions and units example prints a dimension's named bases after
its seven SI exponents, and gains a section on money: a tariff in euros
per energy (L^-2 M^-1 T^2 EUR^1), which times an energy is euros; 250
EUR is 25000 ct and converts back exactly; and 250 EUR to JPY is
refused as a domain error, since an exchange rate is data. Each printed
claim is also checked, and the checks join the example's summary. Its
earlier output is unchanged, and neither example's overflow census moved.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
unit_quotient's comment, as "test(unit): make the tariff's identity
across translation units catch a slot built apart" worded it, said an
exponent overflow is reachable only by units whose dimensions carry
exponents near 2^31. That is wrong: reduced also refuses a reduced
denominator above INT32_MAX, and adding or subtracting two exponents
multiplies their denominators. A length to the 1/46349 over a length
to the -1/46351 needs 92700/2148322499, and unit_quotient ends the
program on it -- measured on cl with a throwaway program, which printed
its line before the call and nothing after. The comment now says the
guard fires when a combined exponent, in lowest terms, would not fit
std::int32_t: a very large numerator, or two denominators whose product
passes 2^31.

Smaller wording: the tariff's cross-translation-unit spelling reads
"euros times euros, over euros times energy"; the dimensions example
says the unit named after a base has magnitude one; and the too-many
negative case's quantities are EuroAmount and YenAmount.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
A Dimension now holds up to four base dimensions the application names
itself, and the guides still described it as the seven SI exponents alone.
The dimensions guide gains a section on them, told through money: why a
currency is not a bare number; base_dimension and identity by name, byte
for byte, with the convention that makes it work -- the unit named after a
base has magnitude one, a cent a hundredth; one base per currency, so euros
never convert into yen and an exchange rate is data, a quantity in yen per
euro; the name rules and the guard each one names; the capacity, counted
after cancellation; and how a name is written in a coherent unit. Its
output blocks are the dimensions example's own money lines, from a run.

The same guide says where named bases appear in a dimension mismatch's
diagnostic -- by name on g++, as character codes on cl, clang-cl and
clang++, measured on all four -- replaces the hypothetical "if Dimension
ever grew an eighth base quantity" with what happened when it did, and
states the coherent unit, not the coherent SI unit, in the Units table.
Limits gains the capacity, the name limits and the guard names.

The expressions guide says the addition check refuses euros plus a number
and euros plus yen, pointing at the two negative tests that pin it, and
that the composed cost formula's price is in a dimension of its own. The
series guide's coherent unit covers a named base, and the README and the
documentation index mention base dimensions such as money.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
The comments and guides called the unit every value is carried in "the
coherent SI unit". For a dimension with a named base there is no such
unit: euros per kilowatt-hour are carried in EUR s^2/(m^2 kg), the SI
unit times one euro. Where a statement holds for any dimension -- a leaf
converted on the way in, the value a step stores, a method's answer, an
opaque operation's inputs, a series' elements -- it now says "the
coherent unit", which evaluate.hpp, unit.hpp and the dimensions guide
define as the SI unit times one of each named base.

Left as they were, because "coherent SI" is still exact there: the
comments on Metre, Pascal and the other coherent SI units in unit.hpp;
prose about one physical quantity -- kelvin in the expressions guide,
metres in the methods guide, kg^2 and Pa in the numeric-headroom guide,
the SI units the dimensions guide lists; and the tests, which state it of
physical dimensions only.

The tracing guide's quotation of Step::unit's comment had drifted from
the header before this change: it lacked the clauses on an overridden
constant and on rounding steps, and it left out the paragraph on
NumericValue while presenting the rest as consecutive. It is now the
header's lines as they stand. The base_dimension and coherent() comments
no longer say the unit named after a base "is one of it"; they say it has
magnitude one, by convention.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
…ings

The README and the documentation index listed named base dimensions as
shipped, beside text saying 0.1.0 is usable for what is listed as
shipped, while the changelog has them under Unreleased. The dimensions
row is back to what 0.1.0 shipped, and a row of its own says named base
dimensions come in the next release. The index also said series and
statistics were planned, though 0.1.0 shipped them; its table now lists
the same shipped rows as the README, which match 0.1.0's own.

Wordings:
- the dimensions guide gives the character codes a name is printed as in
  prose, since cl and clang space them differently;
- a computed step does carry a unit, the coherent unit of its dimension,
  only no symbol of its own, and the expressions guide now says that;
- each money addition fails to compile with exactly one error, which is
  what the two negative tests pin, not merely "is refused once";
- unit_quotient's comment said two denominators whose product passes
  2^31 overflow, which reads as sufficient and is off by one: the
  product must still exceed INT32_MAX once in lowest terms;
- the tracing guide's quotation of Step::unit ends where the paragraph
  it discusses ends, so it stays verbatim without bringing in
  NumericValueNode and sourceUnit, which the guide does not explain.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
Quantities bound to the expressions that calculate them, a dependency
graph checked at compile time, and a worksheet that recalculates only
what a change reaches, with what-if copies, overrides and a derivation
per named value.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
QuantityList and JoinQuantities lived inside the overlay header, where only
the overlays could reach them. Code that needs to reason about which
quantities an expression reads has to build, de-duplicate, subtract and index
such lists too, and should not have to include the overlays to do it.

Move both into detail/type_list.hpp, which overlay.hpp now includes, and add
the list utilities that code needs: UniqueQuantities (each quantity once, in
order of first appearance), QuantitiesWithout (a list minus another, order and
repeats kept), quantity_count_v, quantity_index_v and QuantityAt.

quantity_index_v yields the first position of a quantity, and the length of
the list for one that is not listed, as Environment's own search for an entry
does. A caller that must refuse an unlisted quantity compares with
quantity_count_v and words its own refusal, so one mistake draws one message
rather than a generic one on top of it.

The overlay's local alias for how a derived quantity's definition is rewritten
is renamed DefinitionRewrite: it names the rewrite rather than the definition,
and it frees `Definition` for a public type of that name. No behaviour
changes; every utility is covered by compile-time tests next to the existing
index_in_tuple ones.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
… at run time

The variable evaluator decided where a read's value came from by the
environment's static is_entered<Q>, and read the value through get<Q>(),
which cannot carry a failure. An environment that works some of its values
out from others must be able to say at run time that a value was
calculated, and to let a calculation that failed fail the read, so that the
failure reaches the formula as any operand's failure does.

Two optional hooks, both backward compatible:

- source_of<Q>() returning exactly ValueSource is now preferred over
  is_entered<Q>, for a variable and for the entry an overlay's constant or
  derived quantity replaced. Environment's answer agrees with is_entered for
  every quantity it holds, so no existing trace changes.
- checked_get<Q>() returning exactly std::expected<Measured<Q>,
  ArithmeticError> is read instead of get<Q>() when present. A failed read is
  recorded at the variable's own step, after its source, and its parent
  relays it; an untaken when() branch never reads it. Only that exact return
  type is taken for the hook, so an unrelated member of the same name is
  never read by accident.

The environments a precision limit, a rejection and a retry evaluate in
forward each hook only when the environment they wrap has it: forwarding
source_of unconditionally would make a consumer's environment without it
fail to compile inside a traced precision limit.

A variable whose value was calculated renders ", calculated", and one with
no value "(no value)", since "(not measured)" would be false of it.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
…cover

Two constraints on the run-time source hook were not pinned by any test.

A retry's attempt environment forwards source_of only when the environment
it wraps has one. Every retry test wrapped an environment that does, so
deleting that constraint passed the suite while breaking a consumer's
explain_retry over an environment with only get and is_entered. A test now
retries over such an environment: the attempt environment has neither hook,
and every read's source is the one is_entered gives.

The hook is taken only when source_of returns exactly ValueSource. The one
lookalike tested returned an int, which a convertible_to check rejects as
well, so loosening the check passed the suite. Two more lookalikes answer
Derived -- one through a type that converts to ValueSource, one through a
reference -- against an is_entered that says otherwise, and the trace keeps
is_entered's answer for both. The concept's comment now says a reference or
a converting type is not the hook, as its sibling's does.

Environment::source_of's comment now says that an entered series is
ManuallyEntered too, and that an overlay's replaced entry asks it as well as
a variable does. The bound environment's comment no longer leans on how its
member list was found; it lists what it forwards and states what it does
not: get_observations, so raw observations read inside a precision limit's
limit expression do not compile.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
…, known at compile time

Composing formulas today means embedding one expression tree in another:
nothing names the quantity a sub-formula calculates, and nothing can say
which values depend on which. Binding each quantity to the expression that
calculates it is the first step towards a calculation whose dependency graph
is known, and checked, before anything runs.

define<Q>(expression) returns a Definition<Q, Expr>, in the new header
calculation.hpp, which formula.hpp includes. Its reads -- each quantity the
expression reads, once, in the order first read -- are worked out at compile
time by walking the expression through the children every node kind already
lists for a precision limit's checks, so a node kind the library ships is
seen, or refused, in one place for both. A precision limit's limit expression
is walked as well as its level, since both are evaluated against the same
environment. A when() lists its condition and both branches: what it may
read. An overlay's derived quantity reads what its definition reads, and a
fixed constant reads nothing; the placeholders a construct binds and a
retry's context read nothing either.

A definition is refused where it is written, with one message each, when its
expression measures a dimension other than the quantity's, is a series,
reads a quantity as a series or as raw observations, reads from another
record, or holds a node kind of a consumer's own that the walk cannot see
inside -- a quantity read there would be missing from the graph and read out
of date. A library kind without an entry of its own is refused in the
existing words for that omission, and the consumer's-kind message does not
follow it. A node refused already reads nothing and is asked nothing, so no
second message follows a first.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
…istry covers

The definition's dimension check is not asked of an expression refused
already, whose dimension is a stand-in. The one test of such an expression
had the defined quantity's dimension, so deleting that gate passed the suite;
it now defines a share by what arithmetic over a retry of a mass leaves.

The negative cases of a series read, an observations read, a record read and
an unseen node kind asked the definition whether it was valid, which walks
the expression by itself; each now only calls define, so define alone must
refuse. The case of a library kind without a children entry used what
arithmetic over a retry leaves, which would stop meaning anything if that
kind gained an entry; it now declares a node kind of its own in the
library's namespace and reaches it through define.

The header comment claimed a children entry for every node kind the library
ships; three have none, and it now names them. It said a retry's context
reads nothing from the environment, which attempt_input does inside a
retry; it now says why the walk may list it as reading nothing: no
definition can hold a retry, and outside one the evaluator refuses it.

A single value made of the remaining series kinds -- a negation, a rounding
per element, a running total, a splice of two curves -- is now walked too.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
…eries over it

A definition says which quantity an expression calculates and what it
reads. Put several together and the question becomes which values depend on
which -- what must be calculated first, what a changed input reaches -- and
whether they can be calculated at all: a quantity defined twice, a
definition that reads itself, or definitions that read one another in a
circle have no answer, and should be refused where they are written rather
than discovered when evaluated.

calculation(define<A>(...), ...) builds a Calculation. Its inputs are what
its definitions read and none of them defines, in the order first read; its
graph -- what each quantity reads, what reads it, and both through any chain
of reads -- is worked out at compile time over one 64-bit row per quantity,
every algorithm at most N^2 word operations: 4,096 at 64 quantities, which a
test of 64 builds with cl 19.51, g++ 13.3 and 14.2, and clang 20.1.8. Its
dependency order lists the inputs, then each defined quantity once
everything it reads comes before it, taking the one given first whenever
several could come next: definitions given in dependency order keep their
order, and others are reordered.

dependencies_of, dependents_of, upstream_of, affected_by, inputs_of and
calculation_order answer the symbols concerned, in dependency order, as the
vocabulary they are given writes them; depends_on answers whether one
quantity depends on another, in a constant expression.

Refused where the calculation is written, one message each and each check
asked only once those before it hold: an argument that is not a definition,
no argument, a quantity defined twice, more than 64 quantities, a definition
reading what it defines, and a cycle, all of whose quantities one message
names. A calculation holding a definition refused where it was written is
not judged further, and its queries answer nothing and say nothing; a query
about a quantity the calculation neither defines nor reads is refused.

The empty answer is a namespace-scope constant rather than an array
value-initialised in a function template: cl does that through a helper of
its own that declares a local named i, and a consumer's global of that name
then drew warning C4459 through this header.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
…ches

A calculation knows which of its values depends on which, but nothing yet
holds the values: evaluating each definition by hand means evaluating a
shared value once for every value that reads it, and after one input
changes, evaluating everything again, although most of it cannot have
changed.

worksheet(calculation, environment(...)) builds a Worksheet, which takes
the inputs from the environment and calculates each defined quantity when
it is first asked for. calculate<Q>() answers an Outcome<Q>, throwing for a
failed calculation, and checked_calculate<Q>() the std::expected; either
takes several quantities, answering a tuple, or their variables:
auto [total, vat] = sheet.calculate(var<Total>, var<Vat>). Each definition
is evaluated by checked_evaluate, as it would be on its own, against a view
of the worksheet that shows it only the quantities it declares it reads: a
calculated value is read as an input is, its source Derived, a failed one
fails the read as a failed operand would -- except on a when() branch not
taken -- and a read the view does not show fails to compile rather than
read a value not brought up to date.

set(...) changes inputs, or overrides a calculated value by hand with
entered(...); clear_override<Q>() drops an override; with(...) answers a
changed copy. A change marks what it reaches, and nothing is calculated
until something is asked. A value the change reached is calculated again
only if something it reads changed since it was last brought up to date,
and otherwise reused; a value calculated again to the same answer, from the
same source, counts as unchanged, so what reads it is reused in turn. On
the household bill, twice the fridge's power for half the hours calculates
two values again and reuses the eight after them. recomputed() and reused()
count both. The calculation's queries take a worksheet too.

Tests compare every value of a worksheet changed step by step with one made
from scratch, over four sequences of changes, and each value with
checked_evaluate of its definitions inlined by hand, over seven sets of
inputs; a small worksheet sets and recalculates inside a constant
expression.

Refused where it is written, one message each: an environment with no entry
for an input -- asked only once every entry is accepted, so that an entry
naming the wrong quantity draws one message, not two -- with an entry the
calculation neither reads nor defines, with a series, or with a calculated
quantity given as a measurement rather than entered; set() naming a quantity
twice, and only then one the calculation does not hold, a series, or a
calculated quantity given as a measurement; asking about a quantity the
calculation does not hold; and clear_override of an input. A worksheet of a
calculation refused already says nothing more.

The CHANGELOG no longer says calculation_order lists the inputs: it lists
the defined quantities, and inputs_of the inputs.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
Christian Parpart added 7 commits September 29, 2026 19:00
…r claims shown

The guide's comparison example was wrong under its own rule: 12.04 % is
an exact decimal, so it would never read ≈12.0 %. The second specimen
is now 289/24 %, which the trace's input line rounds to ≈12 % while the
comparison states 289/24 % against at most 12 %, violated.

"What goes wrong" named three guards but said when only one fires. A
table now gives each guard the specs that fire it and what to write
instead, including the one whose name misleads: places_out_of_range
for {:~HalfEven} on a unit declaring more than 18 decimals.

Claims the example stated but never showed are now its output: the
dish's mass is its weighings' sum times a typed 1/3, which the rounding
style still writes 1/3 in the trace and in the formula's text; the
padded trace keeps the weighings' second decimal; and a rejection of the
weighings shows document() writing its typed limit, 1/30 of the pass's
mean, as typed. A derived quantity's derivation, which needs a method
and an overlay, is linked to the guide that shows one rather than
built here.

Also:
- the snippets define what they use: the trace, w and notMeasured, the
  measured values of the reference, and rat();
- the vformat snippet shows its catch, and print_spelled shows view();
- a paragraph on what each surface does with a number it cannot spell;
- links for checked_round, declared decimals and the vocabulary;
- typed_number_style in place of a spelling that kept the padding;
- the intro, a sentence that misparsed, and a computed-unit claim that
  a series' sum contradicted, corrected;
- the reference measures a wet mass as wetMass, and its temperature
  and grain size are plainly invented;
- the grammar's fill is one Unicode scalar value, in the guide and in
  both specialisations, and its sample is the example's 157.4 g;
- format's @throws says only a value with no exact decimal of at most
  18 places is rounded, and so can overflow;
- the README and docs index status tables list decimals and std::format
  as next release.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
… guard's line

Three places the display guide and README quote could drift unnoticed:

- The guide's spec table states a call and its output in each row,
  beside a checked block holding the same calls. CheckGuideOutput.cmake
  now takes CALL_TABLES: a table row whose last two cells are a
  `std::format(` call and its output must match a line the example
  prints. docs.display-output passes it; no other guide is judged so.
- README's std::format block is fenced as text, and
  docs.readme-display-output judges README's text blocks against the
  display example.
- CheckDocumentedDiagnostics.cmake gains a rule for MSVC's `note: see
  usage of '<name>'`: the header line it quotes must call `<name>(`. The
  guide's quote of the rounding-mode guard is pinned to the call, where
  before only a drift onto a blank or comment line failed.

Each was shown to fail on a deliberately wrong line and pass once it
was restored.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
The display guide says {:~HalfEven} on a Measured whose unit declares
decimals outside -18 to 18 is refused as places out of range. Only the
upper side was tested through std::format. A unit declaring -19
decimals is now refused with the same message; without the lower check
it would throw later, as a value that cannot be spelled.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
…base

A computed value is stated in the coherent unit of its dimension, and a
dimension may now carry a named base such as money, whose coherent unit
is no SI unit. The display guide said "coherent SI unit" of any computed
value; it now says "coherent unit", as the rest of the documentation
does.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
render_derivation took TraceRenderOptions but wrote every number as a
fraction, whatever options.numbers said: its steps and input lines were
rendered in NumberStyle::fraction(), and a header's value through
fraction_text. It now honours the style exactly as render_trace does:

- each step and input line is written as render_trace writes it in the
  style;
- an input with no step of its own falls back to the value its block
  holds, spelled the same way;
- each header's value is spelled as a trace line states a value in its
  unit -- rounded and marked, padded, or exact -- and exact where the
  block's root states a typed number, so that the header and the root's
  line never disagree; one the style cannot spell there reads
  "(not shown: ...)";
- each header's definition is rendered through the vocabulary carrying
  the style, so its typed numbers are exact and never padded, as in any
  rendered formula.

A calculation's render and document already took RenderOptions through
the generic overloads; tests now pin that their definitions' typed
numbers follow it, and are never rounded or padded. describe_graph and
to_dot print no number.

Tests render a derivation in fractions, in exact decimals and rounded,
where the header, the steps and the inputs agree and the typed 1/3 is
never rounded; a header over a typed root; an input's fallback line; and
the household bill rounded and padded. Undoing any one of the five
threads fails a test.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
…write instead

- CheckGuideOutput.cmake's CALL_TABLES counted the spec table's rows
  but did not pin them: a row whose Output cell lost its backticks, or
  whose Example cell stopped being a code span, dropped out of the check
  silently. A table row that names std::format( in any other form is
  now refused.
- CheckDocumentedDiagnostics.cmake's "see usage of" rule accepted a
  comment line that names the call. The line must now call it in code.
- The guide's write-time refusal of {:~Mode} said why a value is refused
  but not what to write instead. No spec rounds such a value to tens or
  thousands: {:~.0HalfEven} rounds it to whole units, since 0 to 18
  places are spelled by long division, which cannot overflow; {:/}
  writes it exactly; or the std::format_error can be caught. A format
  test pins {:~.0HalfEven} of from_double_exact(0.1) at -3 decimals as
  ≈0.
- The comparison example says why ≈12 % shows no decimal: 12.0, its zero
  trimmed.
- The example's rejection, 130 columns wide, is wrapped under 125, in
  the example and in the guide's quote of it.

Each check was shown to fail on a deliberately wrong line: the spec
table's row with its Output cell unquoted, the quoted guard call turned
into a comment, and the new format test expecting ≈1.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
render_derivation stated a typed value exactly in its own block's header
and root, but a block reading it -- a Variable step whose source is
Derived -- styled it as any computed value: under a rounding style the
typed 2/3 read "2/3" in its block and "≈0.667" two lines away.

The step alone cannot say which block it read: a vocabulary may write
two quantities alike, and Step, whose size is pinned, holds no quantity.
explain_worksheet now evaluates each definition through a view that
notes every read's slot at the index of the step reading it -- a node
reads the worksheet just before its own step is recorded -- and records
them in the new WorksheetEntry::readSlots, one per step. render_derivation
decides which blocks hold a typed value, walking them from the last so
that every value a block reads is decided before it, and states a read of
such a block, and anything that passes it on, exactly: on the reading
line and in the header of a block that only passes it on.

Also:
- the header's "(not shown: ...)" branch, where a style cannot spell a
  value in its declared unit, is tested, under a padding and a rounding
  style, with the root spelling the value in metres and fractions
  unchanged;
- the three places that spelled "(not shown: ...)", and two more, share
  one helper, not_shown_text;
- the header's comment and the display guide say what is true: the
  header and a line reading its value agree, while the block's own root
  agrees on whether the value is rounded, not always on its unit or
  padding;
- the vocabulary's comment says render_derivation wraps it on every
  call;
- the input fallback is tested in kilowatt-hours under a padding style;
- render(calc) and document(calc) are tested with a typed number in a
  unit that declares decimals, unpadded under a padding style;
- the derivation tests' fixtures move into the file's one namespace.

Each piece was undone in turn, and each failed a test: the typed read,
the noting of reads, the "(not shown: ...)" branch, the fallback's unit
and the typed style of a formula's numbers.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
@christianparpart christianparpart self-assigned this Sep 29, 2026
Christian Parpart added 3 commits September 29, 2026 20:46
…nificant digit

A value in a unit nobody declared -- a computed step, stated in the
coherent unit of its dimension -- is rounded at that unit's default 3
places. For a price per energy that unit is euros per joule, and every
such step read "≈0": marked, so not false, but saying nothing. A price
of 3401/9300 EUR/kWh is 3401/33480000000 there.

checked_shown_text now extends the places, where they round a value other
than zero to "≈0", to the value's first significant digit, up to 18, and
rounds there in the style's own mode: 3401/33480000000 reads
"≈0.0000001", and 1/11250000 "≈0.00000009" rather than the "≈0.0000001"
it rounds up to one place sooner. A value with no digit within 18 places
still reads "≈0", and a unit someone declared keeps its declared places.
Trace lines and a derivation's headers both spell through it.

Tests pin the tariff in euros per joule and in a pure number, a negative
value, the value that rounds up a place early, and what does not change:
a value showing a digit at 3 places, a rounding the mode takes away from
zero, a value beyond 18 places, a declared unit and the exact style. A
derivation working a price out of a cost and an energy reads its division
step as "≈0.00000009". Without the extension, or rounding at the first
place the style leaves something at, both tests fail.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
…example

docs/dimensions.md quotes examples/dimensions_and_units.cpp's code and
output, the money section's included, but nothing checked either: its
output blocks were fenced as plain blocks, which CheckGuideOutput does
not judge, and no test named the guide.

Its nine output blocks are now fenced as text, and the two code blocks
that are not from the example -- the computed mass rounded to its unit's
precision, and exponent(1, 0), which must not compile -- carry the
"not from the example" marker. docs.dimensions-output and
docs.dimensions-snippets judge the guide as the statistics guide's tests
judge theirs: nine blocks of 24 output lines, and four code blocks, all
real.

Each test was shown to fail on a deliberately wrong line -- 25000 ct
quoted as 2500 ct, and a tariff per power rather than per energy -- and
to pass once it was restored.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
formula::exact_decimal(Rational) returned a value's exact decimal as a
std::optional<NumberText>, while NumberStyle::exact_decimal(padding)
returns a style: one name, two meanings. The function is renamed
exact_decimal_text, beside its siblings fraction_text, decimal_text and
number_text, while it is still unreleased. Its callers, its comments,
the tests and the consumer-globals probe follow, and the CHANGELOG now
names it and has_exact_decimal. NumberStyle::exact_decimal keeps its
name.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
@christianparpart
christianparpart marked this pull request as ready for review September 29, 2026 19:09
Christian Parpart added 15 commits September 29, 2026 21:17
…t_decimal_text

The public formula::exact_decimal_text(Rational) forwarded to
formula::detail::exact_decimal_text(Rational, int), an overload in
another namespace under the same name: one name for the public answer
and for the helper that also pads to a minimum number of places. The
helper is now detail::exact_decimal_digits, and its three callers --
exact_decimal_text, checked_decimal_text's path for whole tens and
hundreds, and checked_number_text -- follow. Nothing public changes.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
…ery node that reads

render_derivation states a typed value exactly where another block reads
it, by the slot WorksheetEntry::readSlots notes for the reading step.
That rests on one invariant: only a Variable step's entry is read, and
the variable evaluator reads its value just before it records its own
step. Nothing tested either half over the nodes that read a worksheet,
and dropping the Variable-kind check in reads_typed_block left every
test passing, although a derived quantity standing for a typed value
then read as typed.

A new calculation holds every node that reads the worksheet: a typed
factor, a share whose derived factor stands for it but computes 1/3,
when() taken both ways, a precision limit whose level reads the factor,
a documented value, a rounding, and a share over a fixed factor. One
test checks each step of each block: a Variable step notes its own
quantity's slot, an overlay's step its own quantity's slot or none, and
every other step none; and that the calculation holds each of those
node kinds. A second renders the share under a rounding style: the
derived factor's line reads "k = #3 = ≈0.333 [derived by ...]", while
the typed factor read beside it stays "2/3".

The derivation text and render_derivation's comments said a block's
header and its root agree on whether the value is rounded. They agree
only on whether it is typed: a computed root states the value in the
coherent unit, so it may differ from the header in unit, padding and
decimals, and one may read "≈" where the other does not -- 1 u, in a
unit of a third of a metre, is ≈0.333 at its root. docs/display.md now
says so, its paragraph reflowed; NotedWorksheetView's comment states the
invariant above instead of a broader one; and readSlots' doc says to
keep it in step with trace when editing either.

Dropping the Variable-kind check fails the rendering test on
"k = #3 = 1/3"; noting a read one step late fails the invariant test;
asking the calculation for a node kind it does not hold fails the check
on kinds.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
…-digit rounding

docs/dimensions.md's code and output are checked against its example,
but its introduction said only that the output was copied. It now says
what docs.dimensions-output and docs.dimensions-snippets check, as the
statistics and display guides do, and what the "not from the example"
marker means. The declared-precision block was marked although it is
the example's own three lines plus a "// 450/7 kg" comment, which the
output block below it prints anyway. The comment is gone and so is the
marker, so the snippet test now checks it. Only the block showing that
formula::exponent(1, 0) does not compile stays marked.

The test of a rounding in a unit nobody declared gains five cases:
- 1/10000001 at half-even: its first digit is at 8 places, where it
  rounds up, and reads "≈0.0000001", not a padded "≈0.00000010";
- 1/11250000 at floor: "≈0.00000008";
- the tariff at floor: "≈0.0000001";
- the negative tariff at ceiling: "≈-0.0000001";
- 1/300000000000000000 at half-even: a digit at the 18th place,
  "≈0.000000000000000003".

docs/display.md's "keeps those 3 places" now says "(bar one exception,
below)", which the next section explains. A CHANGELOG line of 127
columns is reflowed.

Each control failed once and passed when undone:
- the carry's expected text written padded;
- the extended rounding padded;
- the search stopping short of the 18th place;
- the comment put back in the unmarked block.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
…o calculations

A calculation and a worksheet had an API and tests, but no program and no
page a newcomer could read them from.

examples/electricity_bill.cpp works a household's monthly bill through
them, in the library's power and energy units and with currencies of its
own, each a base dimension: the calculation and its graph, described,
queried and drawn for Graphviz; a first run and four changes, with how
many values each recalculated and reused -- 15 and 0, 5 and 0, none, 3
and 0, and 2 and 8 where the fridge's energy a day comes out the same; a
derivation of that energy after the change; a what-if copy; a value typed
in by hand and cleared; and a division by zero reaching the value that
reads it. It checks every number against an exact one, and its pass
expression pins the counts.

docs/calculations.md is the guide: why a calculation rather than a
composed formula, definitions and the graph known at compile time,
worksheets, asking, changes and early cutoff, what-if copies,
derivations and why they are never stale, overrides, failure and
absence, each refusal with the message a real compile printed, and the
limits. Its code blocks are checked against the example's source. It is
linked from the composition section, the navigation, the index and the
README, whose status table lists calculations for the next release.

The numeric-headroom page gains the example's row.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
…re declares

A derivation test's comment wrote twelve hours as "43,200 s", which reads
as a decimal comma to half of this library's readers; it now reads
"43200 s". The shared bill fixture's comment said its money was
dimensionless "as examples/composition.cpp explains", which that example
no longer does: it now says the fixture keeps its own units and a bare
number for money, and that the electricity bill example states the same
bill in the library's units, with currencies of their own.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
…ailure on a calculation of its own

The electricity bill had grown two values, a price paid per kilowatt-hour
and its gross figure, only so that a division by zero had something to
reach. They made its graph, its description and its DOT differ from the
bill of ten inputs and fifteen calculated values the example states.

The bill is back to those fifteen. The failure is shown on a second,
small calculation in the same example: a cost shared among the people
who live there, each share in whole cents. Shared by three, 98.00 EUR is
32.67 EUR each; shared by nobody, the share divides by zero and the share
in cents, which reads it, fails with it, both checked and thrown, with
the derivation saying so. The guide's section on failure quotes that
calculation, and the pass expression pins its line.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
…inned

The electricity bill printed its numbers through a helper of its own, and
its guide quoted none of its output, so nothing checked that what the
page says the program prints is what it prints.

The bill now spells its numbers as the library does. Each step's line is
a std::format of the measured values -- the total padded to the cents it
is rounded to, 98.00 EUR, the net draw as its exact decimal, 279 kWh --
and the calculation and both derivations are written in exact decimals:
self_used = solar * 0.8, and the fridge at 0.4 kW and 400 W after the
change that left its energy a day at 4.8 kWh.

The guide quotes the program's output at each step: the calculation
written out, the queries, the graph described, the DOT's opening and
close, every step's line with its counts, the what-if copy, the value
typed in by hand and its clearing, both derivations and the failure.
docs.calculations-output checks each quoted block against the program's
real output, and the pass expression now pins the four totals as well as
the counts.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
The status tables of the README and the documentation's index list what
the next release brings -- named base dimensions, decimals, calculations
-- but not the power and energy units and Fahrenheit, which it brings
too. Both tables now have that row.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
…nly said

The derivation the guide shows is now the daily load's, which the fridge's
change reused rather than calculated again: it reads the fridge's current
400 W and no 200 W, and recording it moves neither counter. The example
checks all three, and the pass expression pins the daily load's first line
right after its title. A bracket argument cannot spell a line break, so the
expression is built with one spelled as CR? LF.

The guide gains a section on the headers and names the example uses and one
on its quantities; says what `counted` and `step` are, that
`.2HalfAwayFromZero` rounds and pads and that std::format requires a mode;
states the order the queries and describe_graph name values in; and shows
the documentation page's symbol table, the grid cost's derivation with the
value typed in and the step-limit footer, the bill's total read by the
second calculation, a failure not calculated again, and an input nobody
counted. It quotes three more refusals -- the same quantity set twice, a
quantity set that the calculation does not hold, and a query about one --
and names the rest.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
`set` already had an rvalue form that answers the worksheet, changed, so
that `worksheet(...).set(...)` can be kept or asked at once. `clear_override`
now has one too, so a chain that sets an override can also clear it, and
`std::move(sheet).clear_override<Q>()` hands the worksheet on.

A test clears an override in such a chain and on a moved-from worksheet,
and pins that the net draw and the five values built on it are calculated
again. Returning the worksheet without clearing it fails that test.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
…orksheet

The consumer-globals probe now instantiates `render` and `document` with a
`RenderOptions`, and a worksheet over quantities in a currency of the
consumer's own -- a named base dimension -- asked, derived in exact
decimals, formatted, and with an override cleared on a worksheet about to
be discarded. The probe checks 80 properties, up from 78.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
…t surface

Nothing tested a calculation over a named base dimension. One case now
calculates a charge, a total and a rate in an invented currency and its
price per kilowatt-hour, and pins the rate's derivation in exact decimals,
`describe_graph`, the documentation page's formula and each row's unit and
definition, and `std::format` and `number_text` of values in euros and in
euros per kilowatt-hour.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
…claimed

The second calculation now takes the bill's total through
`checked_convert_to<SharedCost>` rather than re-wrapping its raw value: the
value is converted exactly into the input's unit, an absent total stays
absent, and a total of another dimension is refused. The guide says why the
re-wrapped number would be wrong once the units differ.

The example now also prints `inputs_of`, `calculation_order` and
`dependencies_of<GridCost>`, and shows a value that fails again with the same
error counting as unchanged: a new cost, still shared by nobody, calculates
the share again and reuses the share in cents. The pass expression pins that
line. It no longer names `calculation.hpp`, which `formula.hpp` brings.

The guide defines inputs and calculated values where quantities are
introduced, says what `Quantity`'s first argument is, says where the blocks
it does not quote sit in the daily load's derivation, quotes how the failure's
counter is read, and points the `when()` claims at the tests that pin them.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
…ry text function

An unqualified call of the new text functions, or of `define`,
`calculation` or `worksheet`, now also finds the library's function by
argument-dependent lookup, which can make a consumer's own helper of the
same name ambiguous. That breaks source code, so it moves from the
number-text entry to "Changed", naming every function it applies to.

The number-text entry now also names `fraction_text`, the checked forms
`checked_number_text` and `checked_decimal_text`, and the constants
`ApproximationMarker` and `NotMeasuredText`.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
The display guide names a worksheet's derivation twice and a calculation's
rendering once. Each now links the section of the calculations guide that
explains it.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
@christianparpart
christianparpart merged commit c551c5d into master Sep 29, 2026
12 checks passed
@christianparpart
christianparpart deleted the prototype-catch-up branch September 29, 2026 20:17
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.

1 participant