From 9178f3bb5ef691f2d71fba07ec7861b9157c9da9 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Sun, 4 Oct 2026 22:48:02 +0200 Subject: [PATCH 01/35] docs: design the fixes for the ten open issues Covers #11 and #13 to #21: trace text (a scaled dimensionless unit must have a symbol, an unnamed unit named by its size in a rounding clause, negative exponents for an inverse-only unit, a precision limit's first pass borrowing its unit, the snap's on-a-value compare), 128-bit arguments for the logarithm and exponential kernel, pinned bit widths in two guides, the library's refusal for explain of a series, two documentation corrections, and self-describing comments in place of development labels. Signed-off-by: Christian Parpart --- .../specs/2026-10-04-open-issues-design.md | 354 ++++++++++++++++++ 1 file changed, 354 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-04-open-issues-design.md diff --git a/docs/superpowers/specs/2026-10-04-open-issues-design.md b/docs/superpowers/specs/2026-10-04-open-issues-design.md new file mode 100644 index 0000000..ddf8712 --- /dev/null +++ b/docs/superpowers/specs/2026-10-04-open-issues-design.md @@ -0,0 +1,354 @@ +# Closing the open issues — design + +**Status:** draft, ready for review · **Date:** 2026-10-04 · **Owner:** Christian Parpart + +Resolves issues #11, #13, #14, #15, #16, #17, #18, #19, #20 and #21: every issue open on 2026-10-04. They land +together, on the branch `feature/open-issues` from `master` at `1ed39ed`, in one pull request whose body closes all +ten. + +## 1. Delivery + +The ten issues fall into three groups that touch different code: + +| Group | Issues | Main files | +|---|---|---| +| Lane A: trace text | #14, #15, #17, #16, #18 | `trace_render.hpp`, `trace.hpp`, `precision.hpp`, the unit checks, `test/trace_shown_unit_tests.cpp`, pinned trace texts in tests and guides | +| Lane B: numerics | #20, then #19 | `detail/transcendental.hpp`, `rounded_transcendental.hpp`, `detail/least_squares_kernel.hpp`, the census, `docs/numeric-headroom.md`, `docs/opaque-and-retry.md` | +| Final tasks | #11, #21, then #13 | `trace.hpp`, `README.md`, one design document, then comments in about 20 headers and most test files | + +- Lane A and Lane B run in parallel. Lane A runs in the session's worktree. Lane B runs in agent-owned worktrees, + each fast-forwarding from the previous Lane B head. Lane B merges into the branch before the final tasks start. +- #20 precedes #19 inside Lane B: both edit `docs/numeric-headroom.md` and the kernels' comments. +- #13 runs last. It rewrites comments in files both lanes change, so running it in parallel would conflict with + both. Running it last also catches any label a lane adds. + +## 2. Lane A: trace text + +### 2.1 #14: a scaled dimensionless unit must have a symbol + +**Problem.** A dimensionless quantity in a unit with a scale and no symbol (hundredths, magnitude 1/100, no symbol) +traces as the number in that scale with nothing after it: one half prints as `50`. `detail::spells_coherent_unit` +(`trace_render.hpp:662`) leaves every dimensionless value in its declared unit, which is right only at scale 1. + +**Decision.** Refuse such a unit where it is declared. A scaled dimensionless unit without a symbol cannot be +written truthfully anywhere, so it is a declaration error, not a rendering problem. + +**Design.** + +- A new check, `detail::RequireNamedScaledScalar`, beside the other unit checks. It fails when all three + hold: + - `U.dimension == dim::Scalar`; + - `view(U.symbolText)` is empty; + - the magnitude is not 1 or the offset is not 0, compared as reduced fractions. +- Its message: `formula: a dimensionless unit with a scale must have a symbol (for example "%"), or the quantity + must be declared in scale 1`. +- It is asserted at every entry point that accepts a `Unit`: + - a quantity's description, beside `DescribesConsistentDimension` (`quantity.hpp:244`); + - `constant`; + - every node or factory with a unit parameter: lookup, snap and band keys, `rounded<…>` (including + `DecimalRounding` and `SignificantRounding`), `rounded_output`, conformity, escape, and any other. + The plan lists every site, found with `git grep` for `Unit ` template parameters. +- Where an entry point already gates its checks so that one mistake gives one message, the new check is gated the + same way. +- `unit::One` and every shipped unit pass. The scaled ones (`Percent`, `PerMille`, `PartsPerMillion`, + `MilligramPerKilogram`) have symbols. +- `spells_coherent_unit` keeps its rule. Its comment states that a dimensionless unit with no symbol now always + has scale 1, so its bare number is the value. + +**Tests.** Negative tests (`test/negative/`, `formula_add_negative_test`) on MSVC and clang-cl, one per kind of +entry point: a quantity, a constant, and a keyed node. Each expected text includes `must have a symbol`. + +**Compatibility.** This is a breaking change: a program that declares such a unit stops compiling. The CHANGELOG +entry says so and names the fix: give the unit a symbol, or declare the quantity in scale 1. + +### 2.2 #15: the rounding clause names an unnamed unit by its size + +**Problem.** `round(#1, to 2 dp)` writes no unit clause when the unit has no symbol, while the value after it reads +in the coherent unit. "2 dp" then reads as places of a kilogram, which is not what was computed. + +**Decision.** Name the unnamed unit by its size in the coherent unit, in the existing `to N dp of ` shape. + +**Design.** + +- A new `detail::rounding_unit_text(Unit)` gives the text after `of`: + - a unit with a symbol gives its escaped symbol, as now; + - a dimensionless unit at scale 1 with no symbol gives nothing, as now. After §2.1 this is the only symbol-less + dimensionless unit that can reach a rounding; + - a dimensioned unit with no symbol and no offset gives its magnitude, spelled exactly in fraction style, then + the coherent unit's spelling (`coherent_unit_text`): `1/1000 kg`. A magnitude of 1 is still written: `1 kg`; + - a dimensioned unit with no symbol and an offset gives its size and its zero, both in the coherent unit: + `1 K from 27315/100 K`. The places then count steps of the size from that zero, which is how the rounding + computes them. +- It replaces `unit_symbol_text(...)` at every `rounding_call_text` call site (`trace_render.hpp:1163`, `:1275`, + `:1341`, `:3007`), and at `render()`'s `detail::rounding_call` call sites (`render.hpp`), so the formula text + and its trace use the same words. +- Rounding to significant digits that writes a unit clause gets the same text. +- The rounded transcendental keeps writing no unit clause: it rounds a pure number at scale 1. + +**Example.** `2. round(#1, to 2 dp of 1/1000 kg) = 3/1000 kg`. + +**Tests.** +- In `test/trace_shown_unit_tests.cpp`: a rounding to 2 places of a quantity in `UnnamedGram`. Its line contains + `to 2 dp of 1/1000 kg`. +- A `render()` test for the same formula. +- A test for the offset form, if any rounding form accepts a unit with an offset. If none does, the plan records + that, and the offset branch is unreachable and stays untested. + +### 2.3 #17: an inverse-only coherent unit uses negative exponents + +**Problem.** `coherent_unit_text` spells a dimension with no positive exponent as `1/X`, so after a fraction a +line reads `20000/413 1/kg`. + +**Decision.** When nothing would stand above the slash, write each factor with its exponent negated, in the caret +style the spelling already uses. Units with a numerator keep the slash. + +**Design.** + +- In `coherent_unit_text` (`trace_render.hpp:610`), when `above` is empty, `below` is built with negated + exponents and no slash. +- Factors keep their existing order and are separated by a space: named bases first, then the SI bases. +- Exponent spelling, for a factor of exponent -n/d: + - `^-n` when d is 1, including n = 1: `kg^-1`; + - `^(-n/d)` otherwise: `kg^(-1/2)`. +- Examples: `kg^-1`, `s^-1`, `m^-3`, `m^-1 s^-1`, `JPY^-1`, `kg^(-1/2)`. Unchanged: `m/s`, `EUR/JPY`, + `EUR s^2/(m^2 kg)`. +- The function's comment loses `1/JPY` and gains `JPY^-1`. + +**Tests.** +- The `coherent_unit_text` cases in `test/opaque_tests.cpp` change to the new spelling, plus a case for each + example above. +- Every pinned trace text containing a `1/` coherent spelling, in tests and guides, is updated. +- The whole-trace walker in `test/trace_shown_unit_tests.cpp` accepts the `^-n` and `^(-n/d)` forms. +- A new walker case: a value with an inverse coherent unit, in the fraction style. Its line contains + `20000/413 kg^-1`, or the equivalent for the case's data. + +### 2.4 #16: a precision limit's first pass borrows its unit + +**Problem.** Pass 1 of a precision limit restates the level's value, but its unit is chosen statically +(`precision.hpp:1015`): the limit's first placeholder's quantity, else the level expression's quantity, else the +coherent unit. A `ConstantNode` names no quantity, so a constant level declared in grams reads `40 g`, then +`1/25 kg` on the pass-1 line. + +**Design.** Where the trace records the pass-1 step, its unit becomes +`detail::restated_unit_or(steps, operands, dimension, value, levelUnit)`, as pass 2's already is +(`trace.hpp:3441`). The static `levelUnit` stays the fallback. The safety rule is the one every restating step +uses: `restated_unit_or` borrows only a unit with a symbol, of the same dimension, for exactly the same value, and +passing `borrowable_for_a_point`. + +**Tests.** In `test/trace_shown_unit_tests.cpp`, a precision limit whose level is `constant(…)`. Its +pass-1 line reads in grams, and the case is added to the whole-trace walker. + +### 2.5 #18: the snap's on-a-value test, and an unused bound + +**Problem.** A snap decides "on a permitted value" by comparing raw numerator/denominator pairs +(`trace_render.hpp:2164`), while table rows compare bounds by value (`same_declared_bound`). + +**Finding.** The raw compare is exact, so no change of behaviour is needed: +- An exact hit records one row twice: `Segment { Permitted[i], Permitted[i] }` from a single index + (`snap.hpp:134`). +- The permitted set passes `RequireValidBreakpointTable`, which makes it strictly ascending by value, so two + different rows never hold the same value. + +**Design.** +- A comment at the comparison states the two facts above, so the difference from table rows reads as intended. +- `lookup_miss_text` spells its low bound only on the path that writes it. + +**Tests.** None new: the behaviour does not change. Existing snap and lookup-miss tests must stay green. + +## 3. Lane B: numerics + +### 3.1 #20: the logarithm and exponential kernel takes 128-bit arguments + +**Problem.** `Rational` holds 128-bit integers, but the kernel behind `rounded_ln`, `rounded_log10` and +`rounded_exp` (`detail/transcendental.hpp`) narrows its argument's numerator and denominator to 64 bits +(`narrow_to_int64`, `:220-225`, `:297-302`) and refuses wider ones with `Overflow`. `rounded_exp` refuses arguments +above 44, a bound derived when `Rational` was 64-bit. The kernel's fixed point is already 384 bits +(`WideUnsigned<12>`, 128 fraction bits). Only its inputs, and the 64-bit long division `scaled_quotient`, are +narrow. + +**Design.** + +- **Inputs.** + - `natural_log_magnitude` and `exponential_enclosure` take the argument's magnitudes through `wide_magnitude` + (`UInt128`), not `narrow_to_int64`. + - A reduced `Rational`'s numerator and denominator have magnitudes below 2^127. + - Every reduction step is restated for magnitudes below 2^127, where it was for magnitudes below 2^63. +- **`scaled_quotient`** becomes a bitwise long division of `UInt128` operands. It produces 128 quotient bits, and + carries the bit the remainder's doubling shifts out. The general `divmod` stays out of the kernel, for the cost + reason in the file comment. +- **ln(a/b)**, a > b: + - B = b·2^k with B ≤ a < 2B, so k ≤ 126 and a + B < 2^128. + - z = (a − B)/(a + B) < 1/3 as before, so the atanh series, `AtanhSlack` (64) and `AtanhTermLimit` (42) are + unchanged. + - The ends lie at most k + 128 ≤ 254 units of 2^-128 apart: under 2^-120, absolute. +- **log10:** + - |ln(a/b)| < ln 2^127 < 89. + - The ends lie under 254 · 0.44 + 89 + 2 < 203 units apart. + - The widest product, upper_ln · (M + 1), is below 2^264. +- **exp(x):** + - The rounded form refuses x > 887/10 with `Overflow`. 887/10 is below 128 ln 2 ≈ 88.72, which bounds the + reduction's k by 127, and it replaces the literal 44 (`rounded_transcendental.hpp:79`). + - Whether an answer below that cap fits is decided by the rounding's existing narrowing into `Int128` + (`round_wide_ratio`), so every answer that fits at the requested places is given: + - e^88 at 0 places fits; + - e^88.5 does not fit at any allowed number of places and refuses with `Overflow`. + - The −43 lower end stays: below it the value rounds to 0 at every allowed number of places. + - X = floor(|x| 2^128) comes from the widened `scaled_quotient`. + - The widest numerator becomes (E + 512) · 2^127 < 2^257. `decide_rounding` scales it by up to 10^18, keeping it + under 2^317. So `KernelLimbs` stays 12, and the comment on it states the new widths. +- **The file comment's derivation** is rewritten for these bounds. Every number in it is either derived there or + pinned by a test. +- **Cost.** + - The constant-evaluation steps are re-measured on cl, and the "What it costs" figures are replaced with the new + measurements. + - The existing compile-time check (`test/transcendental_tests.cpp:316-324`, `log10 2` to 3 places) must still + compile on all four compilers under their default budgets. + - If it does not, the kernel is made cheaper. Raising the budget is not an option: a consumer cannot be asked to + do that. +- **Native and portable paths.** `UInt128` uses `__int128` on GCC and Clang and portable code on cl and clang-cl. + The kernel's results are the same on both paths: the new tests run on all four compilers. + +**Tests** (`test/rounded_transcendental_tests.cpp`, `test/transcendental_tests.cpp`): +- The pinned refusals become pinned answers: `ln 2^70`, `log10 2^70` and `exp 2^-64`. +- New points: + - ln and log10 of 2^127 − 1, and of 1/(2^127 − 1); + - ln of a ratio near 1 of two integers near 2^126; + - exp 45 at 18 places; + - exp 88 at 0 places; + - exp 88.5 and exp 89, both refused with `Overflow`. +- The −43 behaviour stays pinned. +- Expected values are computed independently with Python's `decimal` module at 60 digits. A comment at the tests + says so and gives the expression used. + +**Docs.** +- `docs/expressions.md:510-519` loses the 64-bit statement and the 44 bound, and gains the 887/10 cap and its + reason. +- `rounded_transcendental.hpp`'s comments (`:46`, `:50-51`, `:125-127`, `:136-138`, `:147-151`) are updated the + same way. +- `docs/numeric-headroom.md` loses the kernel's entry under "What this does not decide" (`:424-431`). +- CHANGELOG: an entry for the widened arguments. + +### 3.2 #19: the guides' bit widths are pinned by tests + +**Problem.** Two guides quote bit widths that nothing measures: +- the least-squares fit's widest intermediates, `docs/numeric-headroom.md:336-338`; +- the opaque example's coefficient widths, `docs/opaque-and-retry.md:327-328`. + +The headroom figure already names the wrong width: it says "256-bit", but a line's fit computes in +`regression_limbs(1)` = 12 limbs, 384 bits (`detail/least_squares_kernel.hpp:87-94`). + +**Design: the fit kernel's widest intermediate.** + +- The census hook (`FORMULA_CENSUS_NOTE`, `detail/checked_int.hpp`) gains a width form, + `census_record_width(CensusRole, std::size_t bits)`. +- The fit kernel calls it with `bit_length()` of every wide sum, centred sum and solve product it forms. +- The hook keeps its existing guarantee: outside the census program it expands to nothing, and its arguments are + never evaluated. +- The census program (`support/census_tally.cpp` and the generator of the headroom page's tables) records the + widest value per fixture. +- The least-squares table gains a generated column, "widest fit intermediate (of N bits)", for the rows the exact + kernel computes. N is generated from `regression_limbs(1) · 32`, never written by hand. +- The hand-written "68 bits … up to 249 of the 256" sentence and the "256-bit" wording go. The prose names the + width through the generated table. +- `docs.numeric-headroom` already fails when the generated tables drift, so the column is pinned by that test. + +**Design: the opaque example's coefficients.** + +- A test fits the example's data with the library's exact kernel and pins the bit widths of the numerator and + denominator of the slope, the intercept and R². The example's data is fifty readings at eight decimals, the + data `docs/opaque-and-retry.md` describes. If an example program already holds the data, the test includes it + from there. Otherwise the data lives in the test, and the guide points at the test. +- The guide quotes the test's figures and names the test. +- If the test's figures differ from the Python-computed figures now in the guide (65 and 73, 93 and 91, 130 and + 130), the test's figures are the ones published. +- The example's refusal with `Overflow` stays pinned as it is. + +## 4. Final tasks + +### 4.1 #11: `explain` and `checked_explain` refuse a series in the library's words + +**Problem.** `explain(series, environment)` and `checked_explain(series, environment)` fail with "no +matching function". `trace.hpp` has only a `Node` overload and a `Yields` overload of each, so overload resolution +fails before any library check runs. + +**Design.** + +- A new check beside `RequireSingleValueExpression` (`evaluate.hpp:191`): `detail::RequireSingleValueTraced + `. + - It has the same `refused_already` exemption, so a series refused where it was written is not refused twice. + - Its message: `formula: this expression is a series, not a single value; explain it with explain_series, or + reduce it to one value first (sum, interpolate_at)`. +- A `SeriesNode Expression` overload of `explain` and of `checked_explain` in `trace.hpp`. Each asserts the check, + then returns an empty value of the verb's declared return type, so that only the one message appears. This is the + shape of `evaluate`'s `SeriesNode` overloads (`evaluate.hpp:514`, `:532`). +- `trace_of`, and the `Yields` forms through `detail::SingleValueBoundCheck`, keep refusing a series exactly as + they do now. + +**Tests.** +- Two negative tests, `test/negative/explain_series_refused.cpp` and `checked_explain_series_refused.cpp`, + registered with `formula_add_negative_test`. Their expected text includes `explain_series`, and they pass on + MSVC and clang-cl. +- Before they are committed, removing the new overload is checked to bring back the generic "no matching function" + diagnostic. + +**Docs.** One sentence each in `docs/tracing.md` and `docs/series.md`, where `explain` and `explain_series` are +described. + +### 4.2 #21: two documentation corrections + +- `README.md:247-252` links the tracing guide twice in one paragraph. The link in the middle of the paragraph goes, + and the closing "See [the tracing guide](docs/tracing.md)." stays. +- `docs/superpowers/specs/2026-10-03-int128-rational-design.md:87` says the checked forms use the compiler's + overflow builtins. The sentence is corrected to describe the code: + - For `Int128`, add and subtract compute on the two words' bit patterns with the portable routines on every + compiler, and detect overflow from the signs. + - Only multiply uses the native checked builtin, through `u128_mul_checked`. + - The reason: calling `Int128`'s `+` or `-` first would break their precondition that the exact result fits. + +### 4.3 #13: no internal development labels + +**Problem.** More than 80 comments, test names, one guide sentence and one CMake comment refer to the project's +development history ("phase 12", "phase 15's spike, step 9", "spec phase 8"). A reader cannot look these up. + +**Design.** Every hit of the issue's search outside `docs/superpowers/` is rewritten: + +- A comment that quotes a measurement keeps the measurement and says where it holds, by compiler and version, or + points to the test that pins it. For example: "measured on cl 19.51 and g++ 14: the five-parameter spelling + compiles". +- A reference that adds nothing is removed. +- Section banners name the feature, not the work that added it. +- The two census test cases are renamed after what they cover: `census: phase 13's fixtures` is named after the + features those fixtures exercise, and `census: phase 12's cumulative sums and interpolation` becomes `census: cumulative sums and interpolation`. + No build file refers to their names. +- `docs/quantities.md:186` and `cmake/CheckInstalledHeaders.cmake:7` are rewritten the same way. + +**Acceptance.** This search returns nothing: + +```text +git grep -nE '\b[Pp]hase [0-9]+|\bspike\b|\bstep [0-9]+\)' -- include test examples tools docs/*.md README.md cmake CMakeLists.txt +``` + +## 5. Verification + +- **Per task:** + - `cl-debug`, plus the negative tests on `clangcl-debug`. + - The #20 task also builds and tests `gcc-release` and `clang-debug` under WSL, since `UInt128` takes its native + path only there. + - Each task is reviewed, and fixed until no finding is open. +- **After the lanes merge:** the census page is regenerated once, and `docs.numeric-headroom` passes. +- **Finish:** + - a whole-branch review, its fixes, and a re-review; + - all eight presets green locally; + - Doxygen, and `mkdocs build --strict`; + - CI green on the pushed head. + - Then the pull request, whose body closes the ten issues, is marked ready for review. +- **Docs and CHANGELOG:** + - Each task updates the guides whose text it changes: the trace samples for §2.2-§2.4, the expressions guide + and the headroom page for §3.1, the opaque guide for §3.2, the tracing and series guides for §4.1. + - Each task adds its CHANGELOG entry under the unreleased section. §2.1's entry is marked as a breaking change. + +## 6. Out of scope + +- New features. +- Any change to how `trace_of` or the `Yields` forms refuse a series. +- `docs/superpowers/`, except the one sentence §4.2 corrects. Those documents record how earlier work was planned. From c738f39cfe6ce99721512319feb724a3773fd1f5 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Sun, 4 Oct 2026 23:08:28 +0200 Subject: [PATCH 02/35] docs: plan the fixes for the ten open issues Ten tasks: four on trace text, two on numerics, then a merge, the series refusal with two documentation corrections, the replacement of development labels, and the finish. Planning corrected the design in two places. The exponential computes with 192 fraction bits, because at 128 a result near 2^127 could never be rounded with certainty and every such answer would be Overflow. The least-squares rows of the headroom page come from the 256-bit exact fit, so the "256-bit" figure there was right, and the new width column is generated from that fit's own constant. Signed-off-by: Christian Parpart --- .../plans/2026-10-04-open-issues.md | 3336 +++++++++++++++++ .../specs/2026-10-04-open-issues-design.md | 31 +- 2 files changed, 3357 insertions(+), 10 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-04-open-issues.md diff --git a/docs/superpowers/plans/2026-10-04-open-issues.md b/docs/superpowers/plans/2026-10-04-open-issues.md new file mode 100644 index 0000000..52c61e9 --- /dev/null +++ b/docs/superpowers/plans/2026-10-04-open-issues.md @@ -0,0 +1,3336 @@ +# Open Issues Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close every issue open on 2026-10-04 (#11, #13, #14, #15, #16, #17, #18, #19, #20, #21) in one pull request. + +**Architecture:** Three groups of independent changes. + +- **Trace text (Lane A, Tasks 1–4).** + - A scaled dimensionless unit without a symbol is refused where it is declared. + - An inverse-only coherent unit is spelled with negative exponents (`kg^-1`). + - A rounding clause names an unnamed unit by its size (`to 2 dp of 1/1000 kg`). + - A precision limit's first pass borrows the unit of the step it restates. + - The snap's on-a-value comparison is explained in a comment. +- **Numerics (Lane B, Tasks 5–6).** + - The logarithm and exponential kernel takes 128-bit arguments. + - The bit widths two guides quote are pinned: by a census hook in the fit kernel, and by a test of the opaque example's coefficients. +- **Final tasks (Tasks 7–10).** Merge Lane B, then: + - `explain` and `checked_explain` refuse a series in the library's words; + - two documentation corrections; + - every internal development label is replaced by self-describing text; + - the finish. + +**Tech Stack:** C++23, header-only; Catch2 3.6 (`STATIC_REQUIRE`), the `test/negative/` harness, the CTest docs and census checks, Python 3 (the census generator; `decimal` and `fractions` for independent expected values). + +**Spec:** `docs/superpowers/specs/2026-10-04-open-issues-design.md`. Read it before any task. Each task names the spec section it implements. + +**Order and lanes:** + +- **Lane A, trace text:** + - Tasks 1 → 2 → 3 → 4, in `D:\formula-cpp` on branch `feature/open-issues`. + - Task 3 uses Task 2's spelling. +- **Lane B, numerics:** + - Tasks 5 → 6, each in its own agent-owned worktree. + - Their commits go on the branch `feature/open-issues-numerics`. The controller creates it at the commit that adds this plan, and after each Lane B task moves it to that task's head (`git branch -f`). + - Each Lane B task begins with `git reset --hard feature/open-issues-numerics` in its fresh worktree. +- **Shared files.** The lanes run at the same time. The only files they share are `CHANGELOG.md` and possibly a guide's trace sample. +- **After both lanes:** + - Task 7 merges Lane B into `feature/open-issues`. + - Then Tasks 8 → 9 → 10, in `D:\formula-cpp`. + - Task 9 (#13) comes after the merge on purpose, so that it also catches any label either lane added. + +**Line anchors** were read at `9178f3b`. Find each place by the name quoted beside its anchor, never by the number alone. + +--- + +## Global Constraints + +These bind every task. + +- **C++23, header-only.** Nothing beyond the standard library in `include/`. +- **Worktrees:** + - Lane A and Tasks 7–10 work in `D:\formula-cpp`. + - A Lane B task works only in its own agent-owned worktree. + - No task touches another lane's tree. +- **Gates run from PowerShell.** Bash's pipe mis-encodes `°` and fails `docs.*-output`. + - `$S = C:\Users\c.parpart\AppData\Local\Temp\claude\D--formula-cpp\81d1061b-b25f-4c83-9f67-664a67264017\scratchpad` + - `$T` = the task's tree. +- **Verify, the per-task gate.** Every command must print `ALL OK` / `MATRIX OK`: + 1. `pwsh -NoProfile -File $S\cl.ps1 -Tree $T -Exclude "^negative\."`: the full cl-debug build, then every test except the negative ones. + 2. `pwsh -NoProfile -File $S\neg.ps1 -Tree $T -Filter ""`, only for a task that adds or touches negative tests. It runs the negatives matching `` on cl-debug **and** clangcl-debug. + 3. Task 5 only, because `UInt128` computes natively only on GCC and Clang: `wsl bash /mnt/c/Users/c.parpart/AppData/Local/Temp/claude/D--formula-cpp/81d1061b-b25f-4c83-9f67-664a67264017/scratchpad/posix-matrix.sh --tree --presets "gcc-release clang-debug"`. +- **Quick loop:** `pwsh -NoProfile -File $S\cl.ps1 -Tree $T -Filter ""`. +- **Run builds and tests in the foreground.** + - Never wait on a background monitor of your own. + - Never redirect a build to `/dev/null`: a `STATIC_REQUIRE` failure is a build error. + - Prove from ctest's count that a filter selected something before trusting it. + - Catch2 splits test filters on commas. +- **Baseline at `9178f3b`:** cl-debug passes 1569 of 1569 non-negative tests; the negatives pass 561 of 561 on cl-debug and clangcl-debug. Each task reports its total and the difference from the previous task in its lane, which must equal its stated delta. +- **Invariants (CONTRIBUTING.md), each enforced by a `hygiene.*` test:** + - an SPDX header on every file, and no `NOLINT`; + - core public headers include no ``, ``, ``, `` or `` (`hygiene.headers`); + - every public `static_assert` message begins `formula: `; + - a new public header goes into the install `FILE_SET` (`CMakeLists.txt`, `hygiene.installed-headers`) and into `test/consumer_globals_tests.cpp`'s includes (`hygiene.consumer-globals`). +- **Consumer globals.** `test/consumer_globals_tests.cpp` declares a list of `int` globals. No new parameter or local may reuse one: cl C4459 and g++ `-Wshadow` turn a reuse into a consumer's build error. Names on the list that this plan is tempted by: + - `a`–`z`; + - `value`, `values`, `result`, `sum`, `count`, `digits`, `numerator`, `denominator`, `quotient`, `sign`, `width`, `scale`, `scaled`, `factor`; + - `low`, `high`, `lower`, `upper`, `limit`, `pattern`, `root`, `step`, `text`, `unit`, `kind`, `first`, `last`, `left`, `right`, `operand`, `number`, `total`, `range`, `rest`. + + Use descriptive names (`divisorWord`, `remainderSoFar`, `levelStep`). Member names are not affected. +- **Behaviour rule.** + - Every computation that answers today gives the same answer afterwards. + - The only refusals added are the ones the spec names: a scaled dimensionless unit without a symbol, and `explain` of a series. The latter is now refused in the library's words instead of by overload resolution. + - Some `Overflow` refusals of the transcendental functions now answer. A refusal is never turned into a different number. +- **Display rule.** No number is shown in a unit other than the one written after it, and no rounding clause leaves its places' unit unnamed. +- **No internal labels in public text.** + - Code, docs, commit messages and the PR never name tasks, lanes, plans, phases, spikes or reviewers, and never cite local progress notes. + - Every sentence must make sense to a reader who never saw this plan. + - Issue numbers may appear only in the PR body. +- **No third-party standard content.** Cite only `Example Standard N:YYYY`. Fixture values are plainly invented. +- **Style.** + - Do not run clang-format on existing files; match the surrounding style by hand. + - Every new public entity and member gets a Doxygen `///` comment. +- **Printing** is `std::print` / `std::println` only. Never `printf`, `puts` or iostream. +- **Error handling.** Check every `std::expected` result before use. Never unwrap unchecked, and never switch to a throwing form to shorten code. +- **Newest GCC only** (g++-14). No workaround for an older compiler. +- **Commits.** Conventional style (`feat(trace): …`, `fix: …`, `test: …`, `docs: …`). Every message ends with: + + ``` + Signed-off-by: Christian Parpart + ``` + + Commit with `git commit -F `; a here-string passed to `-F -` does not work in PowerShell. +- **CHANGELOG.md:** + - Entries go under `## [Unreleased]` (create it at the top if absent, as `cmake/CheckChangelog.cmake` requires), in `### Added` / `### Changed` / `### Fixed` subsections. + - A breaking change says so in its first words. + +## Review Focus + +The five inputs this spec implies but no obvious test reaches, most likely to bite first. Each one's test is in the task named. + +1. **A dimensionless unit written at scale 1 in an unreduced form**, such as magnitude 7/7, with no symbol. It is at scale 1, so it must still compile. The refusal compares reduced fractions, never raw pairs. Task 1. +2. **An inverse-only unit with a fractional exponent or a named base:** + - `kg^(-1/2)` and `JPY^-1`; + - the mixed `EUR/JPY` and `m/s`, unchanged. + The walker must accept every form the spelling can produce. Task 2. +3. **An unnamed unit whose magnitude is a whole number or exactly 1:** + - a 1000 kg unit with no symbol rounds `to 2 dp of 1000 kg`; + - a unit of magnitude 1 with no symbol, dimensioned, rounds `to 2 dp of 1 kg`. It is never bare, and never `of kg`. + Task 3. +4. **A precision level that cannot borrow:** + - a level computed by an expression whose last step holds a different value; + - a constant level in an offset unit (°C). + The first stays in the fallback unit. The second follows `borrowable_for_a_point`, and must not read as a difference. Task 4. +5. **The kernel at its new edges:** + - exp of exactly 887/10. It passes the cap, and e^88.7 does not fit `Int128`, so the rounding's narrowing refuses it with `Overflow`; + - exp of a negative argument with a 127-bit denominator, which rounds to 1; + - ln of a 127-bit numerator over a 127-bit denominator with a ratio below 1. + The native (g++, clang++) and portable (cl, clang-cl) paths give the same bits. Task 5. + +## Execution + +- **Briefs and reports:** + - The controller writes each task's brief to `D:\formula-cpp\.superpowers\sdd\2026-10-04-open-issues\task-N-brief.md` (`.superpowers/` is git-ignored). + - The implementer writes its report beside it, as `task-N-report.md`. + - A Lane B implementer that cannot write outside its worktree writes the report to `.superpowers/task-N-report.md` inside its worktree and says so in its reply. + - A report states: + - the commits; + - the test total and its delta; + - each rule in *Global Constraints* the task touched, and how it was kept; + - anything it could not do. +- **Agents.** Every task is implemented by `sdd-implementer` and reviewed by `sdd-reviewer`. A fix round goes back to `sdd-implementer`. Task 7's merge is run by the controller; an `sdd-implementer` resolves any conflict that is not mechanical. +- **Negative tests**, where a task adds one: + 1. Add `test/negative/.cpp`, plus `formula_add_negative_test( "" …)` in `test/CMakeLists.txt`. + 2. Register it first with a deliberately wrong expected text, and watch it fail. + 3. Register the right text, and watch it pass. + 4. Delete the guard it pins, confirm the case compiles, then restore the guard with a plain write. + +--- +## Lane A — trace text (#14, #17, #15, #16, #18) + +Lane A works in `D:\formula-cpp` on branch `feature/open-issues`, Tasks 1 → 2 → 3 → 4, in that order. Task 3 uses +the spelling Task 2 writes; Tasks 1 and 4 are independent of the other two but run in the same tree, so the lane is +one sequence. + +Line anchors were read at `9178f3b`. Find each place by the name quoted beside its anchor, never by the number +alone: every task in this lane inserts lines above the anchors of the next. + +Throughout the lane, `$T = D:\formula-cpp`. + +--- + +### Task 1: A scaled dimensionless unit must have a symbol (#14) + +A dimensionless unit whose magnitude is not 1, or whose offset is not 0, and that has no symbol, cannot be written +truthfully anywhere: a trace shows one half in hundredths as `50`. It is refused wherever a `Unit` enters the +library. Spec §2.1. + +The refusal is one class template, `detail::RequireNamedScaledScalar`, beside `RequireSameUnitDimension` in +`unit.hpp`. Every class template that holds a unit as a template argument asserts it in its body, next to the checks +it already makes. The new check is a property of the unit alone, so it is never gated behind another check: it +cannot repeat another check's message for the same mistake. + +A quantity's unit is asserted where the four existing `DescribesConsistentDimension` checks already sit: +`RequireDescribed` (which `Measured` asserts), `VarNode`, `ObservationsVarNode` and `SeriesVarNode`. A program that +both measures and reads such a quantity is told once per site, exactly as it is told today about a quantity whose +declared dimension disagrees with its unit. The negative test for the quantity case uses `var` alone, so it sees +one message. + +**Files:** +- Modify: `include/formula-cpp/unit.hpp`: a new `namespace detail` block after `RequireSameUnitDimension` + (`:530-548`), holding `unnamed_scaled_scalar` and `RequireNamedScaledScalar`. +- Modify, one `static_assert` line each, in the class body beside the existing asserts: + - `include/formula-cpp/quantity.hpp`: `RequireDescribed` (`:233-256`), gated on `Described` through a new + `detail::RequireDescribedUnitNamesItsScale`; + - `include/formula-cpp/expression.hpp`: `VarNode` (`:46-58`), `ConstantNode` (`:75-84`); + - `include/formula-cpp/observations.hpp`: `ObservationsVarNode` (`:50-60`); + - `include/formula-cpp/series.hpp`: `SeriesVarNode` (`:60-72`), `SeriesConstantNode` (`:175-180`), + `ElementwiseRoundNode` (`:481-495`); + - `include/formula-cpp/rounding_node.hpp`: `RoundNode` (`:48-51`), `RoundSignificantNode` (`:74-77`); + - `include/formula-cpp/rounded_root.hpp`: `RoundedRootNode` (`:279-284`); + - `include/formula-cpp/opaque.hpp`: `RoundedOpaqueOutputNode` (`:1022-1027`); + - `include/formula-cpp/lookup.hpp`: `BandedLookupNode` (`:765-770`, key and result), `ExactLookupNode` + (`:1171-1176`, result), `InterpolatingLookupNode` (`:1805-1810`, key and result); + - `include/formula-cpp/snap.hpp`: `SnapNode` (`:160-176`, key); + - `include/formula-cpp/curve.hpp`: `DomainNode` (`:106-111`); + - `include/formula-cpp/binning.hpp`: `BinnedNode` (`:129-144`, key); + - `include/formula-cpp/critical_value.hpp`: `SampleSizeLookupNode` (`:376-383`, result); + - `include/formula-cpp/escape.hpp`: `NumericValueNode` (`:82-91`); + - `include/formula-cpp/conformity.hpp`: `Conformity` (`:343-350`); + - `include/formula-cpp/method.hpp`: `RoundingRule` (`:1081-1083`); + - `include/formula-cpp/overlay.hpp`: `RoundingOverride` (`:696-698`). Its `using rule = RoundingRule<…>` names + the rule without instantiating it, so it asserts on its own. +- Modify: `include/formula-cpp/trace_render.hpp`: the comments of `spells_coherent_unit` (`:651-665`) and + `shown_unit_text` (`:675-682`). +- Create: `test/negative/scaled_scalar_unit_without_symbol_var.cpp`, + `test/negative/scaled_scalar_unit_without_symbol_constant.cpp`, + `test/negative/scaled_scalar_unit_without_symbol_snap.cpp`. +- Modify: `test/CMakeLists.txt`: three `formula_add_negative_test` lines after `unit_currency_mismatch` (`:338-339`). +- Modify: `test/unit_tests.cpp`: one new `TEST_CASE` at the end of the file. +- Modify: `docs/dimensions.md` (the `symbolText` row of the field table, `:133`, and the paragraph after it), + `docs/tracing.md` (`:377-380`), `CHANGELOG.md`. + +**Interfaces:** +- Consumes: `Unit` (`unit.hpp:67`), `dim::Scalar`, `view(Symbol const&) -> std::string_view` (`dimension.hpp:256`), + `Described`, `Describe::unit`. +- Produces: + - `formula::detail::unnamed_scaled_scalar(Unit const&) noexcept -> bool` (constexpr): true for a dimensionless + unit with no symbol whose magnitude is not 1 or whose offset is not 0. + - `formula::detail::RequireNamedScaledScalar`: `::value` is `true`; completing it with such a unit + fails with the message below. + - The message, which later tasks and the CHANGELOG quote: `formula: a dimensionless unit with a scale must have a + symbol (for example "%"), or the quantity must be declared in scale 1; the unit appears in this diagnostic as the + template argument of RequireNamedScaledScalar`. + - After this task, a dimensionless unit with no symbol that reaches a trace or a `render()` has scale 1. Task 3 + relies on it. + +- [ ] **Step 1: Write the failing positive test.** Append to `test/unit_tests.cpp` (its namespace aliases are + `formula::Unit` as `Unit`, `formula::dim` as `dim`, and `formula::unit` as `unit` — check the file's top and keep its + spelling): + +```cpp +TEST_CASE("a dimensionless unit with a scale and no symbol is the one a declaration refuses", "[unit]") +{ + // Hundredths with no symbol: one half would read 50, in a scale nothing names. + constexpr Unit unlabelledHundredth { .dimension = dim::Scalar, .magnitudeNumerator = 1, .magnitudeDenominator = 100 }; + // The same scale with an offset only. + constexpr Unit unlabelledShifted { .dimension = dim::Scalar, .offsetNumerator = 1, .offsetDenominator = 2 }; + // Scale 1 written as 7/7: still scale 1. + constexpr Unit unlabelledSevenSevenths { .dimension = dim::Scalar, .magnitudeNumerator = 7, .magnitudeDenominator = 7 }; + // A dimensioned unit with no symbol is shown in the coherent unit instead, and is not refused. + constexpr Unit unlabelledGram { .dimension = dim::Mass, .magnitudeNumerator = 1, .magnitudeDenominator = 1000 }; + + STATIC_REQUIRE(formula::detail::unnamed_scaled_scalar(unlabelledHundredth)); + STATIC_REQUIRE(formula::detail::unnamed_scaled_scalar(unlabelledShifted)); + STATIC_REQUIRE(!formula::detail::unnamed_scaled_scalar(unlabelledSevenSevenths)); + STATIC_REQUIRE(!formula::detail::unnamed_scaled_scalar(unlabelledGram)); + STATIC_REQUIRE(!formula::detail::unnamed_scaled_scalar(unit::One)); + // Every shipped scaled dimensionless unit has a symbol. + STATIC_REQUIRE(!formula::detail::unnamed_scaled_scalar(unit::Percent)); + STATIC_REQUIRE(!formula::detail::unnamed_scaled_scalar(unit::PerMille)); + STATIC_REQUIRE(!formula::detail::unnamed_scaled_scalar(unit::PartsPerMillion)); + STATIC_REQUIRE(!formula::detail::unnamed_scaled_scalar(unit::MilligramPerKilogram)); + STATIC_REQUIRE(formula::detail::RequireNamedScaledScalar::value); + STATIC_REQUIRE(formula::detail::RequireNamedScaledScalar::value); +} +``` + +- [ ] **Step 2: Run it to see it fail.** + Run: `pwsh -NoProfile -File $S\cl.ps1 -Tree $T -Filter "refuses"`. + Expected: BUILD FAILED, `unnamed_scaled_scalar` is not a member of `formula::detail`. A `STATIC_REQUIRE` failure is + a build error, so read the build output, not ctest's. + +- [ ] **Step 3: Add the predicate and the check.** In `include/formula-cpp/unit.hpp`, immediately after + `RequireSameUnitDimension`'s closing `};` (`:548`), insert: + +```cpp +namespace detail +{ + /// Whether @p candidate is a dimensionless unit with a scale and no symbol: a magnitude other than 1, or an + /// offset other than 0, compared as fractions, and an empty symbol. A number in such a unit is in a scale + /// nothing on its line can name -- one half in hundredths would read `50` -- and no spelling of the unit + /// itself can name it either, since a dimensionless coherent unit is written as nothing. A dimensioned unit + /// with no symbol is not one: its value is shown in the coherent unit, which its dimension spells. + [[nodiscard]] constexpr bool unnamed_scaled_scalar(Unit const& candidate) noexcept + { + return candidate.dimension == dim::Scalar && view(candidate.symbolText).empty() + && (candidate.magnitudeNumerator != candidate.magnitudeDenominator || candidate.offsetNumerator != 0); + } + + /// Fails to compile when @p U is a dimensionless unit with a scale and no symbol (`unnamed_scaled_scalar`). + /// Asserted in the class body of everything that holds a unit as a template argument -- a quantity's + /// description, a variable, a constant, a rounding, a key or a result of a table, a conformity check -- so + /// that such a unit is refused where it is written, never shown as a bare number in its scale. + /// + /// Same shape as `RequireSameUnitDimension` above, and the same caveat: it fires only when the type is + /// completed, so write `::value`. + template + struct RequireNamedScaledScalar + { + static_assert(!unnamed_scaled_scalar(U), + "formula: a dimensionless unit with a scale must have a symbol (for example \"%\"), or the " + "quantity must be declared in scale 1; the unit appears in this diagnostic as the template " + "argument of RequireNamedScaledScalar"); + + /// Always `true` once reached -- the `static_assert` above already failed compilation otherwise. + static constexpr bool value = true; + }; +} // namespace detail +``` + +- [ ] **Step 4: Run the positive test to see it pass.** + Run: `pwsh -NoProfile -File $S\cl.ps1 -Tree $T -Filter "refuses"`. + Expected: `ALL OK`, 1 test. + +- [ ] **Step 5: Write the three negative tests.** Create `test/negative/scaled_scalar_unit_without_symbol_var.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: a dimensionless unit with a scale must have a symbol +// +// A quantity declared in hundredths with no symbol: one half would be shown as +// 50, a number in a scale no line names. Refused where the quantity is read, +// once. +#include + +inline constexpr formula::Unit Hundredth { .dimension = formula::dim::Scalar, + .magnitudeNumerator = 1, + .magnitudeDenominator = 100 }; + +struct Fraction: formula::Quantity +{ +}; + +inline constexpr auto fraction = formula::var; + +int main() +{ + return fraction.dimension == formula::dim::Scalar ? 0 : 1; +} +``` + + Create `test/negative/scaled_scalar_unit_without_symbol_constant.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: a dimensionless unit with a scale must have a symbol +// +// A coefficient stated in hundredths with no symbol: refused where it is +// written, once. +#include + +inline constexpr formula::Unit Hundredth { .dimension = formula::dim::Scalar, + .magnitudeNumerator = 1, + .magnitudeDenominator = 100 }; + +inline constexpr auto half = formula::constant(formula::Rational { 50 }); + +int main() +{ + return half.number == formula::Rational { 50 } ? 0 : 1; +} +``` + + Create `test/negative/scaled_scalar_unit_without_symbol_snap.cpp`. The operand is in `unit::Percent`, which has a + symbol, so the snap's key is the only unit refused: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: a dimensionless unit with a scale must have a symbol +// REJECT: key unit does not measure +// REJECT: breakpoints do not strictly ascend +// +// A snap keyed in hundredths with no symbol: its permitted values would be +// written as numbers in a scale no line names. Refused where the snap is +// written, once, and by nothing else: the key measures what the operand does, +// and the permitted set ascends. +#include + +inline constexpr formula::Unit Hundredth { .dimension = formula::dim::Scalar, + .magnitudeNumerator = 1, + .magnitudeDenominator = 100 }; + +struct Share: formula::Quantity +{ +}; + +inline constexpr formula::BreakpointTable<2> permitted { formula::breakpoint(25), formula::breakpoint(50) }; + +inline constexpr auto snap = + formula::snapped(formula::var); + +int main() +{ + return snap.tie == formula::SnapTie::TowardLower ? 0 : 1; +} +``` + +- [ ] **Step 6: Register them with a wrong text and watch them fail.** In `test/CMakeLists.txt`, after the + `unit_currency_mismatch` registration (`:338-339`), add: + +```cmake +# A dimensionless unit with a scale and no symbol: refused where a quantity +# declared in it is read, where a constant is stated in it, and where a table +# is keyed in it -- once each. +formula_add_negative_test(scaled_scalar_unit_without_symbol_var + "formula: a dimensionless unit with a scale must be WRONG" EXPECT_COUNT 1) +formula_add_negative_test(scaled_scalar_unit_without_symbol_constant + "formula: a dimensionless unit with a scale must be WRONG" EXPECT_COUNT 1) +formula_add_negative_test(scaled_scalar_unit_without_symbol_snap + "formula: a dimensionless unit with a scale must be WRONG" EXPECT_COUNT 1 + REJECT "key unit does not measure" "breakpoints do not strictly ascend") +``` + + Run: `pwsh -NoProfile -File $S\neg.ps1 -Tree $T -Filter "scaled_scalar_unit_without_symbol"`. + Expected: all three FAIL on both presets (the text is not found; before Step 7 the cases may also compile). + +- [ ] **Step 7: Assert the check at every site.** Add the lines below, each directly after the last existing + `static_assert` of the class body named (or as the body's first line where it has none). Keep each line's + surroundings untouched. + + `quantity.hpp`, in `namespace detail` just above `RequireDescribed` (inside the existing `namespace formula`; open + a `namespace detail { … }` block of its own there): + +```cpp +namespace detail +{ + /// `RequireNamedScaledScalar` of a described type's unit, asked only once the type is described: an + /// undescribed type has no unit to ask about, and is already refused, in full, by `RequireDescribed`. + template > + struct RequireDescribedUnitNamesItsScale: std::true_type + { + }; + + template + struct RequireDescribedUnitNamesItsScale: RequireNamedScaledScalar::unit> + { + }; +} // namespace detail +``` + + and in `RequireDescribed`, after its second `static_assert` (the dimension one, `:244-248`): + +```cpp + static_assert(detail::RequireDescribedUnitNamesItsScale::value); +``` + + If `quantity.hpp` does not already include ``, add it to its standard includes. + + `expression.hpp`, `VarNode`, after its `DescribesConsistentDimension` assert: + +```cpp + static_assert(detail::RequireNamedScaledScalar::unit>::value); +``` + + `observations.hpp`, `ObservationsVarNode`, and `series.hpp`, `SeriesVarNode`, the same line after their + `DescribesConsistentDimension` asserts. + + `expression.hpp`, `ConstantNode` (first line of the body), `series.hpp` `SeriesConstantNode` (after its `N > 0` + assert), `series.hpp` `ElementwiseRoundNode`, `rounding_node.hpp` `RoundNode` and `RoundSignificantNode`, + `rounded_root.hpp` `RoundedRootNode`, `opaque.hpp` `RoundedOpaqueOutputNode`, `curve.hpp` `DomainNode`, + `escape.hpp` `NumericValueNode`, `conformity.hpp` `Conformity`, `method.hpp` `RoundingRule` (first line, before + `public:`), `overlay.hpp` `RoundingOverride` (first line): + +```cpp + static_assert(detail::RequireNamedScaledScalar::value); +``` + + `lookup.hpp` `BandedLookupNode` and `InterpolatingLookupNode`: + +```cpp + static_assert(detail::RequireNamedScaledScalar::value); + static_assert(detail::RequireNamedScaledScalar::value); +``` + + `lookup.hpp` `ExactLookupNode` and `critical_value.hpp` `SampleSizeLookupNode`: + +```cpp + static_assert(detail::RequireNamedScaledScalar::value); +``` + + `snap.hpp` `SnapNode` and `binning.hpp` `BinnedNode`: + +```cpp + static_assert(detail::RequireNamedScaledScalar::value); +``` + + In a header outside `namespace formula::detail`, write `detail::` as above; inside one, drop the prefix as the + neighbouring asserts do. Every one of these headers includes `unit.hpp` directly or through `expression.hpp`; if a + build reports `RequireNamedScaledScalar` undeclared, add `#include `. + +- [ ] **Step 8: Register the right text and watch the negatives pass.** Replace `must be WRONG` with + `must have a symbol` in the three registrations of Step 6. + Run: `pwsh -NoProfile -File $S\neg.ps1 -Tree $T -Filter "scaled_scalar_unit_without_symbol"`. + Expected: `ALL OK`, three tests on each preset; on clangcl-debug each found the text exactly once. + +- [ ] **Step 9: Prove each guard is what refuses.** One at a time, delete the `static_assert` line each case + depends on (`VarNode`'s, `ConstantNode`'s, `SnapNode`'s), confirm with the quick loop that the case then compiles + (`neg.ps1 … -Filter "scaled_scalar_unit_without_symbol_var"` reports it built), and restore the line with a plain + write. Record the three results in the report. + +- [ ] **Step 10: Update the comments in `trace_render.hpp`.** In `spells_coherent_unit`'s comment (`:651-661`), + after "…so that every number on a line is in the unit written after it.", add: + +```cpp + /// A dimensionless unit with no symbol is always at scale 1 here: + /// one with a scale is refused where it is written + /// (`RequireNamedScaledScalar`, `unit.hpp`), so its bare number is the + /// value. +``` + + In `shown_unit_text`'s comment (`:675-677`), change "or nothing for a dimensionless value in a unit with no symbol." + to "or nothing for a dimensionless value in a unit with no symbol, which is at scale 1." + +- [ ] **Step 11: Document the rule.** + - `docs/dimensions.md`, the field table's `symbolText` row (`:133`): change its purpose cell to + "a fixed-capacity display symbol (a `Symbol`, not a `std::string_view`); required for a dimensionless unit with a + scale". + - `docs/dimensions.md`, after the paragraph that ends "…via `formula::view()` and the conversion functions below." + (`:143`), add the paragraph: + + > A dimensionless unit with a scale or an offset must have a symbol. One half in hundredths with no symbol would + > be shown as `50`, a number in a scale nothing names, and no spelling of the unit could name it: the coherent + > dimensionless unit is written as nothing. Such a unit is refused wherever it is written -- as a quantity's + > unit, a constant's, a rounding's, or a table's key or result -- with `formula: a dimensionless unit with a + > scale must have a symbol`. A dimensioned unit may have no symbol: its values are shown in the coherent unit, + > which its dimension spells. + + - `docs/tracing.md:377-380`: after "…since its number alone could not say what scale it is on." add the sentence + "A dimensionless unit with a scale must have a symbol, so a bare number is always a value at scale 1." + +- [ ] **Step 12: Write the CHANGELOG entry.** Under `## [Unreleased]` → `### Changed`, append: + +```markdown +- **A dimensionless unit with a scale or an offset and no symbol is refused at compile time**, wherever it is + written: as a quantity's unit, a constant's, a rounding's, or a table's key or result. A trace showed a value in + such a unit as a bare number in a scale nothing named (one half in hundredths read `50`), and no spelling of the + unit could name it. This breaks code that declares one: give the unit a symbol (`%`, `ppm`, or the author's own), + or declare the quantity in scale 1. The refusal reads `formula: a dimensionless unit with a scale must have a + symbol`. A dimensioned unit with no symbol is still accepted, and shown in the coherent unit. +``` + +- [ ] **Step 13: Verify.** + 1. `pwsh -NoProfile -File $S\cl.ps1 -Tree $T -Exclude "^negative\."` → `ALL OK`. + 2. `pwsh -NoProfile -File $S\neg.ps1 -Tree $T -Filter "scaled_scalar_unit_without_symbol|rounding|snap|lookup|unit_|quantity|constant|conformity|escape|binning|curve|critical|rounded|overlay|method|series|observations"` + → `ALL OK`. The asserts added at Step 7 must not add a second message to any existing negative case: + several assert `EXPECT_COUNT`. + If `hygiene.documented-diagnostics` fails, a guide quotes a diagnostic at a header line that Step 7 moved: update + that guide's quoted line number to the line the header's `static_assert` is on now, and nothing else in the quote. + Expected count: **+1** non-negative test (1569 → 1570), **+3** + negative tests. + +- [ ] **Step 14: Commit.** Write the message to `$S\task-1-commit.txt`: + +``` +feat: refuse a dimensionless unit with a scale and no symbol + +A number in such a unit is in a scale nothing names: a trace showed one +half in hundredths as 50, and no spelling of the unit could say so, +since the coherent dimensionless unit is written as nothing. Every class +that holds a unit as a template argument now refuses one, beside the +checks it already makes: a quantity's description and the three nodes +that read a quantity, a constant, every rounding, every table's key and +result, a numeric value, a conformity check, a rounding rule and its +override. A dimensioned unit with no symbol is still accepted and shown +in the coherent unit. + +This breaks code that declares such a unit: give it a symbol, or declare +the quantity in scale 1. + +Signed-off-by: Christian Parpart +``` + + Run: `git -C $T add -A include test docs CHANGELOG.md` then `git -C $T commit -F $S\task-1-commit.txt`. + +--- + +### Task 2: Negative exponents for an inverse-only coherent unit (#17) + +A coherent unit with nothing above the slash is spelt `1/kg` today, so in the fraction style a line reads +`20000/413 1/kg`, which reads as one fraction divided again. It becomes `kg^-1`. Units with a numerator keep the +slash. Spec §2.3. + +**Files:** +- Modify: `include/formula-cpp/trace_render.hpp`: `coherent_unit_text` (`:595-650`), its body and its comment. +- Modify (re-pin): + - `test/opaque_tests.cpp:1052` (`"1/s"` → `"s^-1"`), `:1066` (`"1/JPY"` → `"JPY^-1"`), `:1070` + (`"1/(EUR s)"` → `"EUR^-1 s^-1"`), `:1204` (comment, `12700/103 1/m` → `12700/103 m^-1`), `:1212` + (`"4. quotient of #3 = 12700/103 1/m\n"` → `"4. quotient of #3 = 12700/103 m^-1\n"`); + - `test/trace_render_tests.cpp:2255`: `"3. #1 / #2 = 1500/137 1/kg; 500/71 1/kg; 1500/293 1/kg\n"` → + `"3. #1 / #2 = 1500/137 kg^-1; 500/71 kg^-1; 1500/293 kg^-1\n"`; + - `test/trace_shown_unit_tests.cpp:387` (comment, `the coherent unit, 1/kg.` → `the coherent unit, kg^-1.`), + `:391` (`"3. #1 / #2 = 20000/413 1/kg\n"` → `"3. #1 / #2 = 20000/413 kg^-1\n"`); + - `docs/dimensions.md:454` (`then \`1/JPY\`` → `then \`JPY^-1\``). + Found with `git grep -nE ' 1/[a-zA-Z(]|"1/[a-zA-Z(]|\`1/[A-Za-z(]' -- test examples docs/*.md README.md include` + at `9178f3b`. Re-run that search after Step 4; any hit that is a unit spelling (not a number such as + `1/(2^63 - 1)`) is re-pinned the same way. +- Modify: `test/opaque_tests.cpp`: one new `TEST_CASE` after `"a named base dimension is spelt by its name and ahead + of the SI units on its side"`. +- Modify: `test/trace_shown_unit_tests.cpp`: one line in `"every value a trace shows is in the unit written after + it"` (`:683-711`). +- Modify: `docs/tracing.md:371-375`, `CHANGELOG.md`. + +**Interfaces:** +- Consumes: `Dimension`, `Exponent { numerator, denominator }`, `detail::named_base_in_use` (`dimension.hpp:426`), + `escaped_author_text` (`trace_render.hpp:185`). +- Produces: `detail::coherent_unit_text(Dimension) -> std::string` with the new spelling for a dimension that has no + positive exponent. Its signature is unchanged. Task 3 moves its body into `render.hpp` and keeps this spelling. +- The whole-trace walker (`check_each_value_is_in_the_unit_written_after_it`, `trace_shown_unit_tests.cpp:273`) + compares a line's unit text with `coherent_unit_text(dimension)`, so it accepts `kg^-1` and `kg^(-1/2)` without a + change of its own. + +- [ ] **Step 1: Write the failing test.** In `test/opaque_tests.cpp`, after the test case `"a named base dimension + is spelt by its name and ahead of the SI units on its side"`, add: + +```cpp +TEST_CASE("an inverse-only coherent unit is spelt with negative exponents", "[opaque][trace]") +{ + // `1/kg` after a fraction reads as the fraction divided again: `20000/413 1/kg`. + CHECK(formula::detail::coherent_unit_text(formula::dim::Scalar / formula::dim::Mass) == "kg^-1"); + CHECK(formula::detail::coherent_unit_text(formula::dim::Frequency) == "s^-1"); + CHECK(formula::detail::coherent_unit_text(formula::power(formula::dim::Length, -3)) == "m^-3"); + CHECK(formula::detail::coherent_unit_text(formula::dim::Scalar / (formula::dim::Length * formula::dim::Time)) + == "m^-1 s^-1"); + CHECK(formula::detail::coherent_unit_text(formula::nth_root(formula::dim::Scalar / formula::dim::Mass, 2)) + == "kg^(-1/2)"); + constexpr formula::Dimension yen = formula::base_dimension("JPY"); + CHECK(formula::detail::coherent_unit_text(formula::power(yen, -1)) == "JPY^-1"); + // A unit with a numerator keeps its slash. + CHECK(formula::detail::coherent_unit_text(formula::dim::Velocity) == "m/s"); + CHECK(formula::detail::coherent_unit_text(formula::base_dimension("EUR") / yen) == "EUR/JPY"); +} +``` + + In `test/trace_shown_unit_tests.cpp`, in `"every value a trace shows is in the unit written after it"`, after the + `var * Rational { 2 } - var` line (`:710`), add: + +```cpp + // A pure number over a mass: kg^-1 after a fraction. + check_each_value_is_in_the_unit_written_after_it(recorded_trace(Rational { 2 } / var, inputs)); +``` + +- [ ] **Step 2: Run it to see it fail.** + Run: `pwsh -NoProfile -File $S\cl.ps1 -Tree $T -Filter "negative exponents"`. + Expected: 1 test, FAILED, `"1/kg" == "kg^-1"` among the expansions. + +- [ ] **Step 3: Rewrite `coherent_unit_text`.** Replace the function's body from `std::string above;` to the end + with: + +```cpp + std::string above; + std::string below; + std::string inverse; + std::size_t belowCount = 0; + auto const place = [&](std::string_view symbolText, Exponent baseExponent) { + if (baseExponent.numerator > 0) + above += (above.empty() ? "" : " ") + + unitPower(symbolText, baseExponent.numerator, baseExponent.denominator); + else if (baseExponent.numerator < 0) + { + below += (below.empty() ? "" : " ") + + unitPower(symbolText, -baseExponent.numerator, baseExponent.denominator); + inverse += (inverse.empty() ? "" : " ") + + unitPower(symbolText, baseExponent.numerator, baseExponent.denominator); + ++belowCount; + } + }; + for (std::size_t slot = 0; named_base_in_use(dimension, slot); ++slot) + place(escaped_author_text(view(dimension.namedBases[slot].name)), dimension.namedBases[slot].exponent); + for (BaseUnit const& base: bases) + place(base.symbol, base.exponent); + if (below.empty()) + return above; + if (above.empty()) + return inverse; + return above + "/" + (belowCount > 1 ? "(" + below + ")" : below); +``` + + `unitPower` is unchanged: given a negative numerator it already writes `^-1` for -1/1 and `^(-1/2)` for -1/2. + + In the function's comment, change "`EUR s^2/(m^2 kg)` for euros per joule, `1/JPY`, `EUR/JPY`, `EUR^(1/2)`." to + "`EUR s^2/(m^2 kg)` for euros per joule, `JPY^-1`, `EUR/JPY`, `EUR^(1/2)`.", and add a paragraph after the one that + ends "…not as seconds squared of money per metre.": + +```cpp + /// + /// A dimension with no positive exponent is written with negative + /// exponents and no slash: `kg^-1`, `m^-1 s^-1`, `kg^(-1/2)`. After a + /// number in the fraction style, `20000/413 1/kg` would read as one + /// fraction divided again; `20000/413 kg^-1` cannot. +``` + +- [ ] **Step 4: Re-pin the texts that change.** Make the edits listed under **Files: Modify (re-pin)**, then re-run + the search given there. + +- [ ] **Step 5: Run the tests to see them pass.** + Run: `pwsh -NoProfile -File $S\cl.ps1 -Tree $T -Filter "negative exponents|spelt|quotient never borrows|shown|trace_render"` + (adjust to ctest names; prove the count is non-zero). + Expected: `ALL OK`. + +- [ ] **Step 6: Document it.** + - `docs/tracing.md:371-375`: after "`kg/(m s^2)` for a pressure," insert "`s^-1` for a frequency -- a unit with + nothing above the slash is written with negative exponents, so that `20000/413 kg^-1` cannot read as a fraction + divided again --". + - `CHANGELOG.md`, under `## [Unreleased]` → `### Changed`, append: + +```markdown +- A coherent unit with no positive exponent is spelt with negative exponents in a trace: `kg^-1`, `s^-1`, + `m^-1 s^-1`, `JPY^-1`, where it was `1/kg`, `1/s`, `1/(m s)`, `1/JPY`. After a number in the fraction style, + `20000/413 1/kg` read as a fraction divided again. A unit with a numerator keeps its slash: `m/s`, `EUR/JPY`. + A trace text pinned in a test changes where it showed such a unit. +``` + +- [ ] **Step 7: Verify.** `pwsh -NoProfile -File $S\cl.ps1 -Tree $T -Exclude "^negative\."` → `ALL OK`. + `docs.*-output` and `docs.gallery` are in that run: if one fails on a `1/` unit spelling, the guide or the + regenerated gallery is re-pinned as in Step 4, and the report names it. + Expected count: **+1** non-negative test, **0** negative. + +- [ ] **Step 8: Commit.** Message in `$S\task-2-commit.txt`: + +``` +fix(trace): spell an inverse-only coherent unit with negative exponents + +A coherent unit with nothing above the slash was written 1/kg, so after +a fraction a line read 20000/413 1/kg: one fraction divided again, at a +glance. It is now kg^-1, m^-1 s^-1, JPY^-1, kg^(-1/2). A unit with a +numerator keeps its slash: m/s, EUR s^2/(m^2 kg). + +Signed-off-by: Christian Parpart +``` + + Run: `git -C $T add -A include test docs CHANGELOG.md` then `git -C $T commit -F $S\task-2-commit.txt`. + +--- + +### Task 3: A rounding clause, a constant and a numeric value name an unnamed unit by its size (#15) + +`round(#1, to 2 dp)` writes no unit clause when the rounding's unit has no symbol, while the value after it reads in +the coherent unit: "2 dp" then reads as places of a kilogram. The clause now names such a unit by its size in the +coherent unit: `round(#1, to 2 dp of 1/1000 kg) = 3/1000 kg`. An offset unit is named by its size and its zero: +`to 1 dp of 1 K from 27315/100 K`. Spec §2.2. + +**Where the helper lives.** `render()` must write the same clause, and `trace_render.hpp` includes `render.hpp`, not +the other way round. So the spelling moves into `render.hpp`'s `detail` namespace, beside `unit_clause` and +`latex_unit` (`:452-476`), and takes how author text is written as a parameter: `render()` writes a symbol verbatim +in plain text and Markdown and escapes the whole unit text for LaTeX afterwards (`latex_unit`), while a trace escapes +each piece of author text (`escaped_author_text`). `render.hpp` already includes ``; it is not one of the +core headers `hygiene.headers` keeps free of it. `coherent_unit_text(Dimension)` stays in `trace_render.hpp` with its +signature, as a one-line wrapper, so its callers and its tests do not change. + +**Offset units are reachable.** `RoundNode` and `RoundSignificantNode` accept a unit with an offset (only the +dimension is checked, `RequireRoundingUnitMatches`, `rounding_node.hpp:35-44`); `RoundedRootNode` refuses one +(`RequireRootUnitWithoutOffset`, `rounded_root.hpp:79-89`), and so does `RoundedOpaqueOutputNode` +(`RequireRoundedOutputUnitWithoutOffset`, `opaque.hpp:939`). So the offset branch is tested, through `rounded<>`. + +**Every rounding clause.** In the trace: `StepKind::Round` (`trace_render.hpp:1162-1163`), `RoundSignificant` +(`:1164-1166`), `RoundingRuleApplied` (`:1169-1170`, its `, in `), `ElementwiseRound` (`:1244-1246`), +`RoundedRoot` (`:1273-1275`), `RoundedOpaqueOutput` (`:1339-1342`) and `rounded_opaque_output_line` (`:3005-3008`). +In `render.hpp`: `RoundNode` (`:1330-1336`), `RoundSignificantNode` (`:1343-1356`), `RoundedRootNode` (`:1367-1377`), +`ElementwiseRoundNode` (`:1070-1087`) and `RoundedOpaqueOutputNode` (`:1949-1959`). The rounded transcendentals keep +passing `{}`: they round a pure number at scale 1. + +**Files:** +- Modify: `include/formula-cpp/render.hpp`: new `AuthorTextSpelling`, `verbatim_text`, `coherent_unit_spelling` and + `rounding_unit_text` after `latex_unit` (`:473-476`); the five render sites above; `rounding_call`'s comment + (`:1256-1262`). +- Modify: `include/formula-cpp/trace_render.hpp`: `coherent_unit_text` (`:595-650`) becomes a wrapper; the seven + trace sites above; `rounding_call_text`'s comment (`:1080-1082`). +- Modify: `test/trace_shown_unit_tests.cpp`: one new `TEST_CASE`, and one line in the walker test. +- Modify: `test/render_tests.cpp`: one new `TEST_CASE` after `"render: a significant-digits rounding node renders as + round(..., to N sf of unit)"` (`:463`). +- Modify: `docs/rounding-and-conditionals.md:202-204`, `docs/tracing.md` (after `:380`), `CHANGELOG.md`. + +**Interfaces:** +- Consumes: Task 2's spelling of `coherent_unit_text`; Task 1's guarantee that a symbol-less dimensionless unit is at + scale 1; `Rational::make(std::int64_t, std::int64_t) -> std::expected`; + `fraction_text(Rational) -> NumberText` (`number_text.hpp:378`; bind it to a named local before `.view()`, which is + deleted on an rvalue); `escaped_author_text(std::string_view) -> std::string`. +- Produces (all in `formula::detail`, `render.hpp`): + - `using AuthorTextSpelling = std::string (*)(std::string_view);` + - `verbatim_text(std::string_view) -> std::string`; + - `coherent_unit_spelling(Dimension, AuthorTextSpelling) -> std::string`: the body Task 2 wrote; + - `rounding_unit_text(Unit const&, AuthorTextSpelling) -> std::string`: the text after `of` (or `in`) in a + rounding clause; empty only for a dimensionless unit at scale 1, or a malformed magnitude. +- `trace_render.hpp`'s `coherent_unit_text(Dimension)` keeps its signature and spelling. + +- [ ] **Step 1: Write the failing tests.** In `test/trace_shown_unit_tests.cpp`, after the fixtures (after + `struct Share …` at `:84-86`), add: + +```cpp +// A Celsius scale with no symbol: an offset unit the rounding clause must name by its size and its zero. +inline constexpr formula::Unit UnnamedCelsius { .dimension = formula::dim::Temperature, + .offsetNumerator = 27315, + .offsetDenominator = 100 }; +struct UnnamedReading: formula::Quantity +{ +}; +``` + + and after the test case `"a value in a unit with no symbol is shown in the coherent unit, with its symbol"` + (`:314-320`), add: + +```cpp +TEST_CASE("a rounding in a unit with no symbol names that unit by its size", "[trace-render][shown-unit][rounding]") +{ + // 3.141 of the unnamed gram to 2 places is 3.14 of it: 157/50000 kg. The + // places count in the unnamed gram, and the line says so in the coherent + // unit the value is written in. + auto const masses = formula::environment(formula::Measured { Rational { 3141, 1000 } }); + CHECK(trace_text(formula::rounded( + var), + masses) + == "1. m_u = 3141/1000000 kg\n" + "2. round(#1, to 2 dp of 1/1000 kg) = 157/50000 kg [nearest, ties to even]\n"); + CHECK(trace_text(formula::rounded_to_digits( + var), + masses) + .find("2. round(#1, to 2 sf of 1/1000 kg) = 31/10000 kg") + != std::string::npos); + // 20.5 on the unnamed Celsius scale, rounded to 0 places of it: 21, which + // is 294.15 K. The places count from that scale's zero, 273.15 K. + auto const readings = formula::environment(formula::Measured { Rational { 41, 2 } }); + CHECK(trace_text(formula::rounded( + var), + readings) + == "1. T_u = 5873/20 K\n" + "2. round(#1, to 0 dp of 1 K from 27315/100 K) = 5883/20 K [nearest, ties away from zero]\n"); +} +``` + + In `"every value a trace shows is in the unit written after it"`, after the line Task 2 added, add: + +```cpp + // A rounding in a unit with no symbol. + check_each_value_is_in_the_unit_written_after_it(recorded_trace( + formula::rounded(var), + inputs)); +``` + + In `test/render_tests.cpp`, add to the anonymous namespace's fixtures (after `struct Strength …`): + +```cpp +// Units with no symbol, which a rounding clause names by their size in the coherent unit. +inline constexpr formula::Unit UnlabelledGram { .dimension = formula::dim::Mass, + .magnitudeNumerator = 1, + .magnitudeDenominator = 1000 }; +inline constexpr formula::Unit UnlabelledCelsius { .dimension = formula::dim::Temperature, + .offsetNumerator = 27315, + .offsetDenominator = 100 }; +inline constexpr formula::Unit UnlabelledPerGram { .dimension = formula::dim::Scalar / formula::dim::Mass, + .magnitudeNumerator = 1000 }; +struct UnlabelledWeight: formula::Quantity +{ +}; +struct UnlabelledReading: formula::Quantity +{ +}; +struct UnlabelledLoading: formula::Quantity +{ +}; +``` + + and after the significant-digits rounding test case (`:463`), add: + +```cpp +TEST_CASE("render: a rounding in a unit with no symbol names that unit by its size", "[render][rounding]") +{ + constexpr auto toHundredths = + formula::rounded( + var); + CHECK(formula::render(toHundredths) == "round(w, to 2 dp of 1/1000 kg)"); + CHECK(formula::render(toHundredths) == "\\operatorname{round}_{2\\,\\mathrm{1/1000\\ kg}}(w)"); + CHECK(formula::render( + formula::rounded_to_digits( + var)) + == "round(w, to 3 sf of 1/1000 kg)"); + CHECK(formula::render( + formula::rounded( + var)) + == "round(t, to 1 dp of 1 K from 27315/100 K)"); + CHECK(formula::render( + formula::rounded( + var)) + == "round(q, to 0 dp of 1000 kg^-1)"); + // A dimensioned unit of magnitude 1 with no symbol is still named by its + // size, never bare and never "of kg" alone: the reader cannot tell it from + // the coherent unit otherwise. + CHECK(formula::render( + formula::rounded( + var)) + == "round(h, to 2 dp of 1 kg)"); + // A dimensionless unit at scale 1 still writes no clause. + CHECK(formula::render( + formula::rounded( + formula::number(formula::Rational { 1, 3 }))) + == "round(1/3, to 2 dp)"); +} +``` + + Beside the file's other unlabelled fixtures, add the magnitude-1 one this case uses: + +```cpp +// A mass unit of magnitude 1 with no symbol: the kilogram's size under no name. +inline constexpr formula::Unit UnlabelledKilogram { .dimension = formula::dim::Mass }; +struct UnlabelledHeft: formula::Quantity +{ +}; +``` + + (`render_tests.cpp` has no `using formula::Rational;`, hence the qualified name. Its `using formula::Dialect;` and + `using formula::var;` are at `:92-93`, above the test case. If `render()` spells the constant `1/3` differently, + for example bracketed, take the spelling the file already pins for a dimensionless fraction constant. The point of + that check is that no `of` clause follows.) + +- [ ] **Step 2: Run them to see them fail.** + Run: `pwsh -NoProfile -File $S\cl.ps1 -Tree $T -Filter "names that unit by its size|every value a trace shows"`. + Expected: the two new cases FAIL (`to 2 dp)` with no clause); the walker case passes already (the walker reads + values only). Prove ctest ran 3 tests. + +- [ ] **Step 3: Add the shared spelling to `render.hpp`.** Immediately after `latex_unit` (`:473-476`), inside + `namespace detail`, add: + +```cpp + /// How a caller writes a piece of author text that it states inside a + /// unit's spelling -- a unit's symbol, or a named base dimension's name: + /// as it is, in `render()`'s text (`verbatim_text`), or escaped, in a + /// trace line (`escaped_author_text`, `trace_render.hpp`). + using AuthorTextSpelling = std::string (*)(std::string_view); + + /// @p authored as it is: `render()` writes a unit's symbol verbatim in + /// plain text and Markdown, and LaTeX escapes the whole unit text + /// afterwards (`latex_unit`). + [[nodiscard]] inline std::string verbatim_text(std::string_view authored) + { + return std::string { authored }; + } +``` + + Then **move** the body of `coherent_unit_text` from `trace_render.hpp` (as Task 2 left it) here, renamed and with + the name spelling as a parameter, together with its whole comment: + +```cpp + /// + [[nodiscard]] inline std::string coherent_unit_spelling(Dimension dimension, AuthorTextSpelling spellName) + { + // + } +``` + + Write the body out in full when you move it; the angle-bracket lines above say what changes, they are not code. + Then add: + +```cpp + /// The unit a rounding's places or digits count in, as the clause after + /// `of` names it: `round(m, to 2 dp of )`. + /// + /// - A unit with a symbol: its symbol, as @p spellAuthorText writes it. + /// - A dimensionless unit with no symbol: nothing, so the clause is + /// dropped. Such a unit is at scale 1 (`RequireNamedScaledScalar`, + /// `unit.hpp`), and its places are places of the bare number. + /// - A dimensioned unit with no symbol: its size in the coherent unit, + /// exact, then that unit's spelling: `1/1000 kg`, `1000 kg^-1`. The + /// value after a trace's `=` is written in the same coherent unit, so + /// the line says what the places count in. + /// - The same with an offset: its size and its zero, both in the coherent + /// unit: `1 K from 27315/100 K`. The places count steps of the size from + /// that zero, which is how the rounding computes them. + /// + /// Nothing for a magnitude or offset that names no rational (a zero + /// denominator): a malformed unit, refused by every conversion, whose + /// rounding never reaches a value to show. + [[nodiscard]] inline std::string rounding_unit_text(Unit const& roundedIn, AuthorTextSpelling spellAuthorText) + { + std::string_view const symbolText = view(roundedIn.symbolText); + if (!symbolText.empty()) + return spellAuthorText(symbolText); + if (roundedIn.dimension == dim::Scalar) + return {}; + std::expected const unitSize = + Rational::make(roundedIn.magnitudeNumerator, roundedIn.magnitudeDenominator); + if (!unitSize.has_value()) + return {}; + std::string const coherentText = coherent_unit_spelling(roundedIn.dimension, spellAuthorText); + NumberText const sizeText = fraction_text(*unitSize); + std::string spelled = std::string { sizeText.view() } + " " + coherentText; + if (roundedIn.offsetNumerator == 0) + return spelled; + std::expected const unitZero = + Rational::make(roundedIn.offsetNumerator, roundedIn.offsetDenominator); + if (!unitZero.has_value()) + return {}; + NumberText const zeroText = fraction_text(*unitZero); + return spelled + " from " + std::string { zeroText.view() } + " " + coherentText; + } +``` + + In `trace_render.hpp`, `coherent_unit_text` keeps a short comment and becomes: + +```cpp + /// The coherent unit of @p dimension spelt from its base units, as a + /// trace line writes it: `coherent_unit_spelling` (`render.hpp`), with + /// each named base dimension's name escaped as author text. + [[nodiscard]] inline std::string coherent_unit_text(Dimension dimension) + { + return coherent_unit_spelling(dimension, escaped_author_text); + } +``` + + If `trace_render.hpp` declares `escaped_author_text` below `coherent_unit_text`, it does not (it is at `:185`, the + wrapper at `:610`); keep that order. + +- [ ] **Step 4: Use it at every rounding clause.** + In `render.hpp`: + - `RoundNode` (`:1330-1336`): replace `std::string { view(declaredUnit.symbolText) }` with + `detail::rounding_unit_text(declaredUnit, detail::verbatim_text)`. + - `RoundSignificantNode` (`:1343-1356`), `RoundedRootNode` (`:1367-1377`), `ElementwiseRoundNode` + (`:1070-1087`): replace the initialiser of `unitSymbol` (`{ view(… .symbolText) }`) with + `= detail::rounding_unit_text(declaredUnit, detail::verbatim_text)` (`roundedIn` in the elementwise one). + - `RoundedOpaqueOutputNode` (`:1955-1958`): as `RoundNode`. + In `trace_render.hpp`, replace `unit_symbol_text(.unit)` with `rounding_unit_text(.unit, + escaped_author_text)` in exactly these places: `StepKind::Round`, `RoundSignificant`, `RoundingRuleApplied`, + `ElementwiseRound`, `RoundedRoot`, `RoundedOpaqueOutput` (in `step_expression`), and the + `rounding_call_text(opaque_output_label(...), …)` call in `rounded_opaque_output_line`. Leave `NumericValue`'s + `unit_symbol_text(shownStep.sourceUnit)` as it is: it is no rounding (see the report note below). + + Update the comments that say "No unit clause for a unit with no symbol": in `rounding_call` (`render.hpp:1256-1262`) + write "The unit is `rounding_unit_text`'s: a unit with no symbol is named by its size, and only a dimensionless + unit at scale 1 has no clause."; in `rounding_call_text` (`trace_render.hpp:1080-1082`) replace "No unit clause for + a unit with no symbol." with "The unit is `rounding_unit_text`'s (`render.hpp`), so that a unit with no symbol is + named by its size, in the coherent unit the value after `=` is written in." In `ElementwiseRoundNode`'s render + comment (`render.hpp:1066-1069`), replace "and dropped for a unit with no symbol" with "and, for a unit with no + symbol, its size in the coherent unit (`rounding_unit_text`)". + +- [ ] **Step 5: Run the tests to see them pass.** + Run: `pwsh -NoProfile -File $S\cl.ps1 -Tree $T -Filter "by its size|every value a trace shows|rounding|spelt"`. + Expected: `ALL OK`. If the Celsius case's first line or mode text differs from the pinned text in anything but the + rounding clause, stop and report the actual line rather than re-pinning it: the clause is what this task changes. + +- [ ] **Step 6: Document it.** + - `docs/rounding-and-conditionals.md:202-204`: after "…renders as `numeric(, in )`." add: "A unit + with no symbol is named by its size in the coherent unit, `round(m, to 2 dp of 1/1000 kg)`, and one with an + offset by its size and its zero, `to 1 dp of 1 K from 27315/100 K`, so that the places say what they count in; + only a dimensionless unit at scale 1 writes no unit clause, `round(x, to 2 dp)`." + - `docs/tracing.md`, after the sentence Task 1 added at the end of the paragraph ending `:380`: "A rounding in a + unit with no symbol names that unit by its size in the coherent unit: `round(#1, to 2 dp of 1/1000 kg) = + 157/50000 kg`." + - `CHANGELOG.md`, `### Changed`: + +```markdown +- A rounding in a unit with no symbol names that unit by its size in the coherent unit, in `render()` and in a + trace: `round(#1, to 2 dp of 1/1000 kg) = 157/50000 kg`, where it wrote `round(#1, to 2 dp)`, which read as places + of the kilogram written after it. A unit with an offset is named by its size and its zero, `to 1 dp of 1 K from + 27315/100 K`. Only a dimensionless unit at scale 1 still writes no unit clause. +``` + +- [ ] **Step 6b: The two other places that write an unnamed unit's number without its scale.** These were found + while planning this task. They follow the same display rule, so they are fixed here, not filed: + - `render()` of a constant in a dimensioned unit with no symbol (`render_node(ConstantNode const&, …)`, + `render.hpp:985-998`) writes `3` for 3 of an unnamed gram; + - `numeric(x, in )` writes no clause for such a unit, in `render()` (`render_node(NumericValueNode<…> + const&, …)`, `render.hpp:1405-1416`) and in a trace (`StepKind::NumericValue`, `trace_render.hpp:1171-1172`). + + 1. **Write the failing tests first.** Add one test case to `test/trace_shown_unit_tests.cpp`, after the cases + Step 1 added, using the same `UnnamedGram` fixture (magnitude 1/1000, mass, no symbol) and the file's existing + environment helpers: + +```cpp +TEST_CASE("a constant and a numeric value in a unit with no symbol say what scale their number is on", + "[trace][units]") +{ + // A constant typed as 3 of a unit of 1/1000 kg with no symbol: render() + // writes it in the coherent unit, as a trace does, never as a bare 3. + auto const typedMass = formula::constant(formula::Rational { 3 }); + CHECK(formula::render(typedMass) == "3/1000 kg"); + + // numeric(x, in ) names the unit its bare number is taken in by its + // size, in render() and in the trace line alike. + auto const bareMass = formula::numeric(typedMass); + CHECK(formula::render(bareMass) == "numeric(3/1000 kg, in 1/1000 kg)"); + std::string const traced = formula::render_trace(formula::explain(bareMass, formula::environment())); + CHECK(traced.find("numeric(#1, in 1/1000 kg)") != std::string::npos); +} +``` + + Adjust only the spelling of the factory calls (`constant`, `numeric`, `explain`, the quantity the numeric + value yields, `render_trace`) to the forms the file and `include/formula-cpp/numeric.hpp` actually use. The + three expected texts are the contract. If `numeric`'s quantity has a name other than `Count` in the file's + fixtures, use the file's dimensionless quantity. Run it with `-Filter "say what scale"` and watch all three + checks fail. + + 2. **The constant.** In `render.hpp`, where Step 3 moved the coherent spelling, also move `spells_coherent_unit` + and `shown_unit_of` from `trace_render.hpp` into `render.hpp`'s `detail` namespace, unchanged with their + comments. `trace_render.hpp` uses them through `detail::` as before, so its call sites do not change. Then + `render_node(ConstantNode const&, …)` becomes: + +```cpp +template +[[nodiscard]] std::string render_node(ConstantNode const& node, V const& vocabulary) +{ + constexpr Unit declaredUnit = U; + // A unit with no symbol cannot say what scale its number is on, so the + // constant is written in the coherent unit, exact, as a trace writes it + // (`detail::spells_coherent_unit`). + if constexpr (detail::spells_coherent_unit(declaredUnit, declaredUnit.dimension)) + { + constexpr Unit coherentUnit = coherent(declaredUnit.dimension); + std::expected const inCoherent = + checked_convert(node.number, declaredUnit, coherentUnit); + std::string const numberText = + inCoherent.has_value() + ? detail::styled_number_text(*inCoherent, detail::typed_number_style(vocabulary).exact_only(), coherentUnit) + : detail::not_shown_text(inCoherent.error()); + std::string const coherentText = detail::coherent_unit_spelling(declaredUnit.dimension, detail::verbatim_text); + if constexpr (D == Dialect::LaTeX) + return numberText + detail::unit_clause("\,", detail::latex_unit(coherentText)); + else + return detail::number_with_unit(numberText, coherentText); + } + else if constexpr (D == Dialect::LaTeX) + return detail::typed_number_text(node.number, declaredUnit, vocabulary) + + detail::unit_clause("\,", detail::latex_unit(view(declaredUnit.symbolText))); + else + return detail::number_with_unit(detail::typed_number_text(node.number, declaredUnit, vocabulary), + view(declaredUnit.symbolText)); +} +``` + + `spells_coherent_unit` must be `constexpr` for the `if constexpr`. Mark it `constexpr` when moving it; `view` + and `Dimension`'s `==` already are. If `not_shown_text` lives in `trace_render.hpp`, move it into + `render.hpp`'s `detail` with its comment too. Its text is the trace's `(not shown: …)`. Extend the constant's + doc comment by one sentence: "A constant in a dimensioned unit with no symbol is written in the coherent + unit, exact (`3/1000 kg`), for the reason a trace is." + + 3. **`numeric`.** In `render_node(NumericValueNode<…> const&, …)`, replace + `std::string const unitSymbol { view(declaredUnit.symbolText) };` with + `std::string const unitSymbol = detail::rounding_unit_text(declaredUnit, detail::verbatim_text);`. In + `trace_render.hpp`'s `StepKind::NumericValue` case, replace `unit_symbol_text(shownStep.sourceUnit)` with + `rounding_unit_text(shownStep.sourceUnit, escaped_author_text)`. In `rounding_unit_text`'s comment (Step 3), + change the first line to "The unit a rounding's places or digits count in, or a numeric value's bare number + is taken in, as the clause after `of` or `in` names it." + + 4. Re-run `-Filter "say what scale|by its size|every value a trace shows|render"` → `ALL OK`. A pinned text that + changes is one of these two forms for a unit with no symbol. Re-pin it, and list it in the report. Any other + change: stop and report. + + 5. **Docs.** `docs/expressions.md`, where constants are described (search for "followed by its unit's symbol"): + add "A constant in a dimensioned unit with no symbol is written in the coherent unit, exact: `3/1000 kg`." Then + extend the CHANGELOG entry below by one sentence: "A constant in such a unit renders in the coherent unit + (`3/1000 kg`, where it wrote a bare `3`), and `numeric(x, in )` names the unit by its size too." + +- [ ] **Step 7: Verify.** `pwsh -NoProfile -File $S\cl.ps1 -Tree $T -Exclude "^negative\."` → `ALL OK`. + `hygiene.headers` must stay green: no `` reaches a core header (`render.hpp` already had it). + Expected count: **+3** non-negative tests, **0** negative. + +- [ ] **Step 8: Commit.** Message in `$S\task-3-commit.txt`: + +``` +fix: name a rounding's unit by its size when it has no symbol + +A rounding to places of a unit with no symbol wrote no unit clause, +while the value after it read in the coherent unit: round(#1, to 2 dp) = +3/1000 kg let "2 dp" read as places of a kilogram. The clause now names +the unit by its size in the coherent unit, round(#1, to 2 dp of 1/1000 +kg), and an offset unit by its size and its zero, to 1 dp of 1 K from +27315/100 K, in render() and in a trace alike. numeric(x, in ) +names such a unit the same way, and render() writes a constant in it in +the coherent unit, 3/1000 kg, where it wrote a bare 3. The coherent unit's +spelling moves into render.hpp so that both can write it. + +Signed-off-by: Christian Parpart +``` + + Run: `git -C $T add -A include test docs CHANGELOG.md` then `git -C $T commit -F $S\task-3-commit.txt`. + +--- + +### Task 4: A precision limit's first pass borrows its unit; the snap compare explained (#16, #18) + +**#16.** Pass 1 of a precision limit restates its level's value, but its unit is chosen from types alone +(`precision.hpp:1015`): the limit's first placeholder's quantity, else the level expression's quantity, else the +coherent unit. A constant level in grams therefore reads `40 g`, then `1/25 kg` on the pass-1 line. The pass-1 step +now borrows the unit of the step it restates, through `detail::restated_unit_or`, as pass 2 and a conditional already +do (`trace.hpp:3437-3441`); the static unit stays the fallback. Spec §2.4. + +**#18.** A snap's "on a permitted value" test compares raw numerator/denominator pairs (`snap_suffix`, +`trace_render.hpp:2164`), while table rows compare values (`same_declared_bound`). The raw compare is exact: an exact +hit records one row twice from a single index (`Segment { Permitted[located->low], Permitted[located->low] }`, +`snap.hpp:133-134`), and `RequireValidBreakpointTable` keeps the permitted set strictly ascending by value, so two +different rows never hold one value. That is stated in a comment. Separately, `lookup_miss_text` spells its low bound +before it branches, and only one branch uses it. Spec §2.5. + +**Files:** +- Modify: `include/formula-cpp/trace.hpp`: `RecordingSink::precision_level_produced` (`:3826-3871`), its body and + comment. +- Modify: `include/formula-cpp/trace_render.hpp`: `snap_suffix` (`:2152-2165`), `lookup_miss_text` (`:874-911`). +- Modify: `test/trace_shown_unit_tests.cpp`: one new `TEST_CASE`, one line in the walker test. +- Modify: `docs/tracing.md:393-394`, `CHANGELOG.md`. + +**Interfaces:** +- Consumes: `detail::restated_unit_or(std::vector> const&, std::vector const& operands, + Dimension, std::optional const& restatedValue, Unit fallback) -> Unit` (`trace.hpp:2367-2381`): the last + claimed operand's unit when it has a symbol, is of the dimension, holds exactly the restated value and passes + `borrowable_for_a_point`; `fallback` otherwise. +- Produces: no new names. `Step::unit` of a `StepKind::PrecisionLevel` step holds the borrowed unit where one applies. + +- [ ] **Step 1: Write the failing test.** In `test/trace_shown_unit_tests.cpp`, after the test case `"a conditional + reads in its chosen branch's unit, offset or not"` (`:448-469`), add: + +```cpp +TEST_CASE("a precision limit's first pass reads in the unit of the level it restates", "[trace-render][shown-unit][precision]") +{ + // The level is a constant in grams and the limit names no quantity, so + // nothing in the types says grams: pass 1 reads off the step it restates, + // 40 g, never 1/25 kg. + CHECK(trace_text(formula::precision_limit( + formula::constant(Rational { 40 }), formula::constant(Rational { 1 })), + determinations) + .starts_with("1. 40 g\n" + "2. level (pass 1 of 2) = #1 = 40 g\n")); +} +``` + + In the same test case, pin the two levels that cannot borrow: + +```cpp + // A level constant in a unit with no symbol cannot lend its unit + // (`restated_unit_or` borrows only a unit with a symbol): pass 1 stays in + // the unit the types give, the coherent kilogram, as before. + CHECK(trace_text(formula::precision_limit( + formula::constant(Rational { 40000 }), formula::constant(Rational { 1 })), + determinations) + .find("2. level (pass 1 of 2) = #1 = 40 kg +") + != std::string::npos); + // A level constant in degrees Celsius is a point on an offset scale, which + // `borrowable_for_a_point` lets a restating step show: pass 1 reads in + // degrees Celsius, as the constant's own line does, never as a kelvin + // difference. + CHECK(trace_text(formula::precision_limit( + formula::constant(Rational { 20 }), + formula::constant(Rational { 1 })), + determinations) + .find("2. level (pass 1 of 2) = #1 = 20 °C +") + != std::string::npos); +``` + + Use the file's own names for the unnamed-gram fixture and the Celsius unit. If `precision_limit` refuses a level + of temperature at compile time, drop the Celsius check, and state the refusal's text in the report. That refusal + is then what keeps an offset level from reaching pass 1. The task's count stays **+1**: these checks join the new + test case. + + In `"every value of a rejection, a bill, the statistics, a precision limit and an opaque call is in the unit written + after it"`, after the second precision-limit walker call (`:763-766`), add: + +```cpp + // A precision limit over a level constant in grams. + check_each_value_is_in_the_unit_written_after_it(recorded_trace( + formula::precision_limit(formula::constant(Rational { 40 }), + formula::constant(Rational { 1 })), + pair)); +``` + +- [ ] **Step 2: Run it to see it fail.** + Run: `pwsh -NoProfile -File $S\cl.ps1 -Tree $T -Filter "first pass reads"`. + Expected: 1 test, FAILED; the expansion shows `2. level (pass 1 of 2) = #1 = 1/25 kg`. + +- [ ] **Step 3: Borrow the unit in pass 1.** In `precision_level_produced`, after the value is set (after the + `else if (produced->has_value()) levelStep.value = **produced;` line) and before `std::size_t const levelIndex`, + add: + +```cpp + // Pass 1 restates the level expression's value, so it reads in the + // unit of the step it restates, as pass 2 and a conditional do: a + // level constant in grams reads in grams on both lines. The unit the + // types give stays the answer when nothing can be borrowed. + levelStep.unit = detail::restated_unit_or(_trace->steps, levelStep.operands, levelStep.dimension, + levelStep.value, levelUnit); +``` + + Replace the function's comment paragraph "Its value is in @p levelUnit, the unit of the quantity the limit's + placeholders name, so that the level reads as the results do; its dimension is the level expression's." with: + +```cpp + /// It reads in the unit of the step it restates when that unit has a + /// symbol and holds exactly its value (`detail::restated_unit_or`), as + /// pass 2 does; otherwise in @p levelUnit, the unit of the quantity the + /// limit's placeholders name, so that the level reads as the results do. + /// Its dimension is the level expression's. +``` + +- [ ] **Step 4: Run it to see it pass, and find what else moved.** + Run: `pwsh -NoProfile -File $S\cl.ps1 -Tree $T -Filter "first pass reads|precision|shown"`. + Expected: the new case passes. A pinned pass-1 line in `test/precision_tests.cpp` or elsewhere changes only where + its level's last step shows a unit with a symbol other than the one the types gave. Re-pin each such line to the + borrowed unit, and list every re-pinned line in the report with its old and new text. A change to any other line is + a defect of this step: stop and report it. + +- [ ] **Step 5: Explain the snap compare, and spell the curve's low bound only where it is used.** In + `trace_render.hpp`, `snap_suffix`, directly above `if (neighbours.low == neighbours.high)` (`:2164`), add: + +```cpp + // Raw pairs, not values, and exact all the same: an exact hit + // records the one row it hit twice, from a single index + // (`locate_and_snap`, `snap.hpp`), and the permitted set is + // strictly ascending by value (`RequireValidBreakpointTable`), so + // two different rows never hold one value. A lookup's segment and + // a missed lookup's range compare by value + // (`same_declared_bound`) because a table's rows can be typed as + // different pairs of one number. +``` + + In `lookup_miss_text`, replace the tail from `std::string const lowText = shown_bound_text(` to the function's last + `return` with: + +```cpp + if (same_declared_bound(recorded.coveredRange->lowNumerator, + recorded.coveredRange->lowDenominator, + recorded.coveredRange->highNumerator, + recorded.coveredRange->highDenominator)) + { + std::string const onlyRowText = shown_bound_text( + recorded.coveredRange->lowNumerator, recorded.coveredRange->lowDenominator, keyUnit, numberStyle); + return "outside the curve, whose only row is at " + + number_with_unit(onlyRowText, shown_unit_text(keyUnit, keyUnit.dimension)); + } + return "outside the curve, which runs " + closed_range_text(*recorded.coveredRange, keyUnit, numberStyle); +``` + + Keep the comment above it ("A curve with exactly one row covers…") where it is. + +- [ ] **Step 6: Document it.** + - `docs/tracing.md:393-394`: change "A precision limit reads in its second pass's." to "A precision limit reads + in its second pass's, and its first pass in the unit of the level it restates: a level constant in grams reads + in grams on both lines." + - `CHANGELOG.md`, `### Changed`: + +```markdown +- A precision limit's first pass reads in the unit of the level step it restates, as its second pass already did: + a level constant declared in grams reads `40 g` on both lines, where the first pass read `1/25 kg`. The unit the + limit's quantities give is still used when the level's step has none to lend. +``` + +- [ ] **Step 7: Verify.** `pwsh -NoProfile -File $S\cl.ps1 -Tree $T -Exclude "^negative\."` → `ALL OK`. + Expected count: **+1** non-negative test, **0** negative. The snap and lookup tests (`snap`, `lookup`, `curve`, + `shown`) pass unchanged: Step 5 changes no output. + +- [ ] **Step 8: Commit.** Two commits, one per issue. First `$S\task-4a-commit.txt`: + +``` +fix(trace): read a precision limit's first pass in its level's unit + +Pass 1 restates the level's value but took its unit from the types +alone: the limit's quantity, else the level expression's, else the +coherent unit. A level constant in grams read 40 g on its own line and +1/25 kg on the pass-1 line. Pass 1 now borrows the unit of the step it +restates, under the rule pass 2 and a conditional use; the unit the +types give stays the fallback. + +Signed-off-by: Christian Parpart +``` + + Run: `git -C $T add include/formula-cpp/trace.hpp test/trace_shown_unit_tests.cpp docs/tracing.md CHANGELOG.md` + plus any test file Step 4 re-pinned, then `git -C $T commit -F $S\task-4a-commit.txt`. + Then `$S\task-4b-commit.txt`: + +``` +refactor(trace): say why a snap's exact hit compares raw pairs + +A snap decides it sat on a permitted value by comparing two raw pairs, +where a lookup's rows compare values. That is exact: an exact hit +records one row twice, and the permitted set is strictly ascending by +value. A comment now says so. A missed curve lookup also spells its low +bound only on the path that writes it. + +Signed-off-by: Christian Parpart +``` + + Run: `git -C $T add include/formula-cpp/trace_render.hpp` then `git -C $T commit -F $S\task-4b-commit.txt`. + +--- + +**Lane A totals:** +5 non-negative tests (Task 1: +1, Task 2: +1, Task 3: +2, Task 4: +1), +3 negative tests (Task 1). + +## Lane B — numerics (#20, #19) + +Lane B is Task 5, then Task 6. Each task runs in the implementer's own agent-owned worktree: + +- **Before the task:** the controller has set the branch `feature/open-issues-numerics` to the previous Lane B head. For Task 5 that is the commit that adds this plan. +- **After the task:** the controller moves the branch to the task's last commit with `git branch -f`. + +The two tasks share `docs/numeric-headroom.md` and `CHANGELOG.md`, which is why they run one after the other. + +In this lane, `$T` is the implementer's own worktree, as a Windows path (`D:\formula-cpp\.claude\worktrees\`). `$TW` is the same path as WSL sees it (`/mnt/d/formula-cpp/.claude/worktrees/`). `$SW` is the scratchpad as WSL sees it: `/mnt/c/Users/c.parpart/AppData/Local/Temp/claude/D--formula-cpp/81d1061b-b25f-4c83-9f67-664a67264017/scratchpad`. + +**A correction to the spec, decided while planning (binds Task 5).** Spec §3.1 keeps the kernel's 128 fraction bits and says the existing narrowing alone gives every answer that fits. A model of the kernel shows otherwise: + +- **The model.** `$S\laneb\kernel_sim.py` copies the C++ step for step. +- **What it shows.** + - An exponential's enclosure is 512 · 2^-128 = 2^-119 of its value wide. + - With a 128-bit `Rational`, a result near the top of the range is up to 2^127 last kept units, so its enclosure is up to 2^8 units wide. + - So e^45 at 18 places and e^88 at 0 places are **always** undecided (`Overflow`) at 128 fraction bits. + - The pinned evidence is `undecided == 38` in `test/transcendental_tests.cpp`: e^43 to e^44 at 17 and 18 places, which `Rational` can already hold. +- **The change.** The exponential alone moves to **192 fraction bits**, with a 192-bit ln 2. + - Its enclosure is then 2^-183 of its value, so at worst 2^-56 of a last kept unit: the same margin the 64-bit kernel had. + - The logarithms keep 128 bits. Their results are below 89, so 2^-120 is 2^-60 of a unit at 18 places. + - `KernelLimbs` stays 12: the widest exponential numerator is below 2^321, and below 2^381 after `decide_rounding`'s 10^18. +- **Bounds checked against the spec:** + - ln ends ≤ 254 units: correct. + - log10 < 203 units: correct. + - log10's widest product: below **2^262**. The spec's 2^264 holds but is loose. + - Exponential's widest numerator: **2^321**, and **2^381** after 10^18. The spec's 2^257 and 2^317 assume 128 fraction bits. + - Exponential's reduction: its argument error is **128** units, not 64, since k reaches 127. `ExponentialSlack` 512 still covers it, with 360 needed. + +**A correction to the spec, found while planning (binds Task 6).** Spec §3.2 calls the headroom page's "256-bit" stale. It is not. + +- The `census:least-squares` rows the sentence describes are computed by `LinearLeastSquares::compute_exact` (`least_squares.hpp`). That works in `exact_limbs = 8` limbs: 256 bits. +- The 12-limb, 384-bit kernel is `LinearLeastSquaresOfObservations`', which those rows do not use. +- So N is generated from `formula::LinearLeastSquares::exact_limbs * 32`, and the hook goes into `LinearLeastSquares::compute_exact`. +- Both hand-written figures are right today. The model `$S\laneb\fit_widths.py` gives 68 and 249 bits, with 71 sizes overflowing from 58, matching the census. +- The opaque guide's 65/73, 93/91 and 130/130 are also right (`$S\laneb\fifty_widths.py`). + +--- + +### Task 5: The logarithm and exponential kernel takes 128-bit arguments (#20) + +The kernel now takes the argument's numerator and denominator as `UInt128` magnitudes, instead of narrowing them to 64 bits: + +- `scaled_quotient` becomes a long division over `UInt128` words. +- A logarithm's reduction allows k ≤ 126. +- The exponential computes with 192 fraction bits. Its reduction allows k ≤ 127, and its cap moves from 44 to 887/10. + +The pinned 64-bit refusals become pinned answers. + +**Files:** +- Modify: `include/formula-cpp/detail/transcendental.hpp`: the file comment (`:4-56`); the constants (`:68-112`); `kernel_word` (a three-word overload); `scaled_quotient` (`:114-147`); `exponential_series_lower` (`:174-195`); `natural_log_magnitude` (`:216-254`); `exponential_enclosure` (`:293-372`). +- Modify: `include/formula-cpp/rounded_transcendental.hpp`: `rounded_transcendental` (`:39-92`); the comments of `rounded_ln`, `rounded_log10` and `rounded_exp` (`:122-156`). +- Modify: `include/formula-cpp/detail/checked_int.hpp:13-17`, the file comment only. `narrow_to_int64` stays: `band.hpp` uses it. +- Modify: `docs/expressions.md:510-526`, `docs/numeric-headroom.md:424-431`, `CHANGELOG.md`. +- Test: `test/transcendental_tests.cpp`, `test/rounded_transcendental_tests.cpp`. + +**Interfaces:** +- Consumes: `formula::detail::UInt128`, `UInt128Division`, `u128_divmod`, `u128_add`, `u128_sub`, `portable::shift_left` (`int128.hpp`); `wide_magnitude(Int128) -> UInt128` (`detail/checked_int.hpp:247`); `WideUnsigned<12>::from_u128` (`detail/wide_int.hpp`). +- Produces, in `formula::detail`: + - `inline constexpr std::size_t ExponentialFractionBits = 192;` + - `inline constexpr KernelWord ExponentialOne;` (2^192) + - `inline constexpr KernelWord Ln2Lower192, Ln2Upper192;` (floor(ln 2 · 2^192), and that plus one) + - `inline constexpr std::uint32_t TaylorTermLimit = 50;` (was 40) + - `constexpr KernelWord kernel_word(std::uint64_t, std::uint64_t, std::uint64_t) noexcept;` + - `template requires(FractionBits % 64 == 0 && FractionBits <= 192) constexpr ScaledQuotient scaled_quotient(UInt128 dividend, UInt128 divisor) noexcept;`. It replaces the 64-bit `scaled_quotient(std::uint64_t, std::uint64_t)`. + - In `rounded_transcendental.hpp`: `inline constexpr Rational ExponentialArgumentCap { 887, 10 };` + - Unchanged signatures: `natural_log_enclosure`, `decimal_log_enclosure`, `exponential_enclosure`, `Enclosure`. An exponential's enclosure denominators are now 2^192 and 2^(192+m), not 2^128 and 2^(128+m). + +- [ ] **Step 1: Start the worktree from the lane's head.** In the worktree: + +```powershell +git fetch --all --quiet +git reset --hard feature/open-issues-numerics +git log --oneline -1 +``` + +Expected: the commit that adds this plan. Never touch `D:\formula-cpp` or another worktree. + +- [ ] **Step 2: Write the failing tests in `test/rounded_transcendental_tests.cpp`.** + + 1. Add `#include ` and `#include ` to the includes. Add to the anonymous namespace, after `domainError`: + +```cpp +/// The integer whose decimal digits are @p digits: a literal too wide for a built-in integer. +[[nodiscard]] constexpr Rational::Int integer_of(std::string_view digits) +{ + Rational::Int parsed {}; + for (char const each: digits) + parsed = parsed * 10 + (each - '0'); + return parsed; +} +``` + + 2. Replace the whole test case `"rounded_transcendental: an exponential too large to hold is Overflow"` with: + +```cpp +TEST_CASE("rounded_transcendental: an exponential answers wherever it fits a Rational, and is Overflow past that", + "[rounded_transcendental]") +{ + // exp 89 is past 88.7, where e^x has long left the largest Rational, 2^127 - 1: refused before the kernel. + STATIC_REQUIRE(expAt(Rational { 89 }) == overflow); + // exp 50 = 5184705528587072464087.4533229..., to 6 places. + CHECK(expAt(Rational { 50 }) + == Rational::from_decimal(integer_of("5184705528587072464087453323"), -6)); + // exp 43.7 = 9.52 * 10^18. Floor to whole 10^18s keeps 9 * 10^18; the nearest modes give 10^19. + CHECK(expAt(Rational { 437, 10 }) == Rational { 9'000'000'000'000'000'000 }); + CHECK(expAt(Rational { 437, 10 }) + == Rational { 10'000'000'000'000'000'000ULL }); + // exp 44 = 1.29 * 10^19 floors to 12 * 10^18. + CHECK(expAt(Rational { 44 }) == Rational { 12'000'000'000'000'000'000ULL }); + // exp 43 = 4727839468229346561.474457562744280370...: whole, and to all 18 places. + CHECK(expAt(Rational { 43 }) + == Rational { 4'727'839'468'229'346'561 }); + CHECK(expAt(Rational { 43 }) == Rational { 4'727'839'468'229'346'562 }); + CHECK(expAt(Rational { 43 }) + == Rational::from_decimal(integer_of("4727839468229346561474457562744280370"), -18)); + // exp 45 = 34934271057485095348.034797233406099533 41..., to all 18 places: 38 digits, below 2^127. + CHECK(expAt(Rational { 45 }) + == Rational::from_decimal(integer_of("34934271057485095348034797233406099533"), -18)); + CHECK(expAt(Rational { 45 }) + == Rational::from_decimal(integer_of("34934271057485095348034797233406099534"), -18)); + // exp 88 = 165163625499400185552832979626485876706.9...: whole, 39 digits, below 2^127. + CHECK(expAt(Rational { 88 }) + == Rational { integer_of("165163625499400185552832979626485876706") }); + CHECK(expAt(Rational { 88 }) + == Rational { integer_of("165163625499400185552832979626485876707") }); + // exp 88.5 = 2.7 * 10^38 and exp 88.7 = 3.3 * 10^38 are past 2^127 at every places: through the + // kernel, and Overflow. + CHECK(expAt(Rational { 885, 10 }) == overflow); + CHECK(expAt(Rational { 885, 10 }) == overflow); + CHECK(expAt(Rational { 887, 10 }) == overflow); +} +``` + + 3. In `"rounded_transcendental: absence and failures come first and in order"`, delete these lines: the comment beginning `// An argument whose numerator or denominator does not fit 64 bits`, the three `STATIC_REQUIRE`s of `ln 2^70`, `exp 2^-64` and `log10 2^70`, the comment `// 2^62 and 1/2^62, inside it, answer: ...`, and the two `CHECK`s after it. Keep the `log10 10^30` and `exp -2^70` checks and their comments, but change `// The rule below -43 comes first, whatever the argument's width: exp -2^70 is 0.` to `// The rule below -43 comes first: exp -2^70 is 0 without the kernel.` + + 4. Add a new test case after it: + +```cpp +TEST_CASE("rounded_transcendental: an argument as wide as a Rational holds is answered", "[rounded_transcendental]") +{ + constexpr Rational::Int largest = std::numeric_limits::max(); // 2^127 - 1 + constexpr Rational::Int twoTo126 = Rational::Int { 1 } << 126; + // Through the kernel, so at run time. ln 2^70 = 48.520302639196171659..., log10 2^70 = 21.072099696478683664... + CHECK(lnAt(Rational { Rational::Int { 1 } << 70 }) + == Rational { 485203, 10000 }); + CHECK(lnAt(Rational { Rational::Int { 1 } << 70 }) + == Rational::from_decimal(integer_of("48520302639196171659"), -18)); + CHECK(log10At(Rational { Rational::Int { 1 } << 70 }) + == Rational { 210721, 10000 }); + // exp 2^-64 = 1 + 5.4 * 10^-20 and exp 2^-62 = 1 + 2.2 * 10^-19: 1, and one unit up under Ceiling. + CHECK(expAt(Rational { 1, Rational::Int { 1 } << 64 }) == Rational { 1 }); + CHECK(expAt(Rational { 1, Rational::Int { 1 } << 64 }) + == Rational::from_decimal(1'000'000'000'000'000'001, -18)); + CHECK(expAt(Rational { 1, Rational::Int { 1 } << 62 }) == Rational { 1 }); + // ln 2^62 = 42.97512..., as before. + CHECK(lnAt(Rational { Rational::Int { 1 } << 62 }) + == Rational { 429751, 10000 }); + // ln (2^127 - 1) = 88.029691931113054295..., and of its reciprocal the negation, which Floor takes down. + CHECK(lnAt(Rational { largest }) + == Rational::from_decimal(integer_of("88029691931113054295"), -18)); + CHECK(lnAt(Rational { 1, largest }) + == Rational::from_decimal(-integer_of("88029691931113054296"), -18)); + // log10 (2^127 - 1) = 38.230809449325611792... + CHECK(log10At(Rational { largest }) + == Rational::from_decimal(integer_of("38230809449325611792"), -18)); + CHECK(log10At(Rational { 1, largest }) + == Rational::from_decimal(-integer_of("38230809449325611793"), -18)); + // Two 127-bit integers next to each other: ln((2^126 + 1) / 2^126) = 1.18 * 10^-38. 0 at 18 places, + // and one unit up under Ceiling. + CHECK(lnAt(Rational { twoTo126 + 1, twoTo126 }) == Rational {}); + CHECK(lnAt(Rational { twoTo126 + 1, twoTo126 }) + == Rational::from_decimal(1, -18)); + // The same two the other way round, a ratio below 1 of two 127-bit integers: ln(2^126 / (2^126 + 1)) = + // -1.18 * 10^-38. 0 at 18 places, and one unit down under Floor. + CHECK(lnAt(Rational { twoTo126, twoTo126 + 1 }) == Rational {}); + CHECK(lnAt(Rational { twoTo126, twoTo126 + 1 }) + == Rational::from_decimal(-1, -18)); + // exp of -2^127 / (2^127 - 1), whose numerator is the minimum's magnitude: e^-1.000... = 0.367879441171442321595... + CHECK(expAt(Rational { std::numeric_limits::min(), largest }) + == Rational::from_decimal(367'879'441'171'442'321, -18)); + // exp 1/(2^127 - 1) = 1 + 5.9 * 10^-39: 1, and one unit up under Ceiling. + CHECK(expAt(Rational { 1, largest }) == Rational { 1 }); + CHECK(expAt(Rational { 1, largest }) + == Rational::from_decimal(1'000'000'000'000'000'001, -18)); +} +``` + + Every value above was computed with Python's `decimal` at 150 significant digits, as the file header says. To check one: + +```python +from decimal import Decimal, getcontext, ROUND_FLOOR +getcontext().prec = 150 +print((Decimal(2**127 - 1).ln() * 10**18).to_integral_value(rounding=ROUND_FLOOR)) # 88029691931113054295 +print((Decimal(45).exp() * 10**18).to_integral_value(rounding=ROUND_FLOOR)) # 34934271057485095348034797233406099533 +``` + +- [ ] **Step 3: Write the failing tests in `test/transcendental_tests.cpp`.** + + 1. Add `#include ` to the includes. Replace `word_of`, `at_most` and `pinned_by_published_digits` with these. The cross products need 16 limbs: an exponential's 192-bit numerator times a reference's 10^58 passes 384 bits. + +```cpp +/// A word of 16 limbs, wide enough for the checks' cross products. +using CheckWord = detail::WideUnsigned<16>; + +/// @p narrow, the same value, in a `CheckWord`. +[[nodiscard]] constexpr CheckWord widened_word(Word const& narrow) +{ + std::array limbsCopied {}; + for (std::size_t limbAt = 0; limbAt < detail::KernelLimbs; ++limbAt) + limbsCopied[limbAt] = narrow.limb(limbAt); + return CheckWord::from_limbs(limbsCopied); +} + +/// The decimal digits @p decimalDigits as a word of @p Limbs limbs. +template +[[nodiscard]] constexpr detail::WideUnsigned word_of(std::string_view decimalDigits) +{ + detail::WideUnsigned parsed {}; + for (char const each: decimalDigits) + parsed = *detail::add_small_checked_or_none(*detail::mul_small_checked_or_none(parsed, 10U), + static_cast(each - '0')); + return parsed; +} + +/// Whether @p left <= @p right, for two signed ratios with positive denominators. +[[nodiscard]] constexpr bool at_most(Ratio const& left, Ratio const& right) +{ + bool const leftNegative = left.negative && !left.numerator.is_zero(); + bool const rightNegative = right.negative && !right.numerator.is_zero(); + if (leftNegative != rightNegative) + return leftNegative; + CheckWord const leftCross = *detail::mul_checked_or_none(widened_word(left.numerator), widened_word(right.denominator)); + CheckWord const rightCross = *detail::mul_checked_or_none(widened_word(right.numerator), widened_word(left.denominator)); + return leftNegative ? rightCross <= leftCross : leftCross <= rightCross; +} + +/// Whether @p stored is floor(v * 2^@p fractionBits) for the value v below one whose first significant digits +/// are @p published, as many as it has characters: (D - 1) * 2^bits >= stored * 10^digits and +/// (D + 1) * 2^bits <= (stored + 1) * 10^digits. That interval is 2 * 2^bits / 10^digits units wide -- under +/// 0.07 for 128 bits and 40 digits, under 0.013 for 192 bits and 60 -- so it pins the floor. +[[nodiscard]] constexpr bool pinned_by_published_digits(Word const& stored, std::size_t fractionBits, std::string_view published) +{ + CheckWord const digitsValue = word_of<16>(published); + CheckWord const unit = *detail::shift_left_checked_or_none(CheckWord::from_u64(1), fractionBits); + CheckWord const tenToDigits = *detail::pow10<16>(published.size()); + CheckWord const storedWide = widened_word(stored); + return *detail::mul_checked_or_none(storedWide, tenToDigits) + <= *detail::mul_checked_or_none(*detail::sub_checked_or_none(digitsValue, CheckWord::from_u64(1)), unit) + && *detail::mul_checked_or_none(*detail::add_small_checked_or_none(digitsValue, 1U), unit) + <= *detail::mul_checked_or_none(*detail::add_small_checked_or_none(storedWide, 1U), tenToDigits); +} +``` + + 2. In the file header comment, extend the published-values sentence with `and ln 2 to 60 digits, for the exponential's 192-bit constant: 0.693147180559945309417232121458176568075500134360255254120680...`. + + 3. Widen the reference table. `struct Reference`'s `std::int64_t numerator;` and `std::int64_t denominator;` become `formula::Rational::Int numerator;` and `formula::Rational::Int denominator;`. Add before the table: + +```cpp +constexpr Rational::Int largestInt = std::numeric_limits::max(); // 2^127 - 1 +constexpr Rational::Int smallestInt = std::numeric_limits::min(); // -2^127 +``` + + The table becomes `std::array`. Append these twelve rows after the `44, 1` row. Their digits come from the header's Python, run by `$S\laneb\table_sim.py`. + +```cpp + { Transcendental::NaturalLogarithm, Rational::Int { 1 } << 70, 1, false, "4852030263919617165920624850207235976528", 38 }, + { Transcendental::NaturalLogarithm, largestInt, 1, false, "8802969193111305429598847942518842414558", 38 }, + { Transcendental::NaturalLogarithm, 1, largestInt, true, "8802969193111305429598847942518842414558", 38 }, + { Transcendental::NaturalLogarithm, (Rational::Int { 1 } << 126) + 1, Rational::Int { 1 } << 126, false, + "1175494350822287507968736537222245677811", 77 }, + { Transcendental::DecimalLogarithm, Rational::Int { 1 } << 70, 1, false, "2107209969647868366496172263071451187377", 38 }, + { Transcendental::DecimalLogarithm, largestInt, 1, false, "3823080944932561179214483963001061439955", 38 }, + { Transcendental::DecimalLogarithm, 1, largestInt, true, "3823080944932561179214483963001061439955", 38 }, + { Transcendental::Exponential, 1, Rational::Int { 1 } << 64, false, "1000000000000000000054210108624275221701", 39 }, + { Transcendental::Exponential, 1, largestInt, false, "1000000000000000000000000000000000000005", 39 }, + { Transcendental::Exponential, smallestInt, largestInt, false, "3678794411714423215955237701614608674436", 40 }, + { Transcendental::Exponential, 45, 1, false, "3493427105748509534803479723340609953341", 20 }, + { Transcendental::Exponential, 877, 10, false, "1223562231638072508562388385422483006583", 1 }, +``` + + e^88 is **not** a row. Its 40 digits leave one decimal, `...706.9`, and that reference interval straddles a whole number, so the reference cannot decide it at 0 places where the kernel can. The rounded test above pins e^88. + + 4. In `"every reference value is enclosed and rounds as the reference does in every mode"`: + - Loop by index, since an `Int128` has no stream output for `INFO`: `for (std::size_t rowAt = 0; rowAt < references.size(); ++rowAt)` with `Reference const& row = references[rowAt];` and `INFO("row " << rowAt);`. + - Replace the body of `if (!decided.has_value() && referenceDecided.has_value())` with `CHECK(decided.error() == formula::ArithmeticError::Overflow); ++undecided;`. + - `REQUIRE(compared == 2457)` becomes `REQUIRE(compared == 3213)`, with the comment `// 51 rows, 9 places, 7 modes: a loop over nothing fails here.` + - `CHECK(undecided == 38)` and its comment become: + +```cpp + // None: an exponential's 192 fraction bits and a logarithm's 128 decide every row the reference's 40 + // digits decide. At 128 fraction bits the exponentials of 43 to 44 at 17 and 18 places were not. + CHECK(undecided == 0); +``` + + 5. In `"the stored ln 2 and log10(e) are the published values"`, change the two existing pins to `pinned_by_published_digits(detail::Ln2Lower, 128, "...")` and `pinned_by_published_digits(detail::Log10eLower, 128, "...")`, keeping their digits. Then add: + +```cpp + STATIC_REQUIRE(pinned_by_published_digits(detail::Ln2Lower192, 192, + "693147180559945309417232121458176568075500134360255254120680")); + STATIC_REQUIRE(detail::Ln2Upper192 == *detail::add_small_checked_or_none(detail::Ln2Lower192, 1U)); + // The exponential's ln 2 begins with the logarithms': floor(L192 / 2^64) = L128. + STATIC_REQUIRE(detail::shift_right(detail::Ln2Lower192, 64) == detail::Ln2Lower); +``` + + 6. In `"the kernel re-derives its stored constants from its own series"`, `detail::scaled_quotient(1, 3)` becomes `detail::scaled_quotient(detail::UInt128::from_u64(1), detail::UInt128::from_u64(3))`, and the same for `(1, 9)`. Its `unit` / `twoTo256` checks are unchanged. + + 7. Rename `"transcendental kernel: an enclosure is at most 2^-118 wide"` to `"transcendental kernel: an enclosure is at most 2^-120 wide for a logarithm and 2^-183 of the value for an exponential"`: + - Its first comment becomes `// Absolute for the logarithms, whose ends share the denominator 2^128: at most 2^8 units apart (2^-120).` and `// Relative for the exponential: upper - lower at most lower / 2^183, since its lower numerator is at least 2^192.` + - `shift_left_checked_or_none(width, 118)` becomes `shift_left_checked_or_none(width, 183)`. + + 8. Add a new test case before `"the kernel answers at compile time"`: + +```cpp +TEST_CASE("transcendental kernel: a scaled quotient of 128-bit operands carries the bit its remainder shifts out", + "[transcendental]") +{ + // The divisor 2^128 - 1 leaves a remainder above 2^127, whose doubling passes 2^128: the shifted-out bit + // must still count. Checked against the general long division of the 384-bit word, a different route. + detail::UInt128 const divisor { ~std::uint64_t { 0 }, ~std::uint64_t { 0 } }; + detail::UInt128 const dividend { std::uint64_t { 1 } << 63, 5 }; + for (std::size_t const fractionBits: { std::size_t { 128 }, std::size_t { 192 } }) + { + INFO("fraction bits " << fractionBits); + detail::ScaledQuotient const scaled = + fractionBits == 128 ? detail::scaled_quotient<128>(dividend, divisor) : detail::scaled_quotient<192>(dividend, divisor); + detail::WideDivision const reference = detail::divmod( + *detail::shift_left_checked_or_none(Word::from_u128(dividend), fractionBits), Word::from_u128(divisor)); + CHECK(scaled.below == reference.quotient); + CHECK(scaled.exact == reference.remainder.is_zero()); + CHECK_FALSE(scaled.exact); + } + // An exact one: 3 * 2^100 / 2^100 is 3, to the last fraction bit. + detail::UInt128 const twoTo100 { std::uint64_t { 1 } << 36, 0 }; + detail::ScaledQuotient const three = detail::scaled_quotient<192>( + detail::UInt128 { std::uint64_t { 3 } << 36, 0 }, twoTo100); + CHECK(three.exact); + CHECK(three.below == *detail::shift_left_checked_or_none(Word::from_u64(3), 192)); +} +``` + + `WideDivision` and `divmod` are in `detail/wide_int.hpp`. Check their exact names there, and use what the header declares. + +- [ ] **Step 4: Run the tests and see them fail.** + +```powershell +pwsh -NoProfile -File $S\cl.ps1 -Tree $T -Filter "transcendental" +``` + +Expected: a **build failure** naming `ExponentialFractionBits`, `Ln2Lower192` or the new `scaled_quotient<...>` form, which do not exist yet. That is the red state. + +To see the behavioural failures too, comment out the uses of the new names in `transcendental_tests.cpp` temporarily. `rounded_transcendental_tests.cpp` then compiles, and its new cases fail: `ln 2^70`, `exp 45` and `exp 88` answer `Overflow`. Restore the file afterwards with a plain write, not `git checkout`. + +- [ ] **Step 5: Rewrite the kernel's file comment.** In `include/formula-cpp/detail/transcendental.hpp`, replace the `/// @file` block (`:4-56`, from `/// An integer kernel that encloses` through the cost paragraph) with: + +```cpp +/// @file +/// An integer kernel that encloses the natural logarithm, the decimal logarithm and the exponential of a +/// rational: two ends between which the value certainly lies, computed in 384-bit fixed point with only +/// integer operations the language defines exactly, so that no floating-point mode enters and the same +/// inputs are meant to give the same bits, at compile time and at run time. It is what the rounded forms +/// (`rounded_transcendental.hpp`) round: when both ends round to the same decimal, that is the rounding of +/// the value. +/// +/// ## The algorithm and its error bound +/// +/// - **The argument** is a/b in lowest terms, as a `Rational` holds it: |a| <= 2^127 (the magnitude of +/// `Int128`'s minimum) and 0 < b < 2^127. The kernel reads the two magnitudes as `UInt128` and never narrows +/// them. +/// - **Two fixed points.** A value v is an integer V in `WideUnsigned<12>` (384 bits), V = floor(v 2^F): F = +/// 128 fraction bits for a logarithm, whose value is below 89, and F = 192 for an exponential, whose value +/// can be as wide as a `Rational` -- up to 2^127 -- and must still be enclosed more narrowly than its last +/// kept unit. Every operation truncates a non-negative value, so a lower bound stays one; each upper bound +/// is the lower bound plus a slack derived here. No ``, no floating point, no intrinsics, **and no +/// call of the general `divmod`** (too costly in a constant evaluation over 384 bits): the scaled quotients +/// are long divisions over `UInt128` words, whose whole part is `u128_divmod` -- the compiler's own 128-bit +/// integer where it has one, portable code elsewhere, the same quotient either way -- k is a binary search, +/// and the series' divisors are below 2^32 (`divmod_small`). +/// - **ln(a/b)**, a, b > 0, a != b, so both below 2^127. For a < b, ln(b/a) is taken and negated: the sign +/// comes from a < b and never from a rounding. So let a > b. B = b 2^k with B <= a < 2B, so k <= 126 and +/// a + B < 2^128. With z = (a - B)/(a + B), 0 <= z < 1/3, ln(a/b) = k ln 2 + 2 atanh(z), atanh(z) = +/// sum_{i>=0} z^(2i+1)/(2i+1). Z = floor(z 2^128); Z2 = floor(Z^2 / 2^128); P_0 = Z, P_{i+1} = +/// floor(P_i Z2 / 2^128); S = sum floor(P_i / (2i+1)) until P_i = 0. Then S <= atanh(z) 2^128. Deficits: Z2 +/// is below z^2 2^128 by less than 2z + 1 < 2; the deficit d_i of P_i against z^(2i+1) 2^128 obeys d_0 < 1 +/// and d_{i+1} < 2 z^(2i+1) + z^2 d_i + 1, so d_i < 2 throughout; each term is short by less than +/// 1 + d_i/(2i+1); P_i is 0 by i = 41 (z^83 2^128 < 1), after which the tail is below 2.25/(2i+1). So +/// atanh(z) 2^128 - S < 41 + 2 (1 + 1/3 + ... + 1/81) + 2.25/83 < 47 <= `AtanhSlack` = 64. With +/// L = floor(ln 2 2^128): lower = k L + 2S, upper = k (L + 1) + 2 (S + 64) -- k + 128 <= 254 units of +/// 2^-128 apart, under 2^-120, absolute. +/// - **log10** = ln log10(e). With M = floor(log10(e) 2^128): lower = floor(lower_ln M / 2^128), +/// upper = floor(upper_ln (M + 1) / 2^128) + 1, under 254 · 0.44 + 89 + 2 < 203 units apart, since +/// |ln(a/b)| <= ln(2^127) < 89. upper_ln is below 89 · 2^128 + 254 < 2^135 and M + 1 below 2^127, so the +/// widest product, upper_ln (M + 1), is below 2^262. +/// - **exp(x)**, x = a/b != 0, -43 <= x <= 887/10 (the rounded forms answer outside it), in F = 192 bits. +/// X = floor(|x| 2^192), exact or one below. L' = floor(ln 2 2^192). For x > 0, k is the largest integer +/// in [0, 127] with k (L' + 1) <= X (128 ln 2 > 887/10 bounds it), and R = X - k (L' + 1) <= r 2^192 for +/// r = x - k ln 2; for x < 0, m is the smallest in [1, 63] with m L' >= X' (X' = X + 1 when X is inexact; +/// 63 ln 2 > 43 bounds it), R = m L' - X' <= r 2^192 for r = m ln 2 - |x|, and exp(x) = 2^-m exp(r). +/// Either way 0 <= R < L' + 1, so r < ln 2 + 2^-184, and r 2^192 - R < 128: one unit for X, and one per +/// multiple of ln 2's unit, k <= 127 or m <= 63. E = sum T_j, T_0 = 2^192, +/// T_j = floor(floor(T_{j-1} R / 2^192) / j), until T_j = 0 (by j = 43 for r < 0.7; `TaylorTermLimit` = 50 +/// refuses a longer one), so E <= exp(R 2^-192) 2^192 <= exp(r) 2^192. Each T_j is short by +/// e_j < e_{j-1} r / j + 1 < 2, and the tail after the last term is below 3, so +/// exp(R 2^-192) 2^192 - E < 2 · 50 + 3; the 128 units of r add less than 2 · 1.0001 · 128 < 257. So +/// exp(r) 2^192 < E + 360 <= E + `ExponentialSlack` = 512: lower = E 2^k / 2^192, +/// upper = (E + 512) 2^k / 2^192 (for x < 0, denominator 2^(192+m)). Since E >= 2^192, the ends are at +/// most 2^-183 of the value apart: at the largest result a `Rational` holds, 2^127 last kept units, that is +/// 2^-56 of one unit. E < 2^193, so the widest numerator, (E + 512) 2^127, is below 2^321; +/// `decide_rounding`'s scaling by up to 10^18 keeps it below 2^381. +/// - **Undecided.** When the two ends round differently, `decide_rounding` answers `Overflow`: the rounding +/// needs more bits than the kernel holds. The rounded decimal exists, so `Inexact` would be wrong, and a new +/// error enumerator would break `describe()` and consumers' switches. +/// +/// ## What it costs +/// +/// Measured on cl 19.51.36257, whose default constant-evaluation budget measured about 1 049 000 steps: one +/// enclosure costs between and steps (ln 3: ; ln (2^127 - 1) / 2^126: ; log10 7: ; +/// exp 1: ; exp -43: ; exp 88: ), and a whole rounding of log10 2 to 3 places, with +/// `decide_rounding`, about . +``` + + The `<…>` figures in the cost paragraph are the measurements Step 9 takes. Write the measured numbers in their place, rounded to hundreds as the old comment did. No `<` may remain. + +- [ ] **Step 6: The constants, `kernel_word` and `scaled_quotient`.** In `include/formula-cpp/detail/transcendental.hpp`: + + 1. After `inline constexpr std::size_t KernelFractionBits = 128;`, add: + +```cpp +/// How many of an exponential's fixed-point bits are fraction: more than a logarithm's, so that an +/// exponential as wide as a `Rational` holds is still enclosed more narrowly than its last kept unit -- see +/// the file comment. +inline constexpr std::size_t ExponentialFractionBits = 192; +``` + + 2. Change the `/// How many of a fixed-point value's bits are fraction.` comment on `KernelFractionBits` to `/// How many of a logarithm's fixed-point bits are fraction.` + + 3. Change `TaylorTermLimit`: + +```cpp +/// More terms than the exponential's series takes for any r below 0.7 at 192 fraction bits (it ends by the +/// 43rd); reaching it is refused. +inline constexpr std::uint32_t TaylorTermLimit = 50; +``` + + 4. After the existing `kernel_word`, add the three-word overload: + +```cpp +/// @p topWord * 2^128 + @p middleWord * 2^64 + @p bottomWord as a kernel word. 192 bits in 384: no step can +/// overflow. +[[nodiscard]] constexpr KernelWord kernel_word(std::uint64_t topWord, std::uint64_t middleWord, std::uint64_t bottomWord) noexcept +{ + return *add_checked_or_none(*shift_left_checked_or_none(kernel_word(topWord, middleWord), 64), + KernelWord::from_u64(bottomWord)); +} +``` + + 5. After `Log10eUpper`, add: + +```cpp +/// One in an exponential's fixed point, 2^192. +inline constexpr KernelWord ExponentialOne = + *shift_left_checked_or_none(KernelWord::from_u64(1), ExponentialFractionBits); +/// floor(ln 2 * 2^192), the exponential's ln 2: ln 2 lies in [Ln2Lower192, Ln2Upper192] * 2^-192. Checked +/// against its published digits, and against `Ln2Lower`, in `transcendental_tests.cpp`. +inline constexpr KernelWord Ln2Lower192 = + kernel_word(0xB172'17F7'D1CF'79ABULL, 0xC9E3'B398'03F2'F6AFULL, 0x40F3'4326'7298'B62DULL); +/// Ln2Lower192 + 1. +inline constexpr KernelWord Ln2Upper192 = + kernel_word(0xB172'17F7'D1CF'79ABULL, 0xC9E3'B398'03F2'F6AFULL, 0x40F3'4326'7298'B62EULL); +``` + + 6. Replace the `ScaledQuotient` comment and the whole of `scaled_quotient` (`:114-147`): + +```cpp +/// floor(dividend * 2^F / divisor), and whether that is exact. +struct ScaledQuotient +{ + /// The quotient, rounded down. + KernelWord below; + /// Whether nothing was rounded away. + bool exact; +}; + +/// @p dividend * 2^FractionBits / @p divisor, by long division over `UInt128` words: the whole part from +/// `u128_divmod`, then the fraction bits one at a time, gathered 64 to a word. The running remainder stays +/// below the divisor, which is below 2^128; doubled, it can pass 2^128 only when it is then above the +/// divisor, so the bit it shifts out is kept as a carry and the subtraction, taken modulo 2^128, is exact. +/// 128 or 192 fraction bits: the kernel's two fixed points. The whole part is below 2^128, so with 192 +/// fraction bits the quotient stays below 2^320: no step leaves the word. @pre @p divisor != 0. +template + requires(FractionBits % 64 == 0 && FractionBits <= ExponentialFractionBits) +[[nodiscard]] constexpr ScaledQuotient scaled_quotient(UInt128 dividend, UInt128 divisor) noexcept +{ + UInt128Division const split = u128_divmod(dividend, divisor); + UInt128 remaining = split.remainder; + KernelWord quotientSoFar = KernelWord::from_u128(split.quotient); + for (std::size_t wordAt = 0; wordAt < FractionBits / 64; ++wordAt) + { + std::uint64_t fractionWord = 0; + for (int bitAt = 0; bitAt < 64; ++bitAt) + { + bool const carriedOut = (remaining.highWord >> 63) != 0; + remaining = portable::shift_left(remaining, 1); + fractionWord <<= 1; + if (carriedOut || !(remaining < divisor)) + { + remaining = u128_sub(remaining, divisor); + fractionWord |= 1U; + } + } + quotientSoFar = + *add_checked_or_none(*shift_left_checked_or_none(quotientSoFar, 64), KernelWord::from_u64(fractionWord)); + } + return { quotientSoFar, remaining.is_zero() }; +} +``` + + Keep `atanh_series_lower` as it is. In `exponential_series_lower`, change its comment to `/// A lower bound of exp(r) * 2^192 for r = @p fixedArgument * 2^-192 below 0.7 -- see the file comment; ...`. In its body, `KernelOne` becomes `ExponentialOne` (both uses) and `KernelFractionBits` becomes `ExponentialFractionBits`. + +- [ ] **Step 7: The logarithm's reduction over 128 bits.** Replace `natural_log_magnitude` from its first line to the `atanh_series_lower(...)` call: + +```cpp +/// The enclosure of |ln(@p positive)| -- see the file comment. @pre @p positive > 0 and != 1. +[[nodiscard]] constexpr std::optional natural_log_magnitude(Rational positive) noexcept +{ + // A positive Rational's numerator and its denominator are both below 2^127. + UInt128 larger = wide_magnitude(positive.numerator()); + UInt128 smaller = wide_magnitude(positive.denominator()); + LogarithmSign const logarithmSign = larger < smaller ? LogarithmSign::Negative : LogarithmSign::Positive; + if (logarithmSign == LogarithmSign::Negative) + std::swap(larger, smaller); + // B = smaller * 2^doublings <= larger < 2B. Both are below 2^127, so doublings <= 126 and the shift + // stays in 128 bits. + int doublings = larger.bit_width() - smaller.bit_width(); + if (larger < portable::shift_left(smaller, doublings)) + --doublings; + UInt128 const base = portable::shift_left(smaller, doublings); + // z = (a - B) / (a + B), and a + B < 2^128. + std::optional const series = atanh_series_lower( + scaled_quotient(u128_sub(larger, base), u128_add(larger, base)).below); +``` + + The rest of the function, from `if (!series)` on, is unchanged: `multiples` is still `static_cast(doublings)`. + +- [ ] **Step 8: The exponential in 192 bits.** Replace `exponential_enclosure` whole: + +```cpp +/// exp(@p argument), enclosed -- see the file comment. @pre @p argument != 0 and -43 <= @p argument <= 887/10. +[[nodiscard]] constexpr std::optional exponential_enclosure(Rational argument) noexcept +{ + bool const negative = argument.sign() < 0; + // |a| <= 2^127, the minimum's magnitude, and 0 < b < 2^127: both read whole. + ScaledQuotient const fixedMagnitude = scaled_quotient( + wide_magnitude(argument.numerator()), wide_magnitude(argument.denominator())); + std::optional remainderBelow; + std::uint32_t shifts = 0; + if (!negative) + { + // The largest k in [0, 127] with k (L' + 1) <= X: 128 ln 2 > 887/10 >= x. + std::uint32_t below = 0; + std::uint32_t above = 128; + while (below + 1 < above) + { + std::uint32_t const middle = (below + above) / 2; + std::optional const multiple = mul_small_checked_or_none(Ln2Upper192, middle); + if (!multiple) + return std::nullopt; + if (*multiple <= fixedMagnitude.below) + below = middle; + else + above = middle; + } + std::optional const multiple = mul_small_checked_or_none(Ln2Upper192, below); + remainderBelow = multiple ? sub_checked_or_none(fixedMagnitude.below, *multiple) : std::nullopt; + shifts = below; + } + else + { + // The smallest m in [1, 63] with m L' >= X' (X rounded up): 63 ln 2 > 43 >= |x|. + std::optional const roundedUp = fixedMagnitude.exact + ? std::optional { fixedMagnitude.below } + : add_small_checked_or_none(fixedMagnitude.below, 1U); + if (!roundedUp) + return std::nullopt; + std::uint32_t below = 0; + std::uint32_t above = 63; + while (below + 1 < above) + { + std::uint32_t const middle = (below + above) / 2; + std::optional const multiple = mul_small_checked_or_none(Ln2Lower192, middle); + if (!multiple) + return std::nullopt; + if (*multiple >= *roundedUp) + above = middle; + else + below = middle; + } + std::optional const multiple = mul_small_checked_or_none(Ln2Lower192, above); + remainderBelow = multiple ? sub_checked_or_none(*multiple, *roundedUp) : std::nullopt; + shifts = above; + } + if (!remainderBelow) + return std::nullopt; + std::optional const series = exponential_series_lower(*remainderBelow); + std::optional const slacked = series ? add_small_checked_or_none(*series, ExponentialSlack) : std::nullopt; + if (!series || !slacked) + return std::nullopt; + if (!negative) + { + std::optional const nearer = shift_left_checked_or_none(*series, shifts); + std::optional const farther = shift_left_checked_or_none(*slacked, shifts); + if (!nearer || !farther) + return std::nullopt; + return Enclosure { .lower = { .negative = false, .numerator = *nearer, .denominator = ExponentialOne }, + .upper = { .negative = false, .numerator = *farther, .denominator = ExponentialOne } }; + } + std::optional const denominatorPower = shift_left_checked_or_none(ExponentialOne, shifts); + if (!denominatorPower) + return std::nullopt; + return Enclosure { .lower = { .negative = false, .numerator = *series, .denominator = *denominatorPower }, + .upper = { .negative = false, .numerator = *slacked, .denominator = *denominatorPower } }; +} +``` + + Then: + - If `` has no remaining use in the header, remove its include. `std::bit_width` was used only by the old reduction. + - `narrow_to_int64` is no longer called here. Leave it in `checked_int.hpp`, since `band.hpp` calls it. In that file's comment (`:13-17`), drop `, or to the transcendental kernel's 64-bit words (\`narrow_to_int64\`)` from the 64-bit bullet. The bullet then ends `(a rounded root's unit scale, a trace's unit quotient).` + + In `include/formula-cpp/rounded_transcendental.hpp`, inside `namespace detail`, before `rounded_transcendental`, add: + +```cpp + /// The largest argument the exponential's kernel takes, 88.7: below 128 ln 2 = 88.72..., which bounds + /// its reduction (`detail/transcendental.hpp`), and above ln(2^127) = 88.03..., past which e^x leaves the + /// largest `Rational` at every places. Above it the answer is `Overflow` without the kernel. + inline constexpr Rational ExponentialArgumentCap { 887, 10 }; +``` + + In `rounded_transcendental`, `if (argument > Rational { 44 })` becomes `if (argument > ExponentialArgumentCap)`. Replace its doc comment (`:41-52`) with: + +```cpp + /// @p F of @p argument, rounded to @p places decimal places under @p roundingMode -- the correctly + /// rounded decimal of the true value, rational or not. The decision, in order: a logarithm of zero or + /// below is `DomainError`; places outside -18...18 are `Overflow`, as for `checked_round`; a special + /// point -- the only values that can tie -- goes to `checked_round`; the exponential of more than 887/10 + /// is `Overflow` (`ExponentialArgumentCap`: no such value fits a `Rational`), and of less than -43 is + /// below a quarter of the last kept unit at any places accepted, so 0, or one unit under `Ceiling` and + /// `AwayFromZero`; everything else is the kernel's enclosure, rounded by `decide_rounding`, which + /// answers `Overflow` when the kept integer does not fit -- e^88.5, at every places -- and when the two + /// ends round differently. The kernel takes every argument a `Rational` holds. The special points are + /// `RepFunctions`'s, through `transcendental_of`: their value, and `Inexact` elsewhere. +``` + + Replace the three factories' comments: + +```cpp +/// The natural logarithm of `operand`, a dimensionless expression, rounded exactly to `Places` decimal +/// places: `rounded_ln(var / var)`. +/// +/// The integer kernel (`detail/transcendental.hpp`) takes every argument a `Rational` holds: ln (2^127 - 1) +/// is 88.029691931113054295 at 18 places, under `Floor`. A rounding the kernel cannot decide is `Overflow`. +``` + +```cpp +/// The decimal logarithm of `operand`, rounded exactly to `Places` decimal places. +/// +/// As for `rounded_ln`, every argument a `Rational` holds; a power of ten, 10^-38 up to 10^38, is answered +/// exactly before the kernel is asked: log10 10^30 is 30. +``` + +```cpp +/// The exponential of `operand`, rounded exactly to `Places` decimal places. +/// +/// Checked in this order: an argument above 887/10 is `Overflow`, since e^x is then past the largest +/// `Rational`; one below -43 is 0, or one last kept unit under `Ceiling` and `AwayFromZero`, whatever its +/// width, so exp(-2^70) is 0; any other goes to the integer kernel (`detail/transcendental.hpp`), which +/// answers wherever the result fits the declared places -- e^45 to 18 places, e^88 to whole units -- and is +/// `Overflow` where it does not, as e^88.5 is at every places. A rounding the kernel cannot decide is +/// `Overflow` too, never a guess. +``` + +- [ ] **Step 9: Run the kernel tests to green, then measure the constant-evaluation cost.** + +```powershell +pwsh -NoProfile -File $S\cl.ps1 -Tree $T -Filter "transcendental" +``` + +Expected: `ALL OK`. Prove from ctest's count that both files' cases ran: every `[transcendental]` and `[rounded_transcendental]` case, including the two new ones. + + If a table row mismatches, compare it against `$S\laneb\table_sim.py`, which models this exact kernel, before touching a slack. The slacks come from the derivation, not from the table. + + Then measure on cl. In `$S\probe` (outside the worktree), write `probe.cpp`: + +```cpp +#include +#include +#include +constexpr bool probe() +{ + using formula::Rational; +#if PROBE_CASE == 1 + return formula::detail::natural_log_enclosure(Rational { 3 }).has_value(); +#elif PROBE_CASE == 2 + return formula::detail::natural_log_enclosure( + Rational { std::numeric_limits::max(), Rational::Int { 1 } << 126 }) + .has_value(); +#elif PROBE_CASE == 3 + return formula::detail::decimal_log_enclosure(Rational { 7 }).has_value(); +#elif PROBE_CASE == 4 + return formula::detail::exponential_enclosure(Rational { 1 }).has_value(); +#elif PROBE_CASE == 5 + return formula::detail::exponential_enclosure(Rational { -43 }).has_value(); +#elif PROBE_CASE == 6 + return formula::detail::exponential_enclosure(Rational { 88 }).has_value(); +#else + // The compile-time smoke test's whole rounding. + auto const enclosure = formula::detail::decimal_log_enclosure(Rational { 2 }); + return formula::detail::decide_rounding(enclosure->lower, enclosure->upper, formula::DecimalPlaces { 3 }, + formula::RoundingMode::HalfEven) + == Rational { 301, 1000 }; +#endif +} +static_assert(probe()); +int main() { return 0; } +``` + + In a VS developer shell (`& $S\vsdev.ps1`, or the `Launch-VsDevShell.ps1` line from `cl.ps1`), bisect the smallest `/constexpr:steps` for which each case compiles: + +```powershell +foreach ($case in 1..7) { + $low = 1000; $high = 2000000 + while ($low + 100 -lt $high) { + $middle = [int](($low + $high) / 2) + cl /nologo /std:c++latest /permissive- /c /Zs /I"$T\include" /DPROBE_CASE=$case "/constexpr:steps$middle" "$S\probe\probe.cpp" *> $null + if ($LASTEXITCODE -eq 0) { $high = $middle } else { $low = $middle } + } + "case ${case}: $high steps" +} +cl /? 2>&1 | Select-String "Version" +``` + + Record the seven counts and the cl version in the file comment's cost paragraph (Step 5) and in the report. + - **Target:** one enclosure (cases 1-6) at most 100 000 steps. Any case above 100 000 stops the task: report it to the controller with the counts. + - **The rule that binds:** the compile-time smoke test (case 7, and `"transcendental kernel: the kernel answers at compile time"`) must compile under every compiler's **default** budget. If it ever fails, make the kernel cheaper. Never raise a budget: a consumer cannot be asked to. + - Update that test's comment to the measured figure: `// ... measured at about steps on cl 19.51.36257, against a default budget of about 1 049 000.` + - If case 7 moved by more than 10 %, update the `rounded_transcendental_tests.cpp` header's "about a quarter" to match the new figure. + +- [ ] **Step 10: The other three compilers.** The compile-time smoke test compiles inside `formula-cpp-tests`, so build it on clang-cl, then build and test under WSL, where `UInt128` takes its native path: + +```powershell +pwsh -NoProfile -File $S\cl.ps1 -Tree $T -Preset clangcl-debug -Target formula-cpp-tests -NoTest +wsl bash $SW/posix-matrix.sh --tree $TW --presets "gcc-release clang-debug" +``` + +Expected: `BUILD OK (clangcl-debug)`, then `MATRIX OK`. Both g++-14 and clang++ pass every test, so the native and portable `u128_divmod` give the same kernel bits. + +- [ ] **Step 11: The documentation.** + + 1. `docs/expressions.md`: replace the paragraph from `The places are the method's own, and at most 18;` through `as \`rounded<...>(sqrt(x))\` does.` with: + +```markdown +The places are the method's own, and at most 18; the result must fit a +`Rational` there, which any logarithm does: the `log10` of 10^18 - 1 is +reported to all 18 places. The integer kernel takes every argument a +`Rational` holds: `ln` of 2^127 - 1 is 88.029691931113054295 at 18 places, +under `Floor`. `exp` of more than 88.7 is `Overflow`, since e^x is then past +the largest `Rational`; below that it answers wherever the result fits the +declared places -- e^45 to 18 places, e^88 to whole units -- and is +`Overflow` where it does not, as e^88.5 is at every places. Two kinds of +argument never reach the kernel: a power of ten, 10^-38 up to 10^38, is +answered exactly, so `log10` of 10^30 is 30; and `exp` of less than -43 is +0, or one unit under `Ceiling` and `AwayFromZero`, whatever its width. +Only ln 1, log10 10^k and +exp 0 can tie, and the mode breaks the tie as `rounded<>` does: `log10` of +10^15 at -1 places is 20, 10 or 20 under `HalfAwayFromZero`, +`HalfTowardZero` and `HalfEven`. A rounding the computation cannot decide -- +a value within its width of a rounding boundary, under 2^-120 for a +logarithm and under 2^-183 of the value for `exp` -- is `Overflow`, never a +guess. `rounded<...>(ln(x))` is not +`rounded_ln`: the plain logarithm fails before the rounding sees a value, as +`rounded<...>(sqrt(x))` does. +``` + + 2. `docs/numeric-headroom.md`: under "What this does not decide", delete the whole bullet that begins `- **The logarithm and exponential kernel** (\`detail/transcendental.hpp\`)` and ends `width.` Leave the bullets before and after it as they are. + + 3. Check for any other statement of the old limit: + +```powershell +git -C $T grep -nE "fit 64 bits|more than 44|above 44|2\^-118|exp 44" -- docs/*.md README.md include +``` + + Each hit about the transcendental kernel is rewritten to the new limits. Hits about `Band`, `Breakpoint`, `Unit` fields or the `_r` literal are other 64-bit limits, and stay. Expected: no kernel hit is left. + + 4. `CHANGELOG.md`: append to `### Changed` under `## [Unreleased]`: + +```markdown +- **`rounded_ln`, `rounded_log10` and `rounded_exp` take every argument a `Rational` holds.** Their integer kernel + narrowed the argument's numerator and denominator to 64 bits and answered `Overflow` beyond; `ln` of 2^70 is now + 48.5203 at 4 places. `rounded_exp` answers up to 88.7, past which no value fits a `Rational`, wherever the result + fits the declared places: e^45 to 18 places, e^88 to whole units. The exponential is computed with 192 fraction + bits, so a result as wide as a `Rational` is still decided: e^43 to 18 places, `Overflow` before though the result + fits, now answers. +``` + +- [ ] **Step 12: Full verification.** + +```powershell +pwsh -NoProfile -File $S\cl.ps1 -Tree $T -Exclude "^negative\." +``` + +Expected: `ALL OK`, with the total 2 above the previous Lane B head's. The new cases are `"transcendental kernel: a scaled quotient of 128-bit operands carries the bit its remainder shifts out"` and `"rounded_transcendental: an argument as wide as a Rational holds is answered"`; the renamed cases keep the count. `docs.numeric-headroom` and every `docs.*` test pass. The census does not see the kernel, and no census fixture rounds a logarithm or an exponential. If `docs.numeric-headroom` fails anyway, stop and report the diff. + +- [ ] **Step 13: Commit, in two commits.** + +```text +feat: let the logarithm and exponential kernel take 128-bit arguments + +The kernel narrowed an argument's numerator and denominator to 64 bits +and refused wider ones with Overflow, though a Rational holds 128. It +now reads both as 128-bit magnitudes: the scaled quotient is a long +division over 128-bit words, a logarithm's reduction reaches 2^126, and +the exponential's reaches 2^127 under a cap of 88.7, past which no value +fits a Rational. The exponential computes with 192 fraction bits, so +that a result as wide as a Rational is still enclosed within a fraction +of its last kept unit: e^45 at 18 places and e^88 whole now answer. + +Signed-off-by: Christian Parpart +``` + + The first commit stages `include/formula-cpp/detail/transcendental.hpp`, `include/formula-cpp/rounded_transcendental.hpp`, `include/formula-cpp/detail/checked_int.hpp`, `test/transcendental_tests.cpp` and `test/rounded_transcendental_tests.cpp`. The second stages `docs/expressions.md`, `docs/numeric-headroom.md` and `CHANGELOG.md`: + +```text +docs: state the logarithm and exponential limits of a 128-bit kernel + +Signed-off-by: Christian Parpart +``` + + Write each message to a file in `$S` and commit with `git commit -F `. + +--- + +### Task 6: The guides' bit widths pinned by tests (#19) + +Two figures in the guides are measured by nothing: + +- The headroom page's widest intermediates of the exact curve fit, "68 bits … up to 249 of the 256". The census gains a width hook in `LinearLeastSquares::compute_exact`, and the page's least-squares table gains a generated column that replaces the sentence. +- The opaque guide's coefficient widths, 65/73, 93/91 and 130/130. A test computes them with the library's exact fit, and the guide cites it. + +Both figures are right today; the models in `$S\laneb` reproduce them. + +**Files:** +- Modify: `include/formula-cpp/detail/checked_int.hpp` (the census section, `:21-104`): `CensusRole::Wide`, `census_record_width`, `census_note_width`, `FORMULA_CENSUS_NOTE_WIDTH`, and the file comment. +- Modify: `include/formula-cpp/least_squares.hpp`: `LinearLeastSquares::compute_exact` (`:239-317`) notes every wide integer it forms. Include ``. +- Modify: `support/census_tally.hpp`, `support/census_tally.cpp`. +- Modify: `test/overflow_census_tests.cpp`: `Used`, `census_of`, `FitScan`, `scan_rounded_fit`, and the least-squares emission and checks (`:948-1018`). +- Regenerate: `docs/numeric-headroom.md`'s `census:least-squares` table. Hand-edit the prose above it (`:331-340`). +- Modify: `test/least_squares_tests.cpp` (`fifty_readings`, `:1282-1301`, and a new test case); `docs/opaque-and-retry.md:325-328`; `CHANGELOG.md`. + +**Interfaces:** +- Consumes: Task 5's head (this task fast-forwards from it). Nothing from Task 5's code. +- Produces, census builds only (`FORMULA_OVERFLOW_CENSUS`): + - `enum class CensusRole` gains `Wide`; + - `void formula::detail::census_record_width(CensusRole role, std::size_t bitsUsed) noexcept;`, declared by the library and defined in `support/census_tally.cpp`; + - `constexpr void census_note_width(CensusRole, std::size_t) noexcept;` + - `FORMULA_CENSUS_NOTE_WIDTH(role, bitsUsed)`, which expands to nothing, its arguments unevaluated, outside a census build; + - `formula_census::bits_used(CensusRole::Wide)`: the most bits any wide intermediate used since the last reset. + +- [ ] **Step 1: Start the worktree from the lane's head.** + +```powershell +git fetch --all --quiet +git reset --hard feature/open-issues-numerics +git log --oneline -2 +``` + +Expected: Task 5's two commits on top. Never touch `D:\formula-cpp` or another worktree. + +- [ ] **Step 2: Write the failing census checks.** In `test/overflow_census_tests.cpp`: + + 1. `Used` gains a member after `unsignedBits`, with a comment: `/// The most bits a wide intermediate used (`CensusRole::Wide`), 0 when none was formed.` and `int wideBits;`. `census_of` reads it as its fifth initializer: `formula_census::bits_used(CensusRole::Wide)`. Grep the file for any other `Used {` and give each the fifth value. `headroom()` is unchanged: wide integers are not `Rational`'s. + 2. `FitScan` gains: + +```cpp + /// The most bits a wide intermediate of the fit used, over the sizes that answered; empty for a route + /// that computes in `Rational` and forms none. + std::optional widestWide; +``` + + `row()` appends one cell, `" | " + (widestWide.has_value() ? std::to_string(*widestWide) : std::string { "--" })`, before the closing `" |"`. Add `#include ` to the includes if it is absent. + 3. In `scan_rounded_fit`, the `else` branch becomes: + +```cpp + else + { + found.leastHeadroom = std::min(found.leastHeadroom, used.headroom()); + found.widestWide = std::max(found.widestWide.value_or(0), used.wideBits); + } +``` + + 4. In `"census: least squares over 2 to 128 points"`, the two header lines become: + +```cpp + std::string const wideHeader = + "widest fit intermediate (of " + std::to_string(formula::LinearLeastSquares::exact_limbs * 32) + " bits)"; + emit("least-squares", + "| data (invented) | sizes that overflow | first to overflow | least headroom otherwise | " + wideHeader + " |"); + emit("least-squares", "|---|---|---|---|---|"); +``` + + After `CHECK(rounded_fit_node_overflows<58>(distinct_denominators_point));`, add: + +```cpp + // How close the exact fit came to its width, at the sizes that answered: readings at 3 dp use 68 of its + // bits, a different denominator on every point 249. The Rational routes form no wide integer. + CHECK(roundedThree.widestWide == 68); + CHECK(roundedDistinct.widestWide == 249); + CHECK_FALSE(oneDecimal.widestWide.has_value()); + CHECK(formula::LinearLeastSquares::exact_limbs * 32 == 256); +``` + +- [ ] **Step 3: Write the widths test for the opaque guide.** In `test/least_squares_tests.cpp`, replace `fifty_readings` and its comment (`:1282-1301`) with a column helper and the same environment built from it: + +```cpp +// Fifty readings at four decimals: t_k = k + 1 + (7919 k mod 997) / 10^4 s, +// L_k = 2410 + 3.17 k + ((3217 k mod 1009) - 504) / 10^4 mm. At eight, each +// gains (1237 k mod 10^4) / 10^8 s and (4111 k mod 10^4) / 10^8 mm. +// Reference values computed with Python's fractions. +struct FiftyColumns +{ + std::array seconds; + std::array millimetres; +}; + +[[nodiscard]] FiftyColumns fifty_columns(std::int64_t const morePlaces) +{ + FiftyColumns made {}; + for (std::size_t at = 0; at < 50; ++at) + { + auto const position = static_cast(at); + made.seconds[at] = + rat((10'000 * (position + 1) + (7919 * position) % 997) * morePlaces + (1237 * position) % morePlaces, + 10'000 * morePlaces); + made.millimetres[at] = rat((24'100'000 + 31'700 * position + (3217 * position) % 1009 - 504) * morePlaces + + (4111 * position) % morePlaces, + 10'000 * morePlaces); + } + return made; +} + +[[nodiscard]] auto fifty_readings(std::int64_t const morePlaces = 1) +{ + FiftyColumns const made = fifty_columns(morePlaces); + return formula::environment(*formula::MeasuredObservations::from(made.seconds), + *formula::MeasuredObservations::from(made.millimetres)); +} +``` + + After the test case `"a line through fifty readings at eight decimals overflows exactly and answers rounded"`, add: + +```cpp +TEST_CASE("a line through fifty readings at eight decimals: how wide each exact output is", + "[least-squares][observations]") +{ + // The widths docs/opaque-and-retry.md quotes. In coherent units -- seconds and metres -- as the fit sees + // them, each output reduced to lowest terms as a Rational would hold it: the intercept and the slope fit + // 127 bits, R^2 does not, so the call answers Overflow for all its outputs. + FiftyColumns const atEight = fifty_columns(10'000); + std::array metres {}; + for (std::size_t at = 0; at < metres.size(); ++at) + metres[at] = formula::Rational { atEight.millimetres[at].numerator(), atEight.millimetres[at].denominator() * 1000 }; + auto const exact = formula::LinearLeastSquaresOfObservations::compute_exact( + std::span { atEight.seconds }, std::span { metres }); + REQUIRE(exact.has_value()); + auto const bitsOf = [&exact](std::size_t outputAt) { + auto const lowest = formula::detail::reduced((*exact)[outputAt]); + return std::array { lowest.numerator.bit_length(), lowest.denominator.bit_length() }; + }; + // intercept, slope, r squared: numerator bits, then denominator bits. + CHECK(bitsOf(0) == std::array { 93, 91 }); + CHECK(bitsOf(1) == std::array { 65, 73 }); + CHECK(bitsOf(2) == std::array { 130, 130 }); +} +``` + + This test passes as soon as it compiles: it pins today's widths, which `$S\laneb\fifty_widths.py` computes with Python's fractions as 93/91, 65/73 and 130/130. If a figure differs, the test's is the one the guide states (Step 8). Report the difference. + +- [ ] **Step 4: Run and see the census checks fail.** + +```powershell +pwsh -NoProfile -File $S\cl.ps1 -Tree $T -Filter "census|fifty readings" +``` + +Expected: a build failure, since `CensusRole::Wide` does not exist yet. + +- [ ] **Step 5: The width hook.** In `include/formula-cpp/detail/checked_int.hpp`, inside `#if defined(FORMULA_OVERFLOW_CENSUS)`: + + 1. The enum and its comment become: + +```cpp +/// What an integer the overflow census is told of was: a numerator or a +/// denominator handed to `Rational::make`, any other signed intermediate, an +/// unsigned one (`rounded_sqrt`'s, which has 128 bits to use), or an +/// intermediate of a computation in wide integers (`detail/wide_int.hpp`), +/// told as the bits it used. +enum class CensusRole : std::uint8_t +{ + Numerator, + Denominator, + Intermediate, + Unsigned, + Wide, +}; +``` + + 2. After the `std::uint64_t` overload of `census_note`, add: + +```cpp +/// Told how many bits an intermediate of a computation in wide integers used, +/// formed at run time: how near it came to its width. Declared here and +/// defined only by the census program, never by the library. +void census_record_width(CensusRole role, std::size_t bitsUsed) noexcept; + +/// Tells the overflow census that a wide intermediate used @p bitsUsed bits, +/// unless this is a constant evaluation. +constexpr void census_note_width(CensusRole role, std::size_t bitsUsed) noexcept +{ + if !consteval + { + census_record_width(role, bitsUsed); + } +} +``` + + 3. After the census definition of `FORMULA_CENSUS_NOTE`, add: + +```cpp + /// Tells the overflow census that a wide intermediate in @p role (a + /// `CensusRole` enumerator's name) used @p bitsUsed bits. + #define FORMULA_CENSUS_NOTE_WIDTH(role, bitsUsed) \ + ::formula::detail::census_note_width(::formula::detail::CensusRole::role, (bitsUsed)) +``` + + 4. In the `#else` branch, after the empty `FORMULA_CENSUS_NOTE`, add: + +```cpp + /// Nothing: this is not a census build. The arguments are not evaluated. + #define FORMULA_CENSUS_NOTE_WIDTH(role, bitsUsed) static_cast(0) +``` + + 5. Add `#include ` if absent. In the file comment's census paragraph, after `` `Rational::Int` holds real formulas use (`docs/numeric-headroom.md`). ``, add: `` A computation in wide integers that reports itself -- the exact curve fit, `LinearLeastSquares::compute_exact` -- tells it the bits each of its intermediates used, through `FORMULA_CENSUS_NOTE_WIDTH`. `` + + `support/census_tally.hpp`: change `bits_used`'s comment to `/// The bits the largest magnitude of @p role used since the last `reset` -- 0 when none was seen. For `CensusRole::Wide`, the most bits a wide intermediate used. The largest `Rational::Int` uses all but its sign bit; `rounded_sqrt`'s unsigned intermediates may use every bit.` + + `support/census_tally.cpp`: + +```cpp +/// The most bits a wide intermediate used since the last reset. +std::size_t widestWideBits = 0; +``` + + goes after `largestSeen`, in the anonymous namespace. In `namespace formula::detail`, after `census_record`: + +```cpp +void census_record_width(CensusRole role, std::size_t bitsUsed) noexcept +{ + if (role == CensusRole::Wide && widestWideBits < bitsUsed) + widestWideBits = bitsUsed; +} +``` + + `bits_used` begins with `if (role == formula::detail::CensusRole::Wide) return static_cast(widestWideBits);`. `largestSeen` keeps four slots, since `census_record` is never told of `Wide`. `reset` also sets `widestWideBits = 0;`. `signed_bits_used` is unchanged. + + `support/census_report.cpp` is unchanged: the examples table does not report wide integers. + +- [ ] **Step 6: Note the fit's wide integers.** In `include/formula-cpp/least_squares.hpp`, add `#include ` with the other `detail` includes. In `LinearLeastSquares::compute_exact`, add the notes below. Each goes right after the check that proves its value present, so no empty `optional` is ever read: + +```cpp + if (!pointScale || !valueScale) + return std::unexpected { ArithmeticError::Overflow }; + FORMULA_CENSUS_NOTE_WIDTH(Wide, pointScale->bit_length()); + FORMULA_CENSUS_NOTE_WIDTH(Wide, valueScale->bit_length()); +``` + + In the loop, after `if (!withPoint || !withValue || !withSquare || !withProduct) return ...;`. There `withSquare` and `withProduct` prove `squareTerm` and `productTerm` present. + +```cpp + FORMULA_CENSUS_NOTE_WIDTH(Wide, scaledPoint->magnitude.bit_length()); + FORMULA_CENSUS_NOTE_WIDTH(Wide, scaledValue->magnitude.bit_length()); + FORMULA_CENSUS_NOTE_WIDTH(Wide, squareTerm->magnitude.bit_length()); + FORMULA_CENSUS_NOTE_WIDTH(Wide, productTerm->magnitude.bit_length()); + FORMULA_CENSUS_NOTE_WIDTH(Wide, withPoint->magnitude.bit_length()); + FORMULA_CENSUS_NOTE_WIDTH(Wide, withValue->magnitude.bit_length()); + FORMULA_CENSUS_NOTE_WIDTH(Wide, withSquare->magnitude.bit_length()); + FORMULA_CENSUS_NOTE_WIDTH(Wide, withProduct->magnitude.bit_length()); +``` + + After `if (!countedSquares || ... || !pointsByProducts) return ...;`: + +```cpp + FORMULA_CENSUS_NOTE_WIDTH(Wide, countedSquares->magnitude.bit_length()); + FORMULA_CENSUS_NOTE_WIDTH(Wide, squaredSum->magnitude.bit_length()); + FORMULA_CENSUS_NOTE_WIDTH(Wide, countedProducts->magnitude.bit_length()); + FORMULA_CENSUS_NOTE_WIDTH(Wide, crossSum->magnitude.bit_length()); + FORMULA_CENSUS_NOTE_WIDTH(Wide, valuesBySquares->magnitude.bit_length()); + FORMULA_CENSUS_NOTE_WIDTH(Wide, pointsByProducts->magnitude.bit_length()); +``` + + After `if (!pointSpread || !riseTerm || !interceptTerm) return ...;`: + +```cpp + FORMULA_CENSUS_NOTE_WIDTH(Wide, pointSpread->magnitude.bit_length()); + FORMULA_CENSUS_NOTE_WIDTH(Wide, riseTerm->magnitude.bit_length()); + FORMULA_CENSUS_NOTE_WIDTH(Wide, interceptTerm->magnitude.bit_length()); +``` + + After `if (!slopeNumerator || !sharedDenominator) return ...;`: + +```cpp + FORMULA_CENSUS_NOTE_WIDTH(Wide, slopeNumerator->bit_length()); + FORMULA_CENSUS_NOTE_WIDTH(Wide, sharedDenominator->bit_length()); +``` + + Extend `compute_exact`'s doc comment's last paragraph with: `The overflow census (`docs/numeric-headroom.md`) is told the bits each of these integers used.` Only this fit reports. `LinearLeastSquaresOfObservations` and the regression kernel do not: the page measures them by where they overflow. + +- [ ] **Step 7: Run to green.** + +```powershell +pwsh -NoProfile -File $S\cl.ps1 -Tree $T -Filter "census|fifty readings" +``` + +Expected: `ALL OK`. `docs.numeric-headroom` is **not** in this filter, and fails until Step 8, since the table gained a column. + + If `roundedThree.widestWide` or `roundedDistinct.widestWide` differ from 68 and 249, a value was noted twice or missed. Compare each noted value with `$S\laneb\fit_widths.py`'s `note` calls, which mirror Step 6 one for one. + +- [ ] **Step 8: Regenerate the table and rewrite the prose.** + +```powershell +pwsh -NoProfile -File $S\cl.ps1 -Tree $T -Target formula-cpp-census-page -NoTest +git -C $T diff docs/numeric-headroom.md +``` + + Expected diff: only the `census:least-squares` block changes. Its header gains `| widest fit intermediate (of 256 bits) |`, its rule `|---|` one more cell, the first three rows end `| -- |`, and the last two `| 68 |` and `| 249 |`. Every other figure is unchanged. Any other change stops the task: report it. + + Then, in the prose above the table (`:331-340`), replace from `4 decimal places of N/s, computed by \`LinearLeastSquares::compute_exact\` in` to `figure there says nothing of how close the fit came to its 256 bits.` with: + +```markdown +4 decimal places of N/s, computed by `LinearLeastSquares::compute_exact` in +wide integers and rounded exactly; the node is checked against that at 57, +58 and 128 points. For those two rows the fourth column counts `Rational`'s +128-bit integers only, the rounded result and its conversion among them. The +fit itself computes in wider integers, and the last column gives the most +bits any of them used at the sizes that still answer: how near the fit came +to its width, which the column's heading states. The first three rows +compute in `Rational` and form no wide integer. +``` + + Then edit `docs/opaque-and-retry.md:325-328`. `An exact fit through fifty readings at eight decimals does not fit\n\`Rational\`. Computed with Python's fractions, the slope is a fraction of 65\nand 73 bits and the intercept of 93 and 91, which fit, but R² needs 130 bits\nover 130.` becomes: + +```markdown +An exact fit through fifty readings at eight decimals does not fit +`Rational`. The slope is a fraction of 65 and 73 bits and the intercept of 93 +and 91, which fit, but R² needs 130 bits over 130 (`test/least_squares_tests.cpp`, +"a line through fifty readings at eight decimals: how wide each exact output is"). +``` + + If Step 3's test gave other figures, write those instead. Leave the sentence that follows, `` `opaque_output` then answers `Overflow` -- ... ``, as it is. + + `CHANGELOG.md`: append to `### Changed` under `## [Unreleased]`: + +```markdown +- The numeric headroom page's least-squares table gives the most bits the exact curve fit's wide integers used, as + the overflow census measures it, in place of figures no test checked; the opaque-operation guide's widths of an + exact fit's outputs are pinned by a test. +``` + +- [ ] **Step 9: Full verification.** + +```powershell +pwsh -NoProfile -File $S\cl.ps1 -Tree $T -Exclude "^negative\." +``` + +Expected: `ALL OK`, with the total 1 above Task 5's: the case `"a line through fifty readings at eight decimals: how wide each exact output is"`. The census cases are discovered from `formula-cpp-census-tests` and keep their count. `docs.numeric-headroom` and `docs.opaque-and-retry`-style checks pass. Hygiene: `hygiene.headers` is unaffected, since the macro adds no standard header but ``, which is allowed. No new public header. + +- [ ] **Step 10: Commit, in two commits.** + +```text +test: measure how wide the exact curve fit's integers grow + +The overflow census is told the bits every wide integer of +LinearLeastSquares::compute_exact used, and the headroom page's +least-squares table gains a generated column with the widest at the +sizes that answer: 68 of 256 bits on readings at 3 decimal places, 249 +on a different denominator for every point. A test pins the widths of +the exact outputs of the fifty-readings fit the opaque guide quotes. + +Signed-off-by: Christian Parpart +``` + + The first commit stages `include/formula-cpp/detail/checked_int.hpp`, `include/formula-cpp/least_squares.hpp`, `support/census_tally.hpp`, `support/census_tally.cpp`, `test/overflow_census_tests.cpp` and `test/least_squares_tests.cpp`. The second stages `docs/numeric-headroom.md`, `docs/opaque-and-retry.md` and `CHANGELOG.md`: + +```text +docs: quote the exact fit's widths from the census and a test + +Signed-off-by: Christian Parpart +``` + +## Final tasks + +These run on `feature/open-issues` in `D:\formula-cpp`, after Lane A's last task is committed there and Lane B's last task is committed on `feature/open-issues-numerics`. They run one after another, in this order: 7, 8, 9, 10. Task 9 (#13) is deliberately late: it rewrites comments in files both lanes changed, and running it after the merge also catches any label a lane added. + +### Task 7: Merge Lane B and regenerate the census + +The controller runs this task. A conflict it cannot resolve by the rules below, and every gate failure, goes to `sdd-implementer` as a fix round, with the conflicting hunks or the failing log in the brief. No `sdd-reviewer` review: a merge that resolves by these rules adds no code of its own. If a fix round changes code, that round is reviewed as usual. + +**Files:** +- Modify (merge resolution only): `CHANGELOG.md`, and any file both lanes touched. The likely ones are: + - `docs/numeric-headroom.md`: Lane B's table column; Lane A's trace spellings, if the page quotes any; + - `docs/opaque-and-retry.md`: Lane B's pinned widths; Lane A's `kg^-1` or rounding-clause spellings in its trace blocks; + - `docs/expressions.md`; + - `test/opaque_tests.cpp`: Lane A's `coherent_unit_text` cases; Lane B's coefficient-width test, if it was put there. +- Regenerate: `docs/numeric-headroom.md` (census tables), `docs/gallery.md`. + +**Interfaces:** +- Consumes: `feature/open-issues` at Lane A's last commit; `feature/open-issues-numerics` at Lane B's last commit (Task 6). +- Produces: `feature/open-issues` holding both lanes, with the census page and the gallery current. Tasks 8–10 build on this head. + +- [ ] **Step 1: Confirm both lanes are finished.** + In PowerShell, from `D:\formula-cpp`: + + ```powershell + git status --short # must print nothing tracked + git log --oneline -1 feature/open-issues + git log --oneline -1 feature/open-issues-numerics + git merge-base --is-ancestor feature/open-issues feature/open-issues-numerics; $LASTEXITCODE # 1: the lanes diverged, as expected + ``` + + Both heads must be the last commits the lanes' reports name. If the working tree is not clean, stop: a lane left something uncommitted. + +- [ ] **Step 2: Merge with a merge commit.** + + ```powershell + git merge --no-ff --no-commit feature/open-issues-numerics + git status --short + ``` + + Resolve each conflict by these rules, never by taking one side wholesale: + - **`CHANGELOG.md`:** keep every entry from both lanes under `## [Unreleased]`, each in its own `### Added` / `### Changed` subsection. The breaking-change entry for the scaled dimensionless unit stays first under `### Changed`. + - **`docs/numeric-headroom.md`:** take Lane B's version. Step 3 regenerates its tables anyway. If Lane A changed a trace spelling in the page's prose, re-apply that spelling by hand. + - **A guide (`docs/*.md`) both lanes changed:** keep both changes. Where one hunk holds both, apply Lane A's spelling (`kg^-1`, `to N dp of 1/1000 kg`) inside Lane B's text. Step 3's `docs.*-output` tests catch any text block that still disagrees with its program. + - **A test file both lanes changed:** keep both lanes' test cases. + - **`include/formula-cpp/*.hpp`:** the lanes touch different files (Lane A: `trace_render.hpp`, `trace.hpp`, `precision.hpp`, `quantity.hpp`, `render.hpp` and the unit checks; Lane B: `detail/transcendental.hpp`, `rounded_transcendental.hpp`, `detail/least_squares_kernel.hpp`, `detail/checked_int.hpp`). A conflict in a header means a lane strayed. Stop and send it to `sdd-implementer` with both lanes' reports. + + Then write the message to `$S\task7-merge-msg.txt`: + + ``` + Merge 128-bit logarithm arguments and pinned bit widths + + The logarithm and exponential kernel takes 128-bit arguments, and the + bit widths two guides quote are measured by the census and by a test. + + Signed-off-by: Christian Parpart + ``` + + ```powershell + git add -A + git commit -F "$S\task7-merge-msg.txt" + ``` + +- [ ] **Step 3: Regenerate the census page and the gallery.** + + ```powershell + pwsh -NoProfile -File $S\cl.ps1 -Tree D:\formula-cpp -Target formula-cpp-census-page -NoTest + Set-Location D:\formula-cpp + out\build\cl-debug\tools\gallery\formula-cpp-gallery.exe docs\gallery.md + git diff --stat docs/numeric-headroom.md docs/gallery.md + ``` + + `cl.ps1 -Target` loads the Visual Studio environment and builds that one target, and `formula-cpp-census-page` builds the census programs it depends on, then rewrites the page. The gallery executable is built by the same `cl-debug` tree; if it is missing, run `pwsh -NoProfile -File $S\cl.ps1 -Tree D:\formula-cpp -NoTest` first. + + Expected change in `docs/numeric-headroom.md`: + - the least-squares table holds the generated "widest fit intermediate (of 384 bits)" column Lane B added. The figures may differ from Lane B's commit only if Lane A's changes formed different integers. They form none: rendering is not in the census; + - any other census row moves only if a lane's change formed new integers. Lane B's widened kernel is outside `Rational`'s census, so no row should move. **A row that moves is a finding: report it in the commit body, with the row before and after.** + + Expected change in `docs/gallery.md`: only Lane A's spellings (`kg^-1` after a numerator-less coherent unit, a rounding clause naming an unnamed unit by its size). Anything else is a defect: stop and send it to a fix round. + + For each `docs.-output` test that fails in Step 4, run its example (`out\build\cl-debug\examples\.exe`), and replace, in the guide's ```` ```text ```` blocks, exactly the lines that changed with the program's lines. + +- [ ] **Step 4: Run the Windows gate.** + + ```powershell + pwsh -NoProfile -File $S\cl.ps1 -Tree D:\formula-cpp -Exclude "^negative\." + pwsh -NoProfile -File $S\neg.ps1 -Tree D:\formula-cpp -Filter "." + ``` + + Both must print `ALL OK`. The non-negative total must equal: the baseline count, plus every Lane A task's delta, plus every Lane B task's delta. The negative total must equal the baseline negatives plus the negatives both lanes added. A difference is a lost or doubled test: find it before going on. + +- [ ] **Step 5: Commit the regeneration.** + If Step 3 or Step 4 changed any file, write `$S\task7-regen-msg.txt`: + + ``` + docs: regenerate the census page and the gallery + + + + Signed-off-by: Christian Parpart + ``` + + ```powershell + git add docs/numeric-headroom.md docs/gallery.md docs/*.md + git commit -F "$S\task7-regen-msg.txt" + ``` + + Delta: **0 tests** beyond the two lanes' sum. + +### Task 8: `explain` and `checked_explain` refuse a series; two documentation corrections (#11, #21) + +`explain(series, environment)` and `checked_explain(series, environment)` fail today with the compiler's "no matching function". `trace.hpp` has an `explain` and a `checked_explain` for a `Node` and for a `Yields`, and a series is neither, so overload resolution fails before any library check runs. This task adds a `SeriesNode` overload of each that refuses in the library's words and names `explain_series`, exactly as `evaluate`'s `SeriesNode` overloads (`evaluate.hpp:505-536`) refuse with `checked_evaluate_series`. Then it makes the two documentation corrections of #21. Two commits. + +`trace_of`, `trace_of_si` and the `Yields` forms are **not** changed: they keep refusing a series with `RequireSingleValueExpression`'s message, and their negative tests (`trace_of_series_as_single`, `trace_of_si_series_as_single`, `yields_series_explain`) must pass unchanged. + +**Files:** +- Modify: `include/formula-cpp/evaluate.hpp:185-201`: a new check, `detail::RequireSingleValueTraced`, right after `RequireSingleValueExpression`. +- Modify: `include/formula-cpp/trace.hpp`: a `SeriesNode` overload of `explain` after the `Node` overload (`:4588-4603`), and of `checked_explain` after the `Node` overload (`:4729-4745`). +- Create: `test/negative/explain_series_as_single.cpp`, `test/negative/checked_explain_series_as_single.cpp`. +- Modify: `test/CMakeLists.txt`: two `formula_add_negative_test` calls after `yields_series_explain` (`:535-537`). +- Modify: `docs/series.md:47-53`, `docs/tracing.md:223-228`. +- Modify: `README.md:247-252`. +- Modify: `docs/superpowers/specs/2026-10-03-int128-rational-design.md:86-87`. + +**Interfaces:** +- Consumes: `detail::refused_already()` (`expression.hpp:158`), the concepts `SeriesNode` (`expression.hpp:374`) and `Node` (`expression.hpp:42`), `Explained`, `CheckedExplainFailure` (`trace.hpp`). +- Produces: `template struct detail::RequireSingleValueTraced` with `static constexpr bool value = true`. Its message is exactly: + `formula: this expression is a series, not a single value; explain it with explain_series, or reduce it to one value first (sum, interpolate_at)`. + +- [ ] **Step 1: Write the two negative tests.** + `test/negative/explain_series_as_single.cpp`: + + ```cpp + // SPDX-License-Identifier: Apache-2.0 + // EXPECT: this expression is a series, not a single value; explain it with explain_series + // REJECT: no matching + // + // A series handed to explain, which traces a single value. Refused in this + // library's words, pointing at explain_series, the verb that gives a + // series' derivation -- rather than as an overload nobody matched. + #include + #include + + struct Retained: formula::Quantity + { + }; + + inline constexpr auto inputs = + formula::environment(formula::measured_series(formula::Measured { formula::Rational { 130 } }, + formula::Measured { formula::Rational { 210 } }, + formula::Measured { formula::Rational { 95 } })); + + int main() + { + return formula::explain(formula::series, inputs).trace.empty() ? 1 : 0; + } + ``` + + `test/negative/checked_explain_series_as_single.cpp`: the same file with these differences. + - The comment's first sentence reads "A series handed to checked_explain, which traces a single value." + - `main` is: + + ```cpp + int main() + { + return formula::checked_explain(formula::series, inputs).has_value() ? 0 : 1; + } + ``` + +- [ ] **Step 2: Register them with a deliberately wrong text, and watch them fail.** + Append to `test/CMakeLists.txt`, after the `yields_series_explain` registration (`:535-537`): + + ```cmake + # A bare series handed to explain or checked_explain, which trace a single + # value: refused in the library's words, pointing at explain_series, the + # verb that gives a series' derivation, rather than as an overload nobody + # matched. trace_of and the bound forms above keep their own message. + formula_add_negative_test(explain_series_as_single + "explain it with explain_series -- WRONG ON PURPOSE" EXPECT_COUNT 1 + REJECT "no matching") + formula_add_negative_test(checked_explain_series_as_single + "explain it with explain_series -- WRONG ON PURPOSE" EXPECT_COUNT 1 + REJECT "no matching") + ``` + + Run: `pwsh -NoProfile -File $S\neg.ps1 -Tree D:\formula-cpp -Filter "explain_series_as_single|checked_explain_series_as_single"`. + Expected: both FAIL on both presets. The build log names "no matching function" (cl: `C2672`), which is #11's defect. + +- [ ] **Step 3: The check.** + In `include/formula-cpp/evaluate.hpp`, directly after `RequireSingleValueExpression`'s closing `};` (`:201`), add: + + ```cpp + /// Fails to compile when a series (`series.hpp`) is handed to a verb that + /// traces one value -- `explain` or `checked_explain` (`trace.hpp`). The + /// tracing counterpart of `RequireSingleValueExpression`: a caller who + /// asked for a derivation is pointed at `explain_series`, the series verb + /// that gives one. Named so the expression prints. + template + struct RequireSingleValueTraced + { + // A series already refused (`refused`, `series.hpp`) is not asked + // again: its own refusal is the one message for the mistake. + static_assert( + !SeriesNode || requires { requires detail::refused_already(); }, + "formula: this expression is a series, not a single value; explain it with " + "explain_series, or reduce it to one value first (sum, interpolate_at)"); + + static constexpr bool value = true; + }; + ``` + +- [ ] **Step 4: The two overloads.** + In `include/formula-cpp/trace.hpp`, directly after `explain`'s `Node` overload (the one ending `return explained;\n}` at `:4603`), add: + + ```cpp + /// A series handed to `explain`: fails to compile, in this library's words, + /// pointing at `explain_series`, which gives a series' outcome and its + /// derivation. The body is the refusal and nothing else; what it returns is + /// never seen. + template + [[nodiscard]] Explained explain(Expression const&, Env const&, V const& = V {}) + { + static_assert(detail::RequireSingleValueTraced::value); + return Explained {}; + } + ``` + + Directly after `checked_explain`'s `Node` overload (the one ending `return Explained { *checked, std::move(recorded) };\n}` at `:4745`), add: + + ```cpp + /// A series handed to `checked_explain`: refused as `explain` refuses it, + /// pointing at `explain_series`. The body is the refusal and nothing else; + /// what it returns is never seen. + template + [[nodiscard]] std::expected, CheckedExplainFailure> + checked_explain(Expression const&, Env const&, V const& = V {}) + { + static_assert(detail::RequireSingleValueTraced::value); + return Explained {}; + } + ``` + + `Node` and `SeriesNode` are disjoint (a series derives from `SeriesNodeBase`, never `NodeBase`), so the new overloads cannot be ambiguous with the existing ones. + +- [ ] **Step 5: Register the right text and watch both pass.** + In the two registrations, replace `"explain it with explain_series -- WRONG ON PURPOSE"` with: + + ```cmake + "this expression is a series, not a single value; explain it with explain_series" EXPECT_COUNT 1 + ``` + + Run: `pwsh -NoProfile -File $S\neg.ps1 -Tree D:\formula-cpp -Filter "explain_series_as_single|checked_explain_series_as_single|trace_of_series_as_single|trace_of_si_series_as_single|yields_series_explain|evaluate_series_as_single"`. + Expected: `ALL OK`, 6 cases on each preset. `EXPECT_COUNT` is checked only on clang-cl. + +- [ ] **Step 6: Confirm the overloads are what the tests pin.** + Delete the new `explain` overload alone (keep a copy of the file in `$S\trace.hpp.task8`). Re-run the Step 5 command. Expected: `explain_series_as_single` FAILS on both presets with "no matching function" in its log (the generic error is back), and every other case passes. Restore `trace.hpp` with a plain write from the copy. Do the same for the `checked_explain` overload and `checked_explain_series_as_single`. Restore, then re-run the Step 5 command: `ALL OK`. + +- [ ] **Step 7: The guides.** + In `docs/series.md`, replace the paragraph ending at `:53` and its code block: + + ```markdown + compile. Handed to `checked_evaluate`, `evaluate` or `variant`, it is + refused in the library's words: + + ``` + static assertion failed: formula: this expression is a series, not a single value; evaluate it with checked_evaluate_series, or reduce it to one value first (sum, interpolate_at) + ``` + ``` + + with: + + ```markdown + compile. Handed to `checked_evaluate`, `evaluate` or `variant`, it is + refused in the library's words: + + ``` + static assertion failed: formula: this expression is a series, not a single value; evaluate it with checked_evaluate_series, or reduce it to one value first (sum, interpolate_at) + ``` + + Handed to `explain` or `checked_explain`, which trace a single value, it is + refused the same way, pointing at `explain_series`, the verb that gives a + series' outcome together with its derivation: + + ``` + static assertion failed: formula: this expression is a series, not a single value; explain it with explain_series, or reduce it to one value first (sum, interpolate_at) + ``` + ``` + + In `docs/tracing.md`, after the paragraph that begins "`explain_series` and `explain_retry` share the shape" (`:223-228`), add a paragraph: + + ```markdown + A series handed to `explain` or `checked_explain` does not compile: both + trace a single value, and they say so in the library's words, pointing at + `explain_series`. Reduce the series to one value first (`sum`, + `interpolate_at`) to trace that value instead. + ``` + + `docs.series` and `docs.tracing` (if they exist as checked-guide tests) compare only `text` blocks with program output. These blocks are plain, so they are not compared. + +- [ ] **Step 8: Verify.** + - `pwsh -NoProfile -File $S\cl.ps1 -Tree D:\formula-cpp -Exclude "^negative\."` must print `ALL OK`. Delta: **0 tests**. + - The Step 5 negative command must print `ALL OK`. Negative delta: **+2 tests**. + +- [ ] **Step 9: Commit #11.** + First add to `CHANGELOG.md`, under `## [Unreleased]` → `### Changed`: + + ```markdown + - `explain` and `checked_explain` handed a series refuse it in the library's words, pointing at `explain_series`, instead of failing with "no matching function". + ``` + + Then write `$S\task8-commit-1.txt`: + + ``` + fix(trace): refuse a series handed to explain or checked_explain in the library's words + + A series handed to explain or checked_explain failed with the compiler's + "no matching function": each had an overload for a single-value + expression and one for a bound formula, and a series is neither. Each now + has a series overload that refuses it as evaluate refuses one, pointing + at explain_series, the verb that gives a series' derivation. trace_of + and the bound forms refuse a series as before. + + Signed-off-by: Christian Parpart + ``` + + No `Closes` line: the pull request's body closes the issues (Task 10). + + ```powershell + git add include/formula-cpp/evaluate.hpp include/formula-cpp/trace.hpp test/negative/explain_series_as_single.cpp test/negative/checked_explain_series_as_single.cpp test/CMakeLists.txt docs/series.md docs/tracing.md CHANGELOG.md + git commit -F "$S\task8-commit-1.txt" + ``` + +- [ ] **Step 10: The README's duplicate link.** + In `README.md:247-252`, the paragraph reads: + + ```markdown + arguably a more important one. See [the tracing guide](docs/tracing.md) for + the detail. Tracing costs nothing when nobody asks for it: a sink is passed by + value, and the untraced path — `evaluate()`, `checked_evaluate()` — defaults + to one that does nothing, adding no instruction the evaluator would not + already emit once the call inlines, measured on all four compilers this + library targets. See [the tracing guide](docs/tracing.md). + ``` + + Replace it with: + + ```markdown + arguably a more important one. Tracing costs nothing when nobody asks for it: + a sink is passed by value, and the untraced path — `evaluate()`, + `checked_evaluate()` — defaults to one that does nothing, adding no + instruction the evaluator would not already emit once the call inlines, + measured on all four compilers this library targets. See + [the tracing guide](docs/tracing.md). + ``` + + Check: `git grep -c "the tracing guide](docs/tracing.md)" README.md` drops by exactly one from what it printed before the edit. + +- [ ] **Step 11: The 128-bit design document's checked forms.** + The code (`include/formula-cpp/detail/checked_int.hpp:284-320`, `include/formula-cpp/int128.hpp:229-271`) does this: + - `add_checked_or_none` and `sub_checked_or_none` for `Int128` form the sum and the difference on the two words' bit patterns with `u128_add` / `u128_sub`. These are the portable routines on every compiler (`int128.hpp:229-238` calls `portable::add` / `portable::subtract` with no native branch). They detect overflow from the operands' and the result's signs. + - `mul_checked_or_none` multiplies the magnitudes with `u128_mul_checked`. That uses `__builtin_mul_overflow` on `unsigned __int128` where the compiler has it (`FORMULA_NATIVE_INT128`), and the portable `multiply_checked` elsewhere. Then it checks the product against the signed range. + + In `docs/superpowers/specs/2026-10-03-int128-rational-design.md:85-87`, replace: + + ```markdown + - **Checked forms live in `detail/checked_int.hpp`,** beside the 64-bit ones, as `Int128` overloads of + `add_checked_or_none`, `sub_checked_or_none` and `mul_checked_or_none`. Natively they use `__builtin_add_overflow` + and its kin, which are `constexpr` on GCC and Clang. In software they use partial products. + ``` + + with: + + ```markdown + - **Checked forms live in `detail/checked_int.hpp`,** beside the 64-bit ones, as `Int128` overloads of + `add_checked_or_none`, `sub_checked_or_none` and `mul_checked_or_none`. The sum and the difference are formed on + the two words' bit patterns with the portable routines, on every compiler, and an overflow is detected from the + signs: calling `Int128`'s own `+` or `-` first would break their precondition that the exact result fits. Only + the product uses the compiler's checked builtin, `__builtin_mul_overflow` on the unsigned magnitudes, where the + compiler has `__int128`, and partial products in software elsewhere; it is then checked against the signed + range. + ``` + +- [ ] **Step 12: Commit #21.** + `$S\task8-commit-2.txt`: + + ``` + docs: link the tracing guide once, and describe the checked 128-bit add as built + + The README's tracing paragraph linked the tracing guide twice; it now + links it once, at the end. The 128-bit design document said the checked + add and subtract use the compiler's overflow builtins. They compute on + the words' bit patterns with the portable routines on every compiler, and + detect overflow from the signs; only the multiply uses the native checked + builtin. + + Signed-off-by: Christian Parpart + ``` + + ```powershell + git add README.md docs/superpowers/specs/2026-10-03-int128-rational-design.md + git commit -F "$S\task8-commit-2.txt" + ``` + + No CHANGELOG entry: neither change touches the library. + +### Task 9: Self-describing comments in place of development labels (#13) + +More than 80 comments, test names, section banners, one guide sentence and two CMake comments refer to the project's development history: "phase 12", "phase 15's spike, step 9", "spec phase 8", "a spike compiled …". A reader cannot look any of these up. This task rewrites every one so that it states what it relies on in its own words: +- A comment that quotes a measurement keeps the measurement and says where it holds, by compiler and version where the comment or its neighbours already name them, or it points at the test that pins it. +- A reference that adds nothing is dropped. +- A section banner names the feature. +- A test name says what it tests. + +`docs/superpowers/` is out of scope: it records how the work was planned. + +**The search,** which must return nothing when this task is done: + +```bash +git grep -nE '\b[Pp]hase [0-9]+|\bspike\b|\bstep [0-9]+\)' -- include test examples tools docs/*.md README.md cmake CMakeLists.txt +``` + +At `1ed39ed` it returns **139 lines in 51 files** (count with `| wc -l`). Lanes A and B may add a few; Step 6 catches them. Also fix the same kind of label where the search cannot see it, in a sentence that already holds a hit: "This phase adds" (`sink.hpp:209`), "this phase exists to get right" and "this whole phase's central argument" (`examples/CMakeLists.txt:109`, `:116`). + +**Rules for every rewrite.** +- Edit by hand, in the file's own style. Do not run clang-format. +- Keep the sentence grammatical. Where the replacement below gives only the changed phrase, adjust the words around it so the sentence reads. +- **Never invent a fact.** A compiler, a version or a count goes in only where the comment, a neighbouring comment or a test already states it. Otherwise say "measured" without naming more, or point at the test. +- A label inside a Doxygen `///` comment stays a `///` comment. A banner keeps its width: pad with `-` to the same column. +- Spec section numbers ("spec sections 9 and 9.1", "section 16.7's demands") are outside this search and this task. Leave them. + +**Files:** every file in the lists below; nothing else. + +**Interfaces:** +- Consumes: `feature/open-issues` after Task 8. +- Produces: no code change. Two census test cases renamed (Step 4); no build file, script or document refers to their names (checked in Step 4). + +- [ ] **Step 1: Re-run the search and save the list.** + `git grep -nE '\b[Pp]hase [0-9]+|\bspike\b|\bstep [0-9]+\)' -- include test examples tools docs/*.md README.md cmake CMakeLists.txt > $S\task9-before.txt`, then count its lines. Compare with the lists in Steps 2–5. A hit at a different line number is the same hit moved by the lanes: find it by its text. A hit not listed is a lane's addition: rewrite it under the same rules in Step 6. + +- [ ] **Step 2: Public headers, the guide and the CMake comments.** + Each line gives `file:line`, the phrase as it stands, and its replacement. + + - `cmake/CheckInstalledHeaders.cmake:7`: "forgotten -- and it was. Phase 7 added sink.hpp, trace.hpp and trace_render.hpp and listed none of them" → "forgotten -- and it was: sink.hpp, trace.hpp and trace_render.hpp were once added and listed nowhere". + - `docs/quantities.md:186-187`: "a spike compiled the five-parameter spelling with `dim::Mass` paired against `unit::Litre`, and all three compilers accepted the contradiction in silence" → "the five-parameter spelling, with `dim::Mass` paired against `unit::Litre`, compiled without a diagnostic on every compiler it was tried on". + - `examples/CMakeLists.txt:109-116`: "the very outcome words and trace shape this phase exists to get right" → "the very outcome words and trace shape the constraints guide exists to show". "a withdrawn spelling and a stale guide during phase 8" → "a withdrawn spelling and a stale guide". "the not-checked case that is this whole phase's central argument" → "the not-checked case that is the guide's central argument". + - `include/formula-cpp/band.hpp:26`: "and a spike compiled the rejection on all four compilers" → "and the rejection was measured on all four compilers". + - `band.hpp:175`: "a spike compiled `template ` directly" → "`template ` compiles directly" (the sentence goes on "with alias-template deduction, on all four compilers"). + - `band.hpp:177`: "Consumers (phase 10 tasks 2-4) name a table" → "Consumers name a table". + - `band.hpp:185`: "and by any runtime loader (phase 10 tasks 2-4) --" → "and by any runtime loader --". + - `include/formula-cpp/citation.hpp:90`: "only the documentation walk and, from phase 7, the trace sink will notice it" → "only the documentation walk and the trace sink notice it". + - `include/formula-cpp/constraint.hpp:247`: "that shipped a `StepKind::Pi` enumerator shadowing `formula::Pi` in phase 7 and broke GCC alone" → "that once shipped a `StepKind::Pi` enumerator shadowing `formula::Pi` and broke GCC alone". + - `constraint.hpp:252`: "the same problem phase 8 solved for a `WhenNode`'s branch" → "the same problem a `WhenNode`'s branch has, solved". + - `include/formula-cpp/error.hpp:46`: "Spec phase 5 renders this into the `invalid` arm of the evaluation result." → "An evaluation result's `invalid` arm is written with it." + - `include/formula-cpp/lookup.hpp:36`: "A prior spike verified, separately, that" → "It was measured, separately, that". + - `lookup.hpp:70`: "adding one would be phase 9's forbidden `bool satisfied()` in a new costume" → "adding one would be the refused `bool satisfied()` of constraints (`constraint.hpp`) in a new costume". + - `lookup.hpp:92`: "A prior spike proved `InvalidReason::label` is" → "`InvalidReason::label` is, as measured,". + - `lookup.hpp:169`: "`detail::FixedString` (phase 4), which is" → "`detail::FixedString`, which is". + - `lookup.hpp:439`: "Nothing here reaches for phase 8's rounding" → "Nothing here reaches for rounding (`rounding.hpp`)". + - `lookup.hpp:559-560`: "which would make a binary search valid. Phase 10 is the first thing in this codebase to need an interval search at all; a method's own published table" → "which would make a binary search valid. A method's own published table". + - `lookup.hpp:906`: "a spike compiled `template ` with both `Key` and `N` deduced from the template argument, on cl, clang-cl, clang++ and g++" → "`template ` compiles with both `Key` and `N` deduced from the template argument, measured on cl, clang-cl, clang++ and g++". + - `lookup.hpp:1398`: "a spike compiled `template ` with both the element type and `N` deduced on all four compilers" → "`template ` compiles with both the element type and `N` deduced, measured on all four compilers". + - `include/formula-cpp/measured.hpp:60-61`: "Phase 5's expression layer may well want to, and if it does, this is the line to revisit" → "If a later layer needs to, this is the line to revisit". + - `include/formula-cpp/method.hpp:167-169`: "Phase 10 settled that test for lookups -- a lookup *is* a node because it produces a quantity -- and it comes out the other way here." → "The same test makes a lookup a node, because a lookup produces a quantity, and it comes out the other way here." + - `method.hpp:916`: "The same mistake was found, and fixed, in the lookup tables of phase 10." → "The lookup tables were once open to the same mistake, and are checked the same way." + - `method.hpp:1702`: "the same ruling phase 9 made for `bool satisfied()` and phase 10 made for a lookup miss" → "the same ruling as for a constraint's `bool satisfied()` and a lookup miss". + - `include/formula-cpp/opaque.hpp:224`: "(phase 15's spike, step 9)." → "(measured under MathJax 3.2.2 with the site's configuration)." (`render.hpp:1913` names that engine and version for the same spellings). + - `opaque.hpp:1485-1486`: "errors of its own (phase 15's spike, step 7), the behaviour `checked_evaluate_series` records for its refusal" → "errors of its own -- the behaviour `checked_evaluate_series` records for its refusal, and what `opaque_throwing_compute`'s REJECTs pin". + - `include/formula-cpp/overlay.hpp:16`: "A spike that built both shapes settled it:" → "Both shapes were built and compared:". + - `overlay.hpp:2129`: "as it is for any series (phase 12's message)." → "as it is for any series (`RequireSingleValueExpression`'s message)." Check the message the refusal really gives with the existing negative test for this case before writing it; if it is another check's, name that one. + - `include/formula-cpp/precision.hpp:116`: "Named `abs` after a spike:" → "Named `abs` after measuring it:" (the sentence goes on to name cl 19.51, clang-cl and clang++ 22.1.3, g++ 13.3 and 14.2). + - `precision.hpp:341`: "as `DerivedQuantityNode`, and then phase 12's `sum` and elementwise nodes, once did" → "as `DerivedQuantityNode`, and then the series `sum` and elementwise nodes, once did". + - `precision.hpp:569`: "// Phase 12's series kinds." → "// The series kinds (`series.hpp`)." + - `precision.hpp:640`: "// Phase 14's snap and curves:" → "// Snaps and curves (`snap.hpp`, `curve.hpp`):". + - `precision.hpp:668`: "// Phase 12's raw observations, and the classes they are binned into." → "// Raw observations, and the classes they are binned into." + - `include/formula-cpp/quantity.hpp:92-93`: "A spike compiled that spelling with `dim::Mass` against `unit::Litre` and all three compilers accepted it in silence." → "That spelling, with `dim::Mass` against `unit::Litre`, compiled without a diagnostic on every compiler it was tried on." + - `include/formula-cpp/rational.hpp:14`: "rounding is an explicit operation (rounding.hpp) and, from spec phase 8 on, a node in the expression tree." → "rounding is an explicit operation (rounding.hpp) and a node in the expression tree." + - `include/formula-cpp/record.hpp:1577-1578`: "all the same -- measured by phase 14's spike on cl, clang-cl, clang++ and g++." → "all the same -- measured on cl, clang-cl, clang++ and g++." + - `include/formula-cpp/render.hpp:22`: "the defect phase 8 published, when `round[to 1 dp of mm](d)` reached a page" → "a defect once published, when `round[to 1 dp of mm](d)` reached a page". + - `render.hpp:99-100`: "Added in spec phase 8; every other rung keeps its original number." → "Added later than the others, at 0; every other rung keeps its original number." + - `render.hpp:307`: "Chosen by the phase 12 spike: under MathJax 3.2.2" → "Chosen by measurement: under MathJax 3.2.2". + - `render.hpp:329`: "chosen by phase 15's spike (step 9) for the same engines" → "measured under the same engines". + - `render.hpp:1130`: "the spelling a spike typeset clean" → "a spelling measured to typeset clean under MathJax and tectonic". + - `render.hpp:1446`: `// ------------------------------------------------------- phase 10: lookups` → `// ---------------------------------------------------------------- lookups`. + - `render.hpp:1726`: "a spike measured a row whose formula held" → "measured: a row whose formula held". Keep the versions the sentence already names (python-markdown 3.10.3, pymdown-extensions 12.1). + - `render.hpp:1835`: "typeset clean under MathJax 3.2.2 and tectonic by a spike --" → "measured to typeset clean under MathJax 3.2.2 and tectonic --". + - `render.hpp:1913`: "The spellings are phase 15's spike's (step 9), measured under MathJax 3.2.2" → "The spellings were measured under MathJax 3.2.2". + - `render.hpp:2177`: "measured by the phase-11 spike on clang++ 20.1.8" → "measured on clang++ 20.1.8". + - `include/formula-cpp/retry.hpp:144`: "g++ 14.2 (measured in phase 15's spike, step 5)." → "g++ 14.2, as measured." Or drop the parenthesis: the sentence already says "fit one constant evaluation on cl 19.51, …". + - `include/formula-cpp/rounding.hpp:184`: "series needs (spec phase 12)." → "series needs (`snap.hpp`)." + - `include/formula-cpp/sink.hpp:207-209`: "Phase 5 published `checked_evaluate_si(node, environment)` as an extension point: … This phase adds a third parameter," → "`checked_evaluate_si(node, environment)` was published as an extension point: … The sink is a third parameter,". + - `include/formula-cpp/statistics.hpp:10`: "That is a phase 12 series, `series`" → "That is a series (`series.hpp`), `series`". + - `statistics.hpp:71`: "a phase 12 series," → "a series (`series.hpp`),". + - `statistics.hpp:114`: "-- phase 12's `SeriesFailure`," → "-- a series' `SeriesFailure`,". + - `include/formula-cpp/trace.hpp:611`: "Phase 9 refused `bool satisfied()` for exactly this shape of defect:" → "Constraints refuse a `bool satisfied()` (`constraint.hpp`) for exactly this shape of defect:". + - `trace.hpp:1056-1057`: "the identical duplication phase 8 undid when it removed the member it had added to `WhenNode`" → "the identical duplication once undone by removing a member added to `WhenNode`". + - `trace.hpp:4467-4468`: "as phase 12's series paths and phase 13's statistics paths were," → "as the series paths and the statistics paths were,". + - `include/formula-cpp/trace_render.hpp:926-927`: "which is the defect phase 9 refused `bool satisfied()` over" → "which is the defect a constraint's `bool satisfied()` was refused over". + +- [ ] **Step 3: Test comments, banners and CMake comments.** + - `test/CMakeLists.txt:1080`: "sees through phase 12's series kinds" → "sees through the series kinds". + - `test/CMakeLists.txt:2138`: "# Phase 14: records and the context." → "# Records and the context." + - `test/CMakeLists.txt:2316`: "as phase 12's walks answer a refused series" → "as the walks answer a refused series". + - `test/CMakeLists.txt:2372`: "when the evaluator's body was not gated (phase 15's spike, step 7): the REJECTs." → "when the evaluator's body was not gated: the REJECTs." + - `test/band_tests.cpp:255`: "the way phase 10 tasks 2-4 will reach it" → "the way the lookup nodes reach it". + - `test/conformity_tests.cpp:31`, `test/series_tests.cpp:16`: "(see the phase 12 plan)" → drop the parenthesis. + - `test/dimension_cross_tu.hpp:5`: "Phase 1 established that the equivalent trick" → "The equivalent trick". Adjust the verb: "… with `decltype([]{})` gives each TU its OWN type …". + - `test/document_tests.cpp:238`: "Measured while phase 10 added the lookup overloads:" → "Measured when the lookup overloads were added:". + - `document_tests.cpp:487`: banner `phase 10: lookups` → `lookups`, same width. + - `document_tests.cpp:668`: "which is the shape of the defect phase 8 published" → "which is the shape of a defect once published". + - `document_tests.cpp:682`: "which is the property phase 8's published defect violated" → "which is the property that published defect violated". + - `document_tests.cpp:857`: `// ---- A series in the symbol table (phase 12) ----` → `// ---- A series in the symbol table ----`. + - `test/least_squares_tests.cpp:233-234`: "the spike's shape that overflows from 27 points (step 3)." → "a shape that overflows at 27 points, as the test below pins." + - `test/lineage_tests.cpp:356`: "Phase 11's trace escape (`escaped_author_text`)" → "The trace's escape (`escaped_author_text`)". + - `test/measured_tests.cpp:193`: "the same exact conversion phase 3 proved" → "the same exact conversion `unit_tests.cpp` pins". + - `test/method_tests.cpp:166-170`: "What a reachability probe catches is ABSENCE -- phase 9 shipped an overload that worked, was tested, and no user could call; phase 10 shipped a `document()` walk that compiled for nothing. The spike compiled this pack and never ran it, so nothing until now had established that a stored variant still evaluates at all." → "What a reachability probe catches is ABSENCE: an overload that works and is tested but that no user can call, or a `document()` walk that compiles for nothing. Compiling this pack proves nothing about running it, so this establishes that a stored variant still evaluates at all." + - `method_tests.cpp:387`: banner `phase 13: a precision check` → `a precision check`, same width. + - `test/negative/method_tag_names_spelt_alike.cpp:7`: "(final review of phase 11, L3)" → drop the parenthesis. + - `test/negative/method_variants_disagree.cpp:8-9`: "The rule inherited from phase 10 is "put the defect in the middle, …", and phase 10 read "middle" as a middle PAIR of four rows" → "The rule for a band table is "put the defect in the middle, …", and there "middle" means a middle PAIR of four rows". + - `test/negative/opaque_throwing_compute.cpp:9`: "adds none of its own errors (phase 15's spike, step 7) -- the" → "adds none of its own errors -- the". + - `test/negative/overlay_constant_inside_opaque_series.cpp:9`: "the refusal is phase 12's for a series" → "the refusal is the one for a series". + - `test/opaque_tests.cpp:459`: "a hard error on clang, g++-14 and libc++ in phase 11 (defect class 4)" → "a hard error on clang, g++-14 and libc++ once". + - `test/overflow_census_tests.cpp:197`: "// The phase 13 fixtures (rejection_tests.cpp's shared fixtures), in grams." → "// The statistics fixtures (rejection_tests.cpp's shared fixtures), in grams." + - `overflow_census_tests.cpp:317`: `// ---- Least squares (phase 15), a spike's data shapes ----…` → `// ---- Least squares: three data shapes ----…`, same width. + - `overflow_census_tests.cpp:320-321`: "Invented; the spike's offsets are replaced by primes, the rest of its generator kept." → "Invented: each shape's offsets are primes." + - `overflow_census_tests.cpp:951`: "The spike's shapes, through the library's own fit:" → "The three shapes, through the library's own fit:". + - `test/overlay_tests.cpp:810`: "Final review of phase 11, M3: a pinned method" → "A pinned method". + - `overlay_tests.cpp:1399`: "Final re-review of phase 11, M4: every operation" → "Every operation". + - `test/record_join_tests.cpp:363`: "over phase 12's accessors" → "over the series accessors". + - `record_join_tests.cpp:413`: "Phase 12's invented classes and sizes:" → "Invented classes and sizes:". + - `record_join_tests.cpp:427`: "The observations path phase 12 added records its own step;" → "The observations path records its own step;". + - `test/record_statistics_tests.cpp:3`: "Phase 13's statistics, outlier rejections and precision limits" → "Statistics, outlier rejections and precision limits". + - `record_statistics_tests.cpp:49`, `:103`, `:127`: "phase 13's fixture A" / "Phase 13's fixture A" / "Phase 13's fixture P" → "the statistics fixture A" / "The statistics fixture A" / "The statistics fixture P" (`rejection_tests.cpp`'s shared fixtures). + - `test/render_tests.cpp`, banners `:422`, `:491`, `:549`, `:599`, `:767`, `:825`: drop the `phase 8: ` / `phase 9: ` / `phase 10: ` prefix, keeping the feature name and the width: `rounding`, `predicates`, `conditionals`, `numeric_value_of`, `constraints`, `lookups`. + - `render_tests.cpp:570`: "This is the case phase 6's two rendering bugs generalise to:" → "This is the case two earlier rendering bugs generalise to:". + - `render_tests.cpp:638-639`: "// ---- phase 8 fix round 1: nesting a new kind inside another new kind, in every dialect. This is the exact axis review …" → "// ---- nesting one node kind inside another, in every dialect. This is the exact axis a review …", same banner width. + - `render_tests.cpp:1178`: "the operand a published page dropped in phase 8" → "the operand a published page once dropped". + - `render_tests.cpp:1430`: "that is the whole lesson of phase 8, where two renderers each had passing tests" → "that is the whole lesson of the time two renderers each had passing tests". + - `render_tests.cpp:1472-1473`: "// ---- phase 8 fix round 3: guard against the whole class of bug review round 3 found, not just this one" → "// ---- guard against the whole class of bug, not just one instance of it", same width. + - `render_tests.cpp:1560-1561`: "// Phase 13: a bare `|`. … -- a spike measured a row whose formula held" → "// A bare `|`. … -- measured: a row whose formula held". + - `render_tests.cpp:1568`: "// Phase 10 round 2: an asterisk." → "// An asterisk." + - `render_tests.cpp:1668`: "// Phase 10's three lookup kinds." → "// The three lookup kinds." + - `render_tests.cpp:1684`: "// Phase 14: a read from another record," → "// A read from another record,". + - `render_tests.cpp:1836`: "(phase 12)" → drop, same width. + - `render_tests.cpp:1867`: "(typeset clean in a spike)" → "(measured to typeset clean)". + - `test/retry_tests.cpp:369`: "If phase 14 or any later change adds a member" → "If a change adds a member". + - `test/sink_tests.cpp:116`: "written against the extension point as phase 5 published it:" → "written against the extension point as first published:". + - `sink_tests.cpp:190`: "Threading a sink through every overload in phase 7 could have cost that" → "Threading a sink through every overload could have cost that". + - `sink_tests.cpp:225`: "so this covers the parameter phase 7 added" → "so this covers the sink parameter". + - `test/statistics_tests.cpp:288`: "is refused where it is written (phase 12)," → "is refused where it is written (`series.hpp`),". + - `test/trace_render_tests.cpp:311`: banner `phase 8` → `rounding, predicates and conditionals`, same width. Read the tests under it first and name what they cover if that list is wrong. + - `trace_render_tests.cpp:681`, `:1353`: "Phase 8 shipped exactly this shape of defect" → "This shape of defect once shipped". + - `trace_render_tests.cpp:869`: banner `phase 10: lookups` → `lookups`. + - `trace_render_tests.cpp:1173`: "Phase 9's `[not checked]` against `[else]` is the precedent:" → "A constraint's `[not checked]` against `[else]` is the precedent:". + - `trace_render_tests.cpp:1861`: "Final re-review of phase 11, L3: each escaped today, and no test said so." → "Each is escaped, and these tests say so." + - `trace_render_tests.cpp:2016`: "(phase 12)" → drop. + - `trace_render_tests.cpp:2022`: "The shared fixture of the phase 12 plan:" → "The shared series fixture:". + - `trace_render_tests.cpp:2621`: "Phase 11's escaping reaches the series lines" → "The trace's escaping reaches the series lines". + - `test/trace_tests.cpp:367`: banner `phase 8` → the feature its tests cover (read them: rounding, predicates and conditionals), same width. + - `trace_tests.cpp:748`: banner `phase 10: lookups` → `lookups`. + - `trace_tests.cpp:1808`: "(phase 12)" → drop. + - `test/unit_cross_tu.hpp:12-13`: "and phase 4's Quantity is the consumer that will depend on this holding" → "and `Quantity<…, Unit>` is the consumer that depends on this holding". + - `test/unit_tests.cpp:92`: `// ---- a Unit is a template argument, which is what phase 4 needs ----` → `// ---- a Unit is a template argument, which is what Quantity needs ----`. + - `unit_tests.cpp:1000-1001`: "and phase 4's Quantity is the consumer that will depend on it" → "and `Quantity<…, Unit>` is the consumer that depends on it". + - `test/vocabulary_tests.cpp:701`: "// Phase 13's kinds, added rather than multiplied in:" → "// The statistics kinds, added rather than multiplied in:". + - `vocabulary_tests.cpp:1327`, `:1396`: "(phase 12)" → drop, same width. + - `vocabulary_tests.cpp:1398`: "Phase 11's lesson: two separately verified things do not verify their join." → "Two separately verified things do not verify their join." + +- [ ] **Step 4: Rename the two census test cases.** + In `test/overflow_census_tests.cpp`: + - `:709`: `TEST_CASE("census: phase 13's fixtures", "[census]")` → `TEST_CASE("census: statistics, outlier rejections and spreads over the six fixtures", "[census]")`. + - `:742`: `TEST_CASE("census: phase 12's cumulative sums and interpolation", "[census]")` → `TEST_CASE("census: cumulative sums and interpolation", "[census]")`. + + Then prove nothing refers to the old names: + + ```bash + git grep -n "phase 13's fixtures\|phase 12's cumulative" -- . ':!docs/superpowers' + ``` + + Expected: no output. Two facts make the rename safe: + - the census program is run whole by `docs.numeric-headroom` and `census.*`, never by test name; + - its tables are keyed by `emit("statistics", …)` and the row labels, which do not change. + +- [ ] **Step 5: Check the search, and the words the search cannot see.** + + ```bash + git grep -nE '\b[Pp]hase [0-9]+|\bspike\b|\bstep [0-9]+\)' -- include test examples tools docs/*.md README.md cmake CMakeLists.txt + git grep -niE '\bthis phase\b|\bphase-[0-9]+|\bfix round [0-9]|\breview round [0-9]|final (re-)?review of' -- include test examples tools docs/*.md README.md cmake CMakeLists.txt + ``` + + Both must print nothing. The second search catches labels the issue's pattern misses. Fix each hit it prints under the same rules. + +- [ ] **Step 6: The lanes' additions.** + Any hit from Step 1 that Steps 2–4 did not list is a lane's addition. Rewrite it under the same rules, then re-run Step 5. List each such hit and its replacement in the report. + +- [ ] **Step 7: Verify.** + - `pwsh -NoProfile -File $S\cl.ps1 -Tree D:\formula-cpp -Exclude "^negative\."` must print `ALL OK`. Delta: **0 tests**. The census runs, and `docs.numeric-headroom` passes: no row label changed. + - `pwsh -NoProfile -File $S\neg.ps1 -Tree D:\formula-cpp -Filter "method_tag_names_spelt_alike|method_variants_disagree|opaque_throwing_compute|overlay_constant_inside_opaque_series"` must print `ALL OK`. Only comments changed, so this run proves the files still compile to the same refusal. + - `git diff --stat master...HEAD -- include` lists the headers. `git diff -U0 HEAD~1 -- include test cmake examples docs | grep '^[+-]' | grep -v '^[+-]\s*\(//\|#\|///\)' | grep -v '^+++\|^---'` must print only the two `TEST_CASE` lines of Step 4 and lines of `docs/quantities.md`: no code changed. + +- [ ] **Step 8: Commit.** + `$S\task9-commit.txt`: + + ``` + docs: state what each comment relies on instead of naming development history + + Comments, test names and section banners referred to stages of the + project's development and to one-off experiments by labels a reader + cannot look up. Each now says what it relies on: a measurement keeps its + compilers and versions or points at the test that pins it, a banner + names its feature, and a reference that added nothing is gone. Two census + test cases are renamed after what they cover. + + Signed-off-by: Christian Parpart + ``` + + ```powershell + git add -A include test examples cmake docs/quantities.md + git commit -F "$S\task9-commit.txt" + ``` + + No CHANGELOG entry: nothing a consumer sees changes. + +### Task 10: Finish + +The controller runs this task. Fix rounds go to `sdd-implementer`; the whole-branch review and each re-review go to `sdd-reviewer`. + +**Files:** none of its own; fix rounds touch what their findings name. + +**Interfaces:** +- Consumes: `feature/open-issues` after Task 9. +- Produces: the pull request, CI green, marked ready for review. + +- [ ] **Step 1: Copy the documentation script.** + `docs-pages.sh` is still in the earlier scratchpad. Copy it into `$S`, and point its default tree at `D:\formula-cpp`: + + ```bash + cp /c/Users/c.parpart/AppData/Local/Temp/claude/D--formula-cpp/e2d32a6f-b4c6-5a36-8b79-7a082a83081b/scratchpad/docs-pages.sh "$S_BASH/docs-pages.sh" + ``` + + `$S_BASH` is `/c/Users/c.parpart/AppData/Local/Temp/claude/D--formula-cpp/81d1061b-b25f-4c83-9f67-664a67264017/scratchpad`. Under WSL the same directory is `/mnt/c/Users/c.parpart/AppData/Local/Temp/claude/D--formula-cpp/81d1061b-b25f-4c83-9f67-664a67264017/scratchpad`. Always pass `--tree`; never rely on the defaults, which name the earlier worktree. + + `windows-matrix.ps1` calls `cl.ps1` from the earlier scratchpad (`$scratch` at its line 5). That copy is identical to `$S\cl.ps1`, so the script works as it is. Pass `-Tree D:\formula-cpp` every time. + +- [ ] **Step 2: Whole-branch review.** + Dispatch `sdd-reviewer` over `master..feature/open-issues`. Its brief names: + - the spec, `docs/superpowers/specs/2026-10-04-open-issues-design.md`; + - this plan's Global Constraints and Review Focus; + - every task report under `D:\formula-cpp\.superpowers\sdd\2026-10-04-open-issues\`. + + It reviews the whole diff, not task by task. It checks in particular: + - every acceptance criterion of the ten issues; + - the breaking-change entry for the scaled dimensionless unit; + - that no public text names a task, lane, plan, reviewer or local note; + - that the pinned refusals of #20 that became answers were checked against an independent computation. + + Each finding goes to a fix round (`sdd-implementer`, with the finding and the reviewer's evidence). Then a scoped `sdd-reviewer` re-review of the fix, until no finding is open. Record each finding and its outcome in `$S\task10-review.md`. + +- [ ] **Step 3: All eight presets.** + + ```powershell + pwsh -NoProfile -File $S\windows-matrix.ps1 -Tree D:\formula-cpp + ``` + + Must print `MATRIX OK`: cl-debug, cl-release, clangcl-debug, clangcl-release. + + ```powershell + wsl bash /mnt/c/Users/c.parpart/AppData/Local/Temp/claude/D--formula-cpp/81d1061b-b25f-4c83-9f67-664a67264017/scratchpad/posix-matrix.sh --tree /mnt/d/formula-cpp + ``` + + Must print `MATRIX OK`: gcc-release with g++-14, and clang-debug, clang-release and clang-ubsan with clang++-20. Run it in the foreground (it takes a while; use the Bash tool's longest timeout, or `run_in_background` and wait for its completion notice). + + The negative tests run inside each preset's `ctest`. Their `EXPECT_COUNT` is checked only off MSVC, so clang-cl, g++ and clang++ check the new counts. + + Each failure goes to a fix round with the failing preset's log (`D:\formula-cpp\out\matrix\.log`, or `out/build/-wsl.*.log`). Then this step again, every preset. + +- [ ] **Step 4: Doxygen and the site.** + + ```powershell + wsl bash /mnt/c/Users/c.parpart/AppData/Local/Temp/claude/D--formula-cpp/81d1061b-b25f-4c83-9f67-664a67264017/scratchpad/docs-pages.sh --tree /mnt/d/formula-cpp + ``` + + Must print `DOXYGEN OK`: warnings fail the `formula-cpp-docs-api` target. + + ```powershell + Set-Location D:\formula-cpp + python -m mkdocs build --strict --site-dir out\mkdocs-site + ``` + + Must exit 0 with no warning. `--site-dir` keeps the build out of the tracked tree. + +- [ ] **Step 5: The pull request.** + Push `feature/open-issues` (`git push -u origin feature/open-issues`). Open it as a draft with `contour-workflows:draft-pr`, so CI runs before it asks for review. + - **Title:** `Trace units for unnamed and inverse units, 128-bit logarithm arguments, and the series refusal for explain`. + - **Body**, written for a reader of the repository: + + ```markdown + ## Traces + + - **A scaled dimensionless unit must have a symbol.** A dimensionless quantity in a unit with a scale and no symbol + (hundredths, say) traced as a bare number in that scale: one half read `50`. Such a unit is now refused where it is + declared. **Breaking:** give the unit a symbol (for example `"%"`), or declare the quantity in scale 1. + - **A rounding names the unit its places count in.** `round(#1, to 2 dp)` on a quantity in a unit with no symbol now + reads `round(#1, to 2 dp of 1/1000 kg)`, in the trace and in the rendered formula alike. + - **An inverse unit cannot be misread as part of a fraction.** A coherent unit with nothing above the slash is + written with negative exponents: `20000/413 kg^-1`, not `20000/413 1/kg`. Units with a numerator keep the slash + (`m/s`). + - **A precision limit's first pass reads in its level's unit.** A constant level declared in grams reads in grams on + both lines, not in kilograms on the second. + - A snap's "on a permitted value" test now explains why it compares the stored pairs, and a missed lookup no longer + spells a bound it never writes. + + ## Numerics + + - **`rounded_ln`, `rounded_log10` and `rounded_exp` take 128-bit arguments.** The kernel narrowed its argument to 64 + bits and refused anything wider with `Overflow`; `ln 2^70` now answers. `rounded_exp`'s cap moves from 44 to + 887/10. Below the cap, whether an answer fits is decided by the result: e^88 at 0 places is answered. + - **The bit widths two guides quote are measured.** The least-squares fit's widest intermediate is a generated column + of the headroom page's census table, and the guide's figure for the fit's wide integers now reads 384 bits, from + the constant. The opaque-operation example's coefficient widths are pinned by a test. + + ## Diagnostics and documentation + + - `explain` and `checked_explain` handed a series refuse it in the library's words, pointing at `explain_series`, + instead of failing with "no matching function". + - Comments, test names and section banners say what they rely on, not which stage of development produced them. + - The README links the tracing guide once, and the 128-bit design document describes the checked add and subtract + as built. + + Closes #11 + Closes #13 + Closes #14 + Closes #15 + Closes #16 + Closes #17 + Closes #18 + Closes #19 + Closes #20 + Closes #21 + ``` + + Before pushing, check the body's figures against the branch: + - the exp cap (887/10); + - the kernel width (384); + - that `e^88 at 0 places` is answered by a test. + + Change any figure the branch does not support. + +- [ ] **Step 6: CI.** + Watch the run (`gh pr checks --watch`). Every job must pass: the Windows, Linux and macOS (AppleClang) legs, install-and-consume, and the pages build. A failure goes to `contour-workflows:fix-ci` or a fix round, then a new push. Re-run the affected local preset first if the failure is a compiler the local matrix also covers. + +- [ ] **Step 7: Ready for review.** + When CI is green on the pushed head: `gh pr ready`. The owner reviews and merges, with a merge commit, which closes the ten issues. **Never merge it.** diff --git a/docs/superpowers/specs/2026-10-04-open-issues-design.md b/docs/superpowers/specs/2026-10-04-open-issues-design.md index ddf8712..cdcc353 100644 --- a/docs/superpowers/specs/2026-10-04-open-issues-design.md +++ b/docs/superpowers/specs/2026-10-04-open-issues-design.md @@ -184,7 +184,7 @@ narrow. - **log10:** - |ln(a/b)| < ln 2^127 < 89. - The ends lie under 254 · 0.44 + 89 + 2 < 203 units apart. - - The widest product, upper_ln · (M + 1), is below 2^264. + - The widest product, upper_ln · (M + 1), is below 2^262. - **exp(x):** - The rounded form refuses x > 887/10 with `Overflow`. 887/10 is below 128 ln 2 ≈ 88.72, which bounds the reduction's k by 127, and it replaces the literal 44 (`rounded_transcendental.hpp:79`). @@ -193,9 +193,18 @@ narrow. - e^88 at 0 places fits; - e^88.5 does not fit at any allowed number of places and refuses with `Overflow`. - The −43 lower end stays: below it the value rounds to 0 at every allowed number of places. - - X = floor(|x| 2^128) comes from the widened `scaled_quotient`. - - The widest numerator becomes (E + 512) · 2^127 < 2^257. `decide_rounding` scales it by up to 10^18, keeping it - under 2^317. So `KernelLimbs` stays 12, and the comment on it states the new widths. + - **The exponential computes with 192 fraction bits**, not 128. Near 2^127, 128 fraction bits leave the + enclosure up to 2^8 last units wide, so e^45 at 18 places and e^88 at 0 places could never be decided: every + answer would be `Overflow`. With 192 bits the worst case is 2^-56 of a last kept unit, the margin the 64-bit + kernel had. + - ln 2 is carried to 192 bits. + - `TaylorTermLimit` becomes 50, since the series ends by its 43rd term. + - `ExponentialSlack` stays 512, since 360 is needed. + - The logarithms stay at 128 fraction bits. + - X = floor(|x| 2^192) comes from the widened `scaled_quotient`. Its error is 128 units, not 64, because k now + reaches 127. + - The widest numerator is below 2^321. `decide_rounding` scales it by up to 10^18, keeping it under 2^381. So + `KernelLimbs` stays 12, and the comment on it states the new widths. - **The file comment's derivation** is rewritten for these bounds. Every number in it is either derived there or pinned by a test. - **Cost.** @@ -234,22 +243,24 @@ narrow. - the least-squares fit's widest intermediates, `docs/numeric-headroom.md:336-338`; - the opaque example's coefficient widths, `docs/opaque-and-retry.md:327-328`. -The headroom figure already names the wrong width: it says "256-bit", but a line's fit computes in -`regression_limbs(1)` = 12 limbs, 384 bits (`detail/least_squares_kernel.hpp:87-94`). +The headroom page's least-squares rows come from `LinearLeastSquares::compute_exact`, which computes in +`LinearLeastSquares::exact_limbs` = 8 limbs, 256 bits. The 12-limb kernel belongs to the fit over observations, +which those rows do not use. **Design: the fit kernel's widest intermediate.** - The census hook (`FORMULA_CENSUS_NOTE`, `detail/checked_int.hpp`) gains a width form, `census_record_width(CensusRole, std::size_t bits)`. -- The fit kernel calls it with `bit_length()` of every wide sum, centred sum and solve product it forms. +- `LinearLeastSquares::compute_exact` calls it with `bit_length()` of every wide sum, centred sum and solve product + it forms. - The hook keeps its existing guarantee: outside the census program it expands to nothing, and its arguments are never evaluated. - The census program (`support/census_tally.cpp` and the generator of the headroom page's tables) records the widest value per fixture. - The least-squares table gains a generated column, "widest fit intermediate (of N bits)", for the rows the exact - kernel computes. N is generated from `regression_limbs(1) · 32`, never written by hand. -- The hand-written "68 bits … up to 249 of the 256" sentence and the "256-bit" wording go. The prose names the - width through the generated table. + kernel computes. N is generated from `LinearLeastSquares::exact_limbs · 32`, never written by hand. +- The hand-written "68 bits … up to 249 of the 256" sentence goes. The prose names the width through the generated + table. - `docs.numeric-headroom` already fails when the generated tables drift, so the column is pinned by that test. **Design: the opaque example's coefficients.** From 0ed54e3a5c1d83d32d84ddd031d81c8cf6bb523c Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Sun, 4 Oct 2026 23:48:20 +0200 Subject: [PATCH 03/35] feat: let the logarithm and exponential kernel take 128-bit arguments The kernel narrowed an argument's numerator and denominator to 64 bits and refused wider ones with Overflow, though a Rational holds 128. It now reads both as 128-bit magnitudes: the scaled quotient is a long division over 128-bit words, a logarithm's reduction reaches 2^126, and the exponential's reaches 2^127 under a cap of 88.7, past which no value fits a Rational. The exponential computes with 192 fraction bits, so that a result as wide as a Rational is still enclosed within a fraction of its last kept unit: e^45 at 18 places and e^88 whole now answer. Signed-off-by: Christian Parpart --- include/formula-cpp/detail/checked_int.hpp | 3 +- include/formula-cpp/detail/transcendental.hpp | 229 ++++++++++-------- .../formula-cpp/rounded_transcendental.hpp | 42 ++-- test/rounded_transcendental_tests.cpp | 104 ++++++-- test/trace_render_tests.cpp | 6 +- test/transcendental_tests.cpp | 146 ++++++++--- 6 files changed, 349 insertions(+), 181 deletions(-) diff --git a/include/formula-cpp/detail/checked_int.hpp b/include/formula-cpp/detail/checked_int.hpp index 2d82a41..0128d1e 100644 --- a/include/formula-cpp/detail/checked_int.hpp +++ b/include/formula-cpp/detail/checked_int.hpp @@ -13,8 +13,7 @@ /// that rounding's decimal places and `from_decimal`'s exponent span, the /// `_r` literal's mantissa, and narrowing a value to the 64-bit fields of /// `Band`, `Breakpoint` and a `Unit`'s magnitude (a rounded root's unit -/// scale, a trace's unit quotient), or to the transcendental kernel's -/// 64-bit words (`narrow_to_int64`). MSVC has no __builtin_*_overflow, and +/// scale, a trace's unit quotient). MSVC has no __builtin_*_overflow, and /// its equivalents are not constexpr, so these checks are /// written in portable C++ and used on every compiler. Optimisers /// recognise these idioms. diff --git a/include/formula-cpp/detail/transcendental.hpp b/include/formula-cpp/detail/transcendental.hpp index 9433360..0bbffe2 100644 --- a/include/formula-cpp/detail/transcendental.hpp +++ b/include/formula-cpp/detail/transcendental.hpp @@ -11,39 +11,49 @@ /// /// ## The algorithm and its error bound /// -/// - **Fixed point.** A value v is an integer V in `WideUnsigned<12>` (384 bits) with 128 fraction bits, -/// V = floor(v 2^128). Every operation truncates a non-negative value, so a lower bound stays one; each -/// upper bound is the lower bound plus a slack derived here. No ``, no floating point, no -/// intrinsics, no 128-bit type, **and no call of the general `divmod`** (too costly in a constant evaluation -/// over 384 bits): the two scaled quotients are 64-bit long divisions, k is a binary search, and the -/// series' divisors are below 2^32 (`divmod_small`). -/// - **ln(a/b)**, a, b > 0, a != b. For a < b, ln(b/a) is taken and negated: the sign comes from a < b and -/// never from a rounding. So let a > b. B = b 2^k with B <= a < 2B, so k <= 62 and a + B < 2^64. With -/// z = (a - B)/(a + B), 0 <= z < 1/3, ln(a/b) = k ln 2 + 2 atanh(z), atanh(z) = sum_{i>=0} z^(2i+1)/(2i+1). -/// Z = floor(z 2^128); Z2 = floor(Z^2 / 2^128); P_0 = Z, P_{i+1} = floor(P_i Z2 / 2^128); -/// S = sum floor(P_i / (2i+1)) until P_i = 0. Then S <= atanh(z) 2^128. Deficits: Z2 is below z^2 2^128 -/// by less than 2z + 1 < 2; the deficit d_i of P_i against z^(2i+1) 2^128 obeys d_0 < 1 and -/// d_{i+1} < 2 z^(2i+1) + z^2 d_i + 1, so d_i < 2 throughout; each term is short by less than +/// - **The argument** is a/b in lowest terms, as a `Rational` holds it: |a| <= 2^127 (the magnitude of +/// `Int128`'s minimum) and 0 < b < 2^127. The kernel reads the two magnitudes as `UInt128` and never narrows +/// them. +/// - **Two fixed points.** A value v is an integer V in `WideUnsigned<12>` (384 bits), V = floor(v 2^F): F = +/// 128 fraction bits for a logarithm, whose value is below 89, and F = 192 for an exponential, whose value +/// can be as wide as a `Rational` -- up to 2^127 -- and must still be enclosed more narrowly than its last +/// kept unit. Every operation truncates a non-negative value, so a lower bound stays one; each upper bound +/// is the lower bound plus a slack derived here. No ``, no floating point, no intrinsics, **and no +/// call of the general `divmod`** (too costly in a constant evaluation over 384 bits): the scaled quotients +/// are long divisions over `UInt128` words, whose whole part is `u128_divmod` -- the compiler's own 128-bit +/// integer where it has one, portable code elsewhere, the same quotient either way -- k is a binary search, +/// and the series' divisors are below 2^32 (`divmod_small`). +/// - **ln(a/b)**, a, b > 0, a != b, so both below 2^127. For a < b, ln(b/a) is taken and negated: the sign +/// comes from a < b and never from a rounding. So let a > b. B = b 2^k with B <= a < 2B, so k <= 126 and +/// a + B < 2^128. With z = (a - B)/(a + B), 0 <= z < 1/3, ln(a/b) = k ln 2 + 2 atanh(z), atanh(z) = +/// sum_{i>=0} z^(2i+1)/(2i+1). Z = floor(z 2^128); Z2 = floor(Z^2 / 2^128); P_0 = Z, P_{i+1} = +/// floor(P_i Z2 / 2^128); S = sum floor(P_i / (2i+1)) until P_i = 0. Then S <= atanh(z) 2^128. Deficits: Z2 +/// is below z^2 2^128 by less than 2z + 1 < 2; the deficit d_i of P_i against z^(2i+1) 2^128 obeys d_0 < 1 +/// and d_{i+1} < 2 z^(2i+1) + z^2 d_i + 1, so d_i < 2 throughout; each term is short by less than /// 1 + d_i/(2i+1); P_i is 0 by i = 41 (z^83 2^128 < 1), after which the tail is below 2.25/(2i+1). So /// atanh(z) 2^128 - S < 41 + 2 (1 + 1/3 + ... + 1/81) + 2.25/83 < 47 <= `AtanhSlack` = 64. With -/// L = floor(ln 2 2^128): lower = k L + 2S, upper = k (L + 1) + 2 (S + 64) — at most k + 128 <= 190 units -/// of 2^-128 apart, under 2^-120, absolute. +/// L = floor(ln 2 2^128): lower = k L + 2S, upper = k (L + 1) + 2 (S + 64) -- k + 128 <= 254 units of +/// 2^-128 apart, under 2^-120, absolute. /// - **log10** = ln log10(e). With M = floor(log10(e) 2^128): lower = floor(lower_ln M / 2^128), -/// upper = floor(upper_ln (M + 1) / 2^128) + 1, under 190 · 0.44 + 44 + 2 < 130 units apart, since -/// |ln(a/b)| <= ln(2^63) < 44. The widest product, upper_ln (M + 1), is below 2^261. -/// - **exp(x)**, x = a/b != 0, -43 <= x <= 44 (the rounded forms answer outside it). X = floor(|x| 2^128), exact or one -/// below. For x > 0, k is the largest integer in [0, 63] with k (L + 1) <= X (64 ln 2 > 44 bounds it), and -/// R = X - k (L + 1) <= r 2^128 for r = x - k ln 2; for x < 0, m is the smallest in [1, 63] with -/// m L >= X' (X' = X + 1 when X is inexact; 63 ln 2 > 43 bounds it), R = m L - X' <= r 2^128 for -/// r = m ln 2 - |x|, and exp(x) = 2^-m exp(r). Either way 0 <= R, r < ln 2 + 2^-121, and r 2^128 - R <= 64 -/// (one unit for X, one per multiple of ln 2's unit). E = sum T_j, T_0 = 2^128, -/// T_j = floor(floor(T_{j-1} R / 2^128) / j), until T_j = 0 (within 40 terms for r < 0.7), so -/// E <= exp(R 2^-128) 2^128 <= exp(r) 2^128. Each T_j is short by e_j < e_{j-1} r / j + 1 < 2, and the tail -/// after the last term is below 3, so exp(R 2^-128) 2^128 - E < 2 · 40 + 3; the 64 units of r add less than -/// 2 · 1.0001 · 64 < 129. So exp(r) 2^128 < E + 212 <= E + `ExponentialSlack` = 512: -/// lower = E 2^k / 2^128, upper = (E + 512) 2^k / 2^128 (for x < 0, denominator 2^(128+m)), under 2^-119 -/// relative. The widest numerator, (E + 512) 2^63, is below 2^193; `decide_rounding`'s scaling by up to -/// 10^18 keeps it below 2^253. +/// upper = floor(upper_ln (M + 1) / 2^128) + 1, under 254 · 0.44 + 89 + 2 < 203 units apart, since +/// |ln(a/b)| <= ln(2^127) < 89. upper_ln is below 89 · 2^128 + 254 < 2^135 and M + 1 below 2^127, so the +/// widest product, upper_ln (M + 1), is below 2^262. +/// - **exp(x)**, x = a/b != 0, -43 <= x <= 887/10 (the rounded forms answer outside it), in F = 192 bits. +/// X = floor(|x| 2^192), exact or one below. L' = floor(ln 2 2^192). For x > 0, k is the largest integer +/// in [0, 127] with k (L' + 1) <= X (128 ln 2 > 887/10 bounds it), and R = X - k (L' + 1) <= r 2^192 for +/// r = x - k ln 2; for x < 0, m is the smallest in [1, 63] with m L' >= X' (X' = X + 1 when X is inexact; +/// 63 ln 2 > 43 bounds it), R = m L' - X' <= r 2^192 for r = m ln 2 - |x|, and exp(x) = 2^-m exp(r). +/// Either way 0 <= R < L' + 1, so r < ln 2 + 2^-184, and r 2^192 - R < 128: one unit for X, and one per +/// multiple of ln 2's unit, k <= 127 or m <= 63. E = sum T_j, T_0 = 2^192, +/// T_j = floor(floor(T_{j-1} R / 2^192) / j), until T_j = 0 (by j = 43 for r < 0.7; `TaylorTermLimit` = 50 +/// refuses a longer one), so E <= exp(R 2^-192) 2^192 <= exp(r) 2^192. Each T_j is short by +/// e_j < e_{j-1} r / j + 1 < 2, and the tail after the last term is below 3, so +/// exp(R 2^-192) 2^192 - E < 2 · 50 + 3; the 128 units of r add less than 2 · 1.0001 · 128 < 257. So +/// exp(r) 2^192 < E + 360 <= E + `ExponentialSlack` = 512: lower = E 2^k / 2^192, +/// upper = (E + 512) 2^k / 2^192 (for x < 0, denominator 2^(192+m)). Since E >= 2^192, the ends are at +/// most 2^-183 of the value apart: at the largest result a `Rational` holds, 2^127 last kept units, that is +/// 2^-56 of one unit. E < 2^193, so the widest numerator, (E + 512) 2^127, is below 2^321; +/// `decide_rounding`'s scaling by up to 10^18 keeps it below 2^381. /// - **Undecided.** When the two ends round differently, `decide_rounding` answers `Overflow`: the rounding /// needs more bits than the kernel holds. The rounded decimal exists, so `Inexact` would be wrong, and a new /// error enumerator would break `describe()` and consumers' switches. @@ -51,15 +61,14 @@ /// ## What it costs /// /// Measured on cl 19.51.36257, whose default constant-evaluation budget measured about 1 049 000 steps: one -/// enclosure costs between 21 000 and 29 400 steps (ln 3: 21 200; ln with z near 1/3: 29 100; log10 7: -/// 26 800; exp 1: 20 200; exp -43: 23 800), and a whole rounding of log10 2 to 3 places, with -/// `decide_rounding`, about 239 000. +/// enclosure costs between 23 100 and 43 700 steps (ln 3: 23 100; ln (2^127 - 1) / 2^126: 43 700; log10 7: 28 600; +/// exp 1: 32 900; exp -43: 38 200; exp 88: 38 400), and a whole rounding of log10 2 to 3 places, with +/// `decide_rounding`, about 242 300. #include #include #include -#include #include #include #include @@ -67,21 +76,26 @@ namespace formula::detail { -/// The kernel's width, 384 bits: room for its widest product (under 2^261) and for -/// `decide_rounding`'s scaling by up to 10^18 of every end it is handed (under 2^253). +/// The kernel's width, 384 bits: room for its widest product (under 2^262) and numerator (under 2^321), +/// and for `decide_rounding`'s scaling by up to 10^18 of every end it is handed (under 2^381). inline constexpr std::size_t KernelLimbs = 12; /// A value in the kernel's fixed point. using KernelWord = WideUnsigned; -/// How many of a fixed-point value's bits are fraction. +/// How many of a logarithm's fixed-point bits are fraction. inline constexpr std::size_t KernelFractionBits = 128; +/// How many of an exponential's fixed-point bits are fraction: more than a logarithm's, so that an +/// exponential as wide as a `Rational` holds is still enclosed more narrowly than its last kept unit -- see +/// the file comment. +inline constexpr std::size_t ExponentialFractionBits = 192; /// How far, in units of 2^-128, the atanh series' lower bound can fall short -- see the file comment. inline constexpr std::uint32_t AtanhSlack = 64; /// How far the exponential's lower bound can fall short of exp(r), r's own width included. inline constexpr std::uint32_t ExponentialSlack = 512; /// More terms than the atanh series takes for any z below 1/3; reaching it is refused. inline constexpr std::uint32_t AtanhTermLimit = 42; -/// More terms than the exponential's series takes for any r below 0.7; reaching it is refused. -inline constexpr std::uint32_t TaylorTermLimit = 40; +/// More terms than the exponential's series takes for any r below 0.7 at 192 fraction bits (it ends by the +/// 43rd); reaching it is refused. +inline constexpr std::uint32_t TaylorTermLimit = 50; /// Two ends between which a value certainly lies: lower <= value <= upper. struct Enclosure @@ -99,7 +113,17 @@ struct Enclosure KernelWord::from_u64(lowHalf)); } -/// One in the kernel's fixed point, 2^128. +/// @p topWord * 2^128 + @p middleWord * 2^64 + @p bottomWord as a kernel word. 192 bits in 384: no step can +/// overflow. +[[nodiscard]] constexpr KernelWord kernel_word(std::uint64_t topWord, + std::uint64_t middleWord, + std::uint64_t bottomWord) noexcept +{ + return *add_checked_or_none(*shift_left_checked_or_none(kernel_word(topWord, middleWord), 64), + KernelWord::from_u64(bottomWord)); +} + +/// One in a logarithm's fixed point, 2^128. inline constexpr KernelWord KernelOne = *shift_left_checked_or_none(KernelWord::from_u64(1), KernelFractionBits); /// floor(ln 2 * 2^128): ln 2 lies in [Ln2Lower, Ln2Upper] * 2^-128. Checked against its published /// digits, and re-derived by the kernel's own series, in `transcendental_tests.cpp`. @@ -110,8 +134,18 @@ inline constexpr KernelWord Ln2Upper = kernel_word(0xB172'17F7'D1CF'79ABULL, 0xC inline constexpr KernelWord Log10eLower = kernel_word(0x6F2D'EC54'9B94'38CAULL, 0x9AAD'D557'D699'EE19ULL); /// Log10eLower + 1. inline constexpr KernelWord Log10eUpper = kernel_word(0x6F2D'EC54'9B94'38CAULL, 0x9AAD'D557'D699'EE1AULL); +/// One in an exponential's fixed point, 2^192. +inline constexpr KernelWord ExponentialOne = + *shift_left_checked_or_none(KernelWord::from_u64(1), ExponentialFractionBits); +/// floor(ln 2 * 2^192), the exponential's ln 2: ln 2 lies in [Ln2Lower192, Ln2Upper192] * 2^-192. Checked +/// against its published digits, and against `Ln2Lower`, in `transcendental_tests.cpp`. +inline constexpr KernelWord Ln2Lower192 = + kernel_word(0xB172'17F7'D1CF'79ABULL, 0xC9E3'B398'03F2'F6AFULL, 0x40F3'4326'7298'B62DULL); +/// Ln2Lower192 + 1. +inline constexpr KernelWord Ln2Upper192 = + kernel_word(0xB172'17F7'D1CF'79ABULL, 0xC9E3'B398'03F2'F6AFULL, 0x40F3'4326'7298'B62EULL); -/// floor(dividend * 2^128 / divisor), and whether that is exact. +/// floor(dividend * 2^F / divisor), and whether that is exact. struct ScaledQuotient { /// The quotient, rounded down. @@ -120,30 +154,37 @@ struct ScaledQuotient bool exact; }; -/// @p dividend * 2^128 / @p divisor, by long division in 64-bit words: the whole part, then the 128 -/// fraction bits one at a time. The running remainder stays below the divisor; doubled, it leaves 64 -/// bits only when it is then above the divisor, and the subtraction, taken modulo 2^64, is then exact. -/// @pre divisor != 0. -[[nodiscard]] constexpr ScaledQuotient scaled_quotient(std::uint64_t dividend, std::uint64_t divisor) noexcept +/// @p dividend * 2^FractionBits / @p divisor, by long division over `UInt128` words: the whole part from +/// `u128_divmod`, then the fraction bits one at a time, gathered 64 to a word. The running remainder stays +/// below the divisor, which is below 2^128; doubled, it can pass 2^128 only when it is then above the +/// divisor, so the bit it shifts out is kept as a carry and the subtraction, taken modulo 2^128, is exact. +/// 128 or 192 fraction bits: the kernel's two fixed points. The whole part is below 2^128, so with 192 +/// fraction bits the quotient stays below 2^320: no step leaves the word. @pre @p divisor != 0. +template + requires(FractionBits % 64 == 0 && FractionBits <= ExponentialFractionBits) +[[nodiscard]] constexpr ScaledQuotient scaled_quotient(UInt128 dividend, UInt128 divisor) noexcept { - std::uint64_t remaining = dividend % divisor; - std::uint64_t highBits = 0; - std::uint64_t lowBits = 0; - for (std::size_t bit = 0; bit < KernelFractionBits; ++bit) + UInt128Division const split = u128_divmod(dividend, divisor); + UInt128 remaining = split.remainder; + KernelWord quotientSoFar = KernelWord::from_u128(split.quotient); + for (std::size_t wordAt = 0; wordAt < FractionBits / 64; ++wordAt) { - bool const carriedOut = (remaining >> 63) != 0; - remaining <<= 1; - highBits = (highBits << 1) | (lowBits >> 63); - lowBits <<= 1; - if (carriedOut || remaining >= divisor) + std::uint64_t fractionWord = 0; + for (int bitAt = 0; bitAt < 64; ++bitAt) { - remaining -= divisor; - lowBits |= 1; + bool const carriedOut = (remaining.highWord >> 63) != 0; + remaining = portable::shift_left(remaining, 1); + fractionWord <<= 1; + if (carriedOut || !(remaining < divisor)) + { + remaining = u128_sub(remaining, divisor); + fractionWord |= 1U; + } } + quotientSoFar = + *add_checked_or_none(*shift_left_checked_or_none(quotientSoFar, 64), KernelWord::from_u64(fractionWord)); } - // The whole part is below 2^64 and the fraction below 2^128: the sum fits. - KernelWord const whole = *shift_left_checked_or_none(KernelWord::from_u64(dividend / divisor), KernelFractionBits); - return { *add_checked_or_none(whole, kernel_word(highBits, lowBits)), remaining == 0 }; + return { quotientSoFar, remaining.is_zero() }; } /// A lower bound of atanh(z) * 2^128 for z = @p fixedArgument * 2^-128 below 1/3 -- see the file @@ -171,18 +212,18 @@ struct ScaledQuotient return partialSum; } -/// A lower bound of exp(r) * 2^128 for r = @p fixedArgument * 2^-128 below 0.7 -- see the file -/// comment; nothing when a bound failed or the series had not ended by `TaylorTermLimit`. +/// A lower bound of exp(r) * 2^192 for r = @p fixedArgument * 2^-192 below 0.7 -- see the file comment; +/// nothing when a bound failed or the series had not ended by `TaylorTermLimit`. [[nodiscard]] constexpr std::optional exponential_series_lower(KernelWord const& fixedArgument) noexcept { - KernelWord term = KernelOne; - KernelWord partialSum = KernelOne; + KernelWord term = ExponentialOne; + KernelWord partialSum = ExponentialOne; for (std::uint32_t order = 1;; ++order) { std::optional const product = mul_checked_or_none(term, fixedArgument); if (!product) return std::nullopt; - term = divmod_small(shift_right(*product, KernelFractionBits), order).quotient; + term = divmod_small(shift_right(*product, ExponentialFractionBits), order).quotient; if (term.is_zero()) return partialSum; if (order == TaylorTermLimit) @@ -217,24 +258,21 @@ struct LogarithmMagnitude /// The enclosure of |ln(@p positive)| -- see the file comment. @pre @p positive > 0 and != 1. [[nodiscard]] constexpr std::optional natural_log_magnitude(Rational positive) noexcept { - std::optional const numeratorWord = narrow_to_int64(positive.numerator()); - std::optional const denominatorWord = narrow_to_int64(positive.denominator()); - // The kernel works on a fraction of two values below 2^63; a wider one is - // beyond it. - if (!numeratorWord || !denominatorWord) - return std::nullopt; - auto larger = static_cast(*numeratorWord); - auto smaller = static_cast(*denominatorWord); + // A positive Rational's numerator and its denominator are both below 2^127. + UInt128 larger = wide_magnitude(positive.numerator()); + UInt128 smaller = wide_magnitude(positive.denominator()); LogarithmSign const logarithmSign = larger < smaller ? LogarithmSign::Negative : LogarithmSign::Positive; if (logarithmSign == LogarithmSign::Negative) std::swap(larger, smaller); - // B = smaller * 2^doublings <= larger < 2B. Both are below 2^63, so the shift stays in 64 bits. - int doublings = static_cast(std::bit_width(larger)) - static_cast(std::bit_width(smaller)); - if ((smaller << doublings) > larger) + // B = smaller * 2^doublings <= larger < 2B. Both are below 2^127, so doublings <= 126 and the shift + // stays in 128 bits. + int doublings = larger.bit_width() - smaller.bit_width(); + if (larger < portable::shift_left(smaller, doublings)) --doublings; - std::uint64_t const base = smaller << doublings; - // z = (a - B) / (a + B), and a + B < 2^64. - std::optional const series = atanh_series_lower(scaled_quotient(larger - base, larger + base).below); + UInt128 const base = portable::shift_left(smaller, doublings); + // z = (a - B) / (a + B), and a + B < 2^128. + std::optional const series = atanh_series_lower( + scaled_quotient(u128_sub(larger, base), u128_add(larger, base)).below); if (!series) return std::nullopt; auto const multiples = static_cast(doublings); @@ -290,29 +328,24 @@ struct LogarithmMagnitude return signed_enclosure(shift_right(*lowerProduct, KernelFractionBits), *farther, natural->sign); } -/// exp(@p argument), enclosed -- see the file comment. @pre @p argument != 0 and -43 <= @p argument <= 44. +/// exp(@p argument), enclosed -- see the file comment. @pre @p argument != 0 and -43 <= @p argument <= 887/10. [[nodiscard]] constexpr std::optional exponential_enclosure(Rational argument) noexcept { bool const negative = argument.sign() < 0; - std::optional const numeratorWord = narrow_to_int64(argument.numerator()); - std::optional const denominatorWord = narrow_to_int64(argument.denominator()); - // The kernel works on a fraction of two values below 2^63; a wider one is - // beyond it. - if (!numeratorWord || !denominatorWord) - return std::nullopt; - ScaledQuotient const fixedMagnitude = - scaled_quotient(magnitude(*numeratorWord), static_cast(*denominatorWord)); + // |a| <= 2^127, the minimum's magnitude, and 0 < b < 2^127: both read whole. + ScaledQuotient const fixedMagnitude = scaled_quotient( + wide_magnitude(argument.numerator()), wide_magnitude(argument.denominator())); std::optional remainderBelow; std::uint32_t shifts = 0; if (!negative) { - // The largest k in [0, 63] with k (L + 1) <= X: 64 ln 2 > 44 >= x. + // The largest k in [0, 127] with k (L' + 1) <= X: 128 ln 2 > 887/10 >= x. std::uint32_t below = 0; - std::uint32_t above = 64; + std::uint32_t above = 128; while (below + 1 < above) { std::uint32_t const middle = (below + above) / 2; - std::optional const multiple = mul_small_checked_or_none(Ln2Upper, middle); + std::optional const multiple = mul_small_checked_or_none(Ln2Upper192, middle); if (!multiple) return std::nullopt; if (*multiple <= fixedMagnitude.below) @@ -320,13 +353,13 @@ struct LogarithmMagnitude else above = middle; } - std::optional const multiple = mul_small_checked_or_none(Ln2Upper, below); + std::optional const multiple = mul_small_checked_or_none(Ln2Upper192, below); remainderBelow = multiple ? sub_checked_or_none(fixedMagnitude.below, *multiple) : std::nullopt; shifts = below; } else { - // The smallest m in [1, 63] with m L >= X' (X rounded up): 63 ln 2 > 43 >= |x|. + // The smallest m in [1, 63] with m L' >= X' (X rounded up): 63 ln 2 > 43 >= |x|. std::optional const roundedUp = fixedMagnitude.exact ? std::optional { fixedMagnitude.below } : add_small_checked_or_none(fixedMagnitude.below, 1U); @@ -337,7 +370,7 @@ struct LogarithmMagnitude while (below + 1 < above) { std::uint32_t const middle = (below + above) / 2; - std::optional const multiple = mul_small_checked_or_none(Ln2Lower, middle); + std::optional const multiple = mul_small_checked_or_none(Ln2Lower192, middle); if (!multiple) return std::nullopt; if (*multiple >= *roundedUp) @@ -345,7 +378,7 @@ struct LogarithmMagnitude else below = middle; } - std::optional const multiple = mul_small_checked_or_none(Ln2Lower, above); + std::optional const multiple = mul_small_checked_or_none(Ln2Lower192, above); remainderBelow = multiple ? sub_checked_or_none(*multiple, *roundedUp) : std::nullopt; shifts = above; } @@ -361,10 +394,10 @@ struct LogarithmMagnitude std::optional const farther = shift_left_checked_or_none(*slacked, shifts); if (!nearer || !farther) return std::nullopt; - return Enclosure { .lower = { .negative = false, .numerator = *nearer, .denominator = KernelOne }, - .upper = { .negative = false, .numerator = *farther, .denominator = KernelOne } }; + return Enclosure { .lower = { .negative = false, .numerator = *nearer, .denominator = ExponentialOne }, + .upper = { .negative = false, .numerator = *farther, .denominator = ExponentialOne } }; } - std::optional const denominatorPower = shift_left_checked_or_none(KernelOne, shifts); + std::optional const denominatorPower = shift_left_checked_or_none(ExponentialOne, shifts); if (!denominatorPower) return std::nullopt; return Enclosure { .lower = { .negative = false, .numerator = *series, .denominator = *denominatorPower }, diff --git a/include/formula-cpp/rounded_transcendental.hpp b/include/formula-cpp/rounded_transcendental.hpp index b021f21..89f6b4e 100644 --- a/include/formula-cpp/rounded_transcendental.hpp +++ b/include/formula-cpp/rounded_transcendental.hpp @@ -39,18 +39,21 @@ namespace formula namespace detail { + /// The largest argument the exponential's kernel takes, 88.7: below 128 ln 2 = 88.72..., which bounds + /// its reduction (`detail/transcendental.hpp`), and above ln(2^127) = 88.03..., past which e^x leaves the + /// largest `Rational` at every places. Above it the answer is `Overflow` without the kernel. + inline constexpr Rational ExponentialArgumentCap { 887, 10 }; + /// @p F of @p argument, rounded to @p places decimal places under @p roundingMode -- the correctly /// rounded decimal of the true value, rational or not. The decision, in order: a logarithm of zero or /// below is `DomainError`; places outside -18...18 are `Overflow`, as for `checked_round`; a special - /// point -- the only values that can tie -- goes to `checked_round`; the exponential of more than 44 - /// is `Overflow` (past the kernel's range, which ends at exp 44 = 1.29 * 10^19), and of less than -43 is below a - /// quarter of the last kept unit at any places accepted, so 0, or one unit under `Ceiling` and + /// point -- the only values that can tie -- goes to `checked_round`; the exponential of more than 887/10 + /// is `Overflow` (`ExponentialArgumentCap`: no such value fits a `Rational`), and of less than -43 is + /// below a quarter of the last kept unit at any places accepted, so 0, or one unit under `Ceiling` and /// `AwayFromZero`; everything else is the kernel's enclosure, rounded by `decide_rounding`, which - /// answers `Overflow` when the kept integer does not fit and when the two ends round differently. - /// The kernel takes an argument whose numerator and denominator each fit 64 bits, the range it was - /// built for; a wider argument that reaches it, which a `Rational` can hold, is `Overflow` too. The - /// special points are `RepFunctions`'s, through `transcendental_of`: their value, and - /// `Inexact` elsewhere. + /// answers `Overflow` when the kept integer does not fit -- e^88.5, at every places -- and when the two + /// ends round differently. The kernel takes every argument a `Rational` holds. The special points are + /// `RepFunctions`'s, through `transcendental_of`: their value, and `Inexact` elsewhere. template [[nodiscard]] constexpr std::expected rounded_transcendental( Rational argument, DecimalPlaces places, RoundingMode roundingMode) noexcept @@ -76,7 +79,7 @@ namespace detail enclosure = decimal_log_enclosure(argument); else { - if (argument > Rational { 44 }) + if (argument > ExponentialArgumentCap) return std::unexpected { ArithmeticError::Overflow }; if (argument < Rational { -43 }) { @@ -122,9 +125,8 @@ struct RoundedTranscendentalNode: NodeBase /// The natural logarithm of `operand`, a dimensionless expression, rounded exactly to `Places` decimal /// places: `rounded_ln(var / var)`. /// -/// The integer kernel (`detail/transcendental.hpp`) takes an argument whose numerator and denominator -/// each fit 64 bits: a wider one, though a `Rational` holds it, is `Overflow`, as is a rounding the -/// kernel cannot decide. +/// The integer kernel (`detail/transcendental.hpp`) takes every argument a `Rational` holds: ln (2^127 - 1) +/// is 88.029691931113054295 at 18 places, under `Floor`. A rounding the kernel cannot decide is `Overflow`. template [[nodiscard]] constexpr auto rounded_ln(Operand operand) noexcept { @@ -133,9 +135,8 @@ template /// The decimal logarithm of `operand`, rounded exactly to `Places` decimal places. /// -/// As for `rounded_ln`, an argument whose numerator or denominator does not fit 64 bits is `Overflow`, as -/// log10 2^70 is -- except a power of ten, 10^19 up to 10^38 or one over it, which is answered exactly -/// before the kernel is asked: log10 10^30 is 30. +/// As for `rounded_ln`, every argument a `Rational` holds; a power of ten, 10^-38 up to 10^38, is answered +/// exactly before the kernel is asked: log10 10^30 is 30. template [[nodiscard]] constexpr auto rounded_log10(Operand operand) noexcept { @@ -144,11 +145,12 @@ template /// The exponential of `operand`, rounded exactly to `Places` decimal places. /// -/// Its range is the integer kernel's (`detail/transcendental.hpp`), checked in this order: an argument -/// above 44 is `Overflow`; one below -43 is 0, or one last kept unit under `Ceiling` and `AwayFromZero`, -/// whatever its width, so exp(-2^70) is 0; and an argument between them whose numerator or denominator -/// does not fit 64 bits is `Overflow`, though a `Rational` holds it. A rounding the kernel cannot decide -/// is `Overflow` too, never a guess. +/// Checked in this order: an argument above 887/10 is `Overflow`, since e^x is then past the largest +/// `Rational`; one below -43 is 0, or one last kept unit under `Ceiling` and `AwayFromZero`, whatever its +/// width, so exp(-2^70) is 0; any other goes to the integer kernel (`detail/transcendental.hpp`), which +/// answers wherever the result fits the declared places -- e^45 to 18 places, e^88 to whole units -- and is +/// `Overflow` where it does not, as e^88.5 is at every places. A rounding the kernel cannot decide is +/// `Overflow` too, never a guess. template [[nodiscard]] constexpr auto rounded_exp(Operand operand) noexcept { diff --git a/test/rounded_transcendental_tests.cpp b/test/rounded_transcendental_tests.cpp index 4b2adb4..f90d7a1 100644 --- a/test/rounded_transcendental_tests.cpp +++ b/test/rounded_transcendental_tests.cpp @@ -11,6 +11,8 @@ #include #include +#include +#include namespace { @@ -62,6 +64,15 @@ template using RoundedOrError = std::expected; constexpr RoundedOrError overflow { std::unexpected { formula::ArithmeticError::Overflow } }; constexpr RoundedOrError domainError { std::unexpected { formula::ArithmeticError::DomainError } }; + +/// The integer whose decimal digits are @p digits: a literal too wide for a built-in integer. +[[nodiscard]] constexpr Rational::Int integer_of(std::string_view digits) +{ + Rational::Int parsed {}; + for (char const each: digits) + parsed = parsed * 10 + (each - '0'); + return parsed; +} } // namespace TEST_CASE("rounded_transcendental: ln 2 to 4 dp in every mode and of 1/2 with the directions paired the other way", @@ -123,21 +134,41 @@ TEST_CASE("rounded_transcendental: a special point ties and the mode decides it" STATIC_REQUIRE(expAt(Rational {}) == Rational { 10 }); } -TEST_CASE("rounded_transcendental: an exponential too large to hold is Overflow", "[rounded_transcendental]") +TEST_CASE("rounded_transcendental: an exponential answers wherever it fits a Rational, and is Overflow past that", + "[rounded_transcendental]") { - // exp 50 = 5.18 * 10^21: past the early bound. - STATIC_REQUIRE(expAt(Rational { 50 }) == overflow); - // exp 43.7 = 9.52 * 10^18: through the kernel. Floor to whole 10^18s keeps 9 * 10^18; the nearest - // modes give 10^19, which overflowed 64 bits and fits 128. + // exp 89 is past 88.7, where e^x has long left the largest Rational, 2^127 - 1: refused before the kernel. + STATIC_REQUIRE(expAt(Rational { 89 }) == overflow); + // exp 50 = 5184705528587072464087.4533229..., to 6 places. + CHECK(expAt(Rational { 50 }) + == Rational::from_decimal(integer_of("5184705528587072464087453323"), -6)); + // exp 43.7 = 9.52 * 10^18. Floor to whole 10^18s keeps 9 * 10^18; the nearest modes give 10^19. CHECK(expAt(Rational { 437, 10 }) == Rational { 9'000'000'000'000'000'000 }); CHECK(expAt(Rational { 437, 10 }) == Rational { 10'000'000'000'000'000'000ULL }); - // exp 44 = 1.29 * 10^19, which fitted at no places in 64 bits, floors to 12 * 10^18. + // exp 44 = 1.29 * 10^19 floors to 12 * 10^18. CHECK(expAt(Rational { 44 }) == Rational { 12'000'000'000'000'000'000ULL }); - // exp 43 = 4727839468229346561.47...: whole, it fits. + // exp 43 = 4727839468229346561.474457562744280370...: whole, and to all 18 places. CHECK(expAt(Rational { 43 }) == Rational { 4'727'839'468'229'346'561 }); CHECK(expAt(Rational { 43 }) == Rational { 4'727'839'468'229'346'562 }); + CHECK(expAt(Rational { 43 }) + == Rational::from_decimal(integer_of("4727839468229346561474457562744280370"), -18)); + // exp 45 = 34934271057485095348.034797233406099533 41..., to all 18 places: 38 digits, below 2^127. + CHECK(expAt(Rational { 45 }) + == Rational::from_decimal(integer_of("34934271057485095348034797233406099533"), -18)); + CHECK(expAt(Rational { 45 }) + == Rational::from_decimal(integer_of("34934271057485095348034797233406099534"), -18)); + // exp 88 = 165163625499400185552832979626485876706.9...: whole, 39 digits, below 2^127. + CHECK(expAt(Rational { 88 }) + == Rational { integer_of("165163625499400185552832979626485876706") }); + CHECK(expAt(Rational { 88 }) + == Rational { integer_of("165163625499400185552832979626485876707") }); + // exp 88.5 = 2.7 * 10^38 and exp 88.7 = 3.3 * 10^38 are past 2^127 at every places: through the + // kernel, and Overflow. + CHECK(expAt(Rational { 885, 10 }) == overflow); + CHECK(expAt(Rational { 885, 10 }) == overflow); + CHECK(expAt(Rational { 887, 10 }) == overflow); } TEST_CASE("rounded_transcendental: a tiny exponential is zero or one unit by mode", "[rounded_transcendental]") @@ -183,26 +214,61 @@ TEST_CASE("rounded_transcendental: absence and failures come first and in order" STATIC_REQUIRE(expAt(Rational { -50 }) == overflow); STATIC_REQUIRE(expAt(Rational { -50 }) == overflow); STATIC_REQUIRE(expAt(Rational { -50 }) == overflow); - // An argument whose numerator or denominator does not fit 64 bits is beyond the kernel, which works on - // two values below 2^63: ln 2^70, exp 2^-64 and log10 2^70 are Overflow, though a Rational holds each - // argument. - STATIC_REQUIRE(lnAt(Rational { Rational::Int { 1 } << 70 }) - == overflow); - STATIC_REQUIRE(expAt(Rational { 1, Rational::Int { 1 } << 64 }) - == overflow); - STATIC_REQUIRE(log10At(Rational { Rational::Int { 1 } << 70 }) - == overflow); // A wide power of ten is a special point, answered before the kernel is asked: log10 10^30 is 30. constexpr Rational::Int tenToFifteen = 1'000'000'000'000'000; STATIC_REQUIRE(log10At(Rational { tenToFifteen * tenToFifteen }) == Rational { 30 }); - // The rule below -43 comes first, whatever the argument's width: exp -2^70 is 0. + // The rule below -43 comes first: exp -2^70 is 0 without the kernel. STATIC_REQUIRE(expAt(Rational { -(Rational::Int { 1 } << 70) }) == Rational {}); - // 2^62 and 1/2^62, inside it, answer: ln 2^62 = 42.97512..., exp 2^-62 rounds to 1. +} + +TEST_CASE("rounded_transcendental: an argument as wide as a Rational holds is answered", "[rounded_transcendental]") +{ + constexpr Rational::Int largest = std::numeric_limits::max(); // 2^127 - 1 + constexpr Rational::Int twoTo126 = Rational::Int { 1 } << 126; + // Through the kernel, so at run time. ln 2^70 = 48.520302639196171659..., log10 2^70 = 21.072099696478683664... + CHECK(lnAt(Rational { Rational::Int { 1 } << 70 }) + == Rational { 485203, 10000 }); + CHECK(lnAt(Rational { Rational::Int { 1 } << 70 }) + == Rational::from_decimal(integer_of("48520302639196171659"), -18)); + CHECK(log10At(Rational { Rational::Int { 1 } << 70 }) + == Rational { 210721, 10000 }); + // exp 2^-64 = 1 + 5.4 * 10^-20 and exp 2^-62 = 1 + 2.2 * 10^-19: 1, and one unit up under Ceiling. + CHECK(expAt(Rational { 1, Rational::Int { 1 } << 64 }) == Rational { 1 }); + CHECK(expAt(Rational { 1, Rational::Int { 1 } << 64 }) + == Rational::from_decimal(1'000'000'000'000'000'001, -18)); + CHECK(expAt(Rational { 1, Rational::Int { 1 } << 62 }) == Rational { 1 }); + // ln 2^62 = 42.97512..., as before. CHECK(lnAt(Rational { Rational::Int { 1 } << 62 }) == Rational { 429751, 10000 }); - CHECK(expAt(Rational { 1, Rational::Int { 1 } << 62 }) == Rational { 1 }); + // ln (2^127 - 1) = 88.029691931113054295..., and of its reciprocal the negation, which Floor takes down. + CHECK(lnAt(Rational { largest }) + == Rational::from_decimal(integer_of("88029691931113054295"), -18)); + CHECK(lnAt(Rational { 1, largest }) + == Rational::from_decimal(-integer_of("88029691931113054296"), -18)); + // log10 (2^127 - 1) = 38.230809449325611792... + CHECK(log10At(Rational { largest }) + == Rational::from_decimal(integer_of("38230809449325611792"), -18)); + CHECK(log10At(Rational { 1, largest }) + == Rational::from_decimal(-integer_of("38230809449325611793"), -18)); + // Two 127-bit integers next to each other: ln((2^126 + 1) / 2^126) = 1.18 * 10^-38. 0 at 18 places, + // and one unit up under Ceiling. + CHECK(lnAt(Rational { twoTo126 + 1, twoTo126 }) == Rational {}); + CHECK(lnAt(Rational { twoTo126 + 1, twoTo126 }) + == Rational::from_decimal(1, -18)); + // The same two the other way round, a ratio below 1 of two 127-bit integers: ln(2^126 / (2^126 + 1)) = + // -1.18 * 10^-38. 0 at 18 places, and one unit down under Floor. + CHECK(lnAt(Rational { twoTo126, twoTo126 + 1 }) == Rational {}); + CHECK(lnAt(Rational { twoTo126, twoTo126 + 1 }) + == Rational::from_decimal(-1, -18)); + // exp of -2^127 / (2^127 - 1), whose numerator is the minimum's magnitude: e^-1.000... = 0.367879441171442321595... + CHECK(expAt(Rational { std::numeric_limits::min(), largest }) + == Rational::from_decimal(367'879'441'171'442'321, -18)); + // exp 1/(2^127 - 1) = 1 + 5.9 * 10^-39: 1, and one unit up under Ceiling. + CHECK(expAt(Rational { 1, largest }) == Rational { 1 }); + CHECK(expAt(Rational { 1, largest }) + == Rational::from_decimal(1'000'000'000'000'000'001, -18)); } TEST_CASE("rounded_transcendental: a percentage is read in the coherent unit", "[rounded_transcendental]") diff --git a/test/trace_render_tests.cpp b/test/trace_render_tests.cpp index a6fbc12..a4e4a6d 100644 --- a/test/trace_render_tests.cpp +++ b/test/trace_render_tests.cpp @@ -435,11 +435,11 @@ TEST_CASE("a derivation writes a rounded logarithm or exponential as one step in formula::Rational { -1 }, fractions) == "1. r = -1\n2. round(exp(#1), to 3 dp) = 367/1000 [toward negative infinity]\n"); - // A failure reads like any step's, and still names the mode. + // A failure reads like any step's, and still names the mode. exp 89 is past every value a Rational holds. CHECK(traceOf(formula::rounded_exp(var), - formula::Rational { 50 }, + formula::Rational { 89 }, fractions) - == "1. r = 50\n2. round(exp(#1), to 6 dp) = overflow in exact arithmetic [nearest, ties away from zero]\n"); + == "1. r = 89\n2. round(exp(#1), to 6 dp) = overflow in exact arithmetic [nearest, ties away from zero]\n"); // In exact decimals the rounded value is a decimal like any other, with no approximation mark: it is exact. CHECK(traceOf(formula::rounded_ln(var), formula::Rational { 2 }, diff --git a/test/transcendental_tests.cpp b/test/transcendental_tests.cpp index 1081587..3fa0794 100644 --- a/test/transcendental_tests.cpp +++ b/test/transcendental_tests.cpp @@ -13,7 +13,9 @@ // D = int((abs(v) * Decimal(10) ** scale).to_integral_value(rounding=ROUND_FLOOR)) // // The published values the stored constants are checked against: ln 2 = -// 0.6931471805599453094172321214581765680755..., log10(e) = 0.4342944819032518276511289189166050822943.... +// 0.6931471805599453094172321214581765680755..., log10(e) = 0.4342944819032518276511289189166050822943..., +// and ln 2 to 60 digits, for the exponential's 192-bit constant: +// 0.693147180559945309417232121458176568075500134360255254120680.... // // Every check that runs the kernel runs at run time, but one: a whole rounding costs a quarter of a // compiler's default constant-evaluation budget, too near it to pin many. The last case keeps one at @@ -31,6 +33,7 @@ #include #include #include +#include #include #include @@ -77,10 +80,23 @@ constexpr std::array everyMode { return detail::decide_rounding(enclosure->lower, enclosure->upper, DecimalPlaces { places }, roundingMode); } -/// The decimal digits @p decimalDigits as a word. -[[nodiscard]] constexpr Word word_of(std::string_view decimalDigits) +/// A word of 16 limbs, wide enough for the checks' cross products. +using CheckWord = detail::WideUnsigned<16>; + +/// @p narrow, the same value, in a `CheckWord`. +[[nodiscard]] constexpr CheckWord widened_word(Word const& narrow) +{ + std::array limbsCopied {}; + for (std::size_t limbAt = 0; limbAt < detail::KernelLimbs; ++limbAt) + limbsCopied[limbAt] = narrow.limb(limbAt); + return CheckWord::from_limbs(limbsCopied); +} + +/// The decimal digits @p decimalDigits as a word of @p Limbs limbs. +template +[[nodiscard]] constexpr detail::WideUnsigned word_of(std::string_view decimalDigits) { - Word parsed {}; + detail::WideUnsigned parsed {}; for (char const each: decimalDigits) parsed = *detail::add_small_checked_or_none(*detail::mul_small_checked_or_none(parsed, 10U), static_cast(each - '0')); @@ -94,38 +110,45 @@ constexpr std::array everyMode { bool const rightNegative = right.negative && !right.numerator.is_zero(); if (leftNegative != rightNegative) return leftNegative; - Word const leftCross = *detail::mul_checked_or_none(left.numerator, right.denominator); - Word const rightCross = *detail::mul_checked_or_none(right.numerator, left.denominator); + CheckWord const leftCross = *detail::mul_checked_or_none(widened_word(left.numerator), widened_word(right.denominator)); + CheckWord const rightCross = *detail::mul_checked_or_none(widened_word(right.numerator), widened_word(left.denominator)); return leftNegative ? rightCross <= leftCross : leftCross <= rightCross; } -/// Whether @p stored is floor(v * 2^128) for the value v whose first 40 digits are @p published: -/// (D - 1) * 2^128 >= stored * 10^40 and (D + 1) * 2^128 <= (stored + 1) * 10^40. That interval is -/// 2 * 2^128 / 10^40 < 0.07 units wide, so it pins the floor. -[[nodiscard]] constexpr bool pinned_by_published_digits(Word const& stored, std::string_view published) +/// Whether @p stored is floor(v * 2^@p fractionBits) for the value v below one whose first significant digits +/// are @p published, as many as it has characters: (D - 1) * 2^bits >= stored * 10^digits and +/// (D + 1) * 2^bits <= (stored + 1) * 10^digits. That interval is 2 * 2^bits / 10^digits units wide -- under +/// 0.07 for 128 bits and 40 digits, under 0.013 for 192 bits and 60 -- so it pins the floor. +[[nodiscard]] constexpr bool pinned_by_published_digits(Word const& stored, + std::size_t fractionBits, + std::string_view published) { - Word const digitsValue = word_of(published); - Word const unit = *detail::shift_left_checked_or_none(Word::from_u64(1), 128); - Word const tenTo40 = *detail::pow10(40); - return *detail::mul_checked_or_none(stored, tenTo40) - <= *detail::mul_checked_or_none(*detail::sub_checked_or_none(digitsValue, Word::from_u64(1)), unit) + CheckWord const digitsValue = word_of<16>(published); + CheckWord const unit = *detail::shift_left_checked_or_none(CheckWord::from_u64(1), fractionBits); + CheckWord const tenToDigits = *detail::pow10<16>(published.size()); + CheckWord const storedWide = widened_word(stored); + return *detail::mul_checked_or_none(storedWide, tenToDigits) + <= *detail::mul_checked_or_none(*detail::sub_checked_or_none(digitsValue, CheckWord::from_u64(1)), unit) && *detail::mul_checked_or_none(*detail::add_small_checked_or_none(digitsValue, 1U), unit) - <= *detail::mul_checked_or_none(*detail::add_small_checked_or_none(stored, 1U), tenTo40); + <= *detail::mul_checked_or_none(*detail::add_small_checked_or_none(storedWide, 1U), tenToDigits); } /// One row of the reference table: |value| lies in [digits, digits + 1] * 10^-scale. struct Reference { Transcendental function; - std::int64_t numerator; - std::int64_t denominator; + formula::Rational::Int numerator; + formula::Rational::Int denominator; bool negative; std::string_view digits; std::size_t scale; }; +constexpr Rational::Int largestInt = std::numeric_limits::max(); // 2^127 - 1 +constexpr Rational::Int smallestInt = std::numeric_limits::min(); // -2^127 + // clang-format off -constexpr std::array references { { +constexpr std::array references { { { Transcendental::NaturalLogarithm, 2, 1, false, "6931471805599453094172321214581765680755", 40 }, { Transcendental::NaturalLogarithm, 1, 2, true, "6931471805599453094172321214581765680755", 40 }, { Transcendental::NaturalLogarithm, 3, 1, false, "1098612288668109691395245236922525704647", 39 }, @@ -167,6 +190,19 @@ constexpr std::array references { { { Transcendental::Exponential, 1, 4611686018427387904, false, "1000000000000000000216840434497100886825", 39 }, { Transcendental::Exponential, -1, 4611686018427387904, false, "9999999999999999997831595655028991132220", 40 }, { Transcendental::Exponential, 44, 1, false, "1285160011435930827580929963214309925780", 20 }, + { Transcendental::NaturalLogarithm, Rational::Int { 1 } << 70, 1, false, "4852030263919617165920624850207235976528", 38 }, + { Transcendental::NaturalLogarithm, largestInt, 1, false, "8802969193111305429598847942518842414558", 38 }, + { Transcendental::NaturalLogarithm, 1, largestInt, true, "8802969193111305429598847942518842414558", 38 }, + { Transcendental::NaturalLogarithm, (Rational::Int { 1 } << 126) + 1, Rational::Int { 1 } << 126, false, + "1175494350822287507968736537222245677811", 77 }, + { Transcendental::DecimalLogarithm, Rational::Int { 1 } << 70, 1, false, "2107209969647868366496172263071451187377", 38 }, + { Transcendental::DecimalLogarithm, largestInt, 1, false, "3823080944932561179214483963001061439955", 38 }, + { Transcendental::DecimalLogarithm, 1, largestInt, true, "3823080944932561179214483963001061439955", 38 }, + { Transcendental::Exponential, 1, Rational::Int { 1 } << 64, false, "1000000000000000000054210108624275221701", 39 }, + { Transcendental::Exponential, 1, largestInt, false, "1000000000000000000000000000000000000005", 39 }, + { Transcendental::Exponential, smallestInt, largestInt, false, "3678794411714423215955237701614608674436", 40 }, + { Transcendental::Exponential, 45, 1, false, "3493427105748509534803479723340609953341", 20 }, + { Transcendental::Exponential, 877, 10, false, "1223562231638072508562388385422483006583", 1 }, } }; // clang-format on @@ -184,17 +220,24 @@ constexpr std::array references { { TEST_CASE("transcendental kernel: the stored ln 2 and log10(e) are the published values", "[transcendental]") { - STATIC_REQUIRE(pinned_by_published_digits(detail::Ln2Lower, "6931471805599453094172321214581765680755")); - STATIC_REQUIRE(pinned_by_published_digits(detail::Log10eLower, "4342944819032518276511289189166050822943")); + STATIC_REQUIRE(pinned_by_published_digits(detail::Ln2Lower, 128, "6931471805599453094172321214581765680755")); + STATIC_REQUIRE(pinned_by_published_digits(detail::Log10eLower, 128, "4342944819032518276511289189166050822943")); STATIC_REQUIRE(detail::Ln2Upper == *detail::add_small_checked_or_none(detail::Ln2Lower, 1U)); STATIC_REQUIRE(detail::Log10eUpper == *detail::add_small_checked_or_none(detail::Log10eLower, 1U)); + STATIC_REQUIRE(pinned_by_published_digits(detail::Ln2Lower192, 192, + "693147180559945309417232121458176568075500134360255254120680")); + STATIC_REQUIRE(detail::Ln2Upper192 == *detail::add_small_checked_or_none(detail::Ln2Lower192, 1U)); + // The exponential's ln 2 begins with the logarithms': floor(L192 / 2^64) = L128. + STATIC_REQUIRE(detail::shift_right(detail::Ln2Lower192, 64) == detail::Ln2Lower); } TEST_CASE("transcendental kernel: the kernel re-derives its stored constants from its own series", "[transcendental]") { // At run time: the kernel's series, like every check that runs it but one (see the last case). // ln 2 = 2 atanh(1/3), since (1 + 1/3) / (1 - 1/3) = 2: the series' enclosure of it meets the stored one. - std::optional const atanhThird = detail::atanh_series_lower(detail::scaled_quotient(1, 3).below); + std::optional const atanhThird = detail::atanh_series_lower( + detail::scaled_quotient(detail::UInt128::from_u64(1), detail::UInt128::from_u64(3)) + .below); REQUIRE(atanhThird.has_value()); Word const ln2Lower = *detail::add_checked_or_none(*atanhThird, *atanhThird); Word const ln2Upper = *detail::add_checked_or_none(ln2Lower, Word::from_u64(2 * detail::AtanhSlack)); @@ -202,7 +245,9 @@ TEST_CASE("transcendental kernel: the kernel re-derives its stored constants fro CHECK(detail::Ln2Lower <= ln2Upper); // log10(e) = 1 / ln 10, and ln 10 = 3 ln 2 + 2 atanh(1/9), since (1 + 1/9) / (1 - 1/9) = 10/8. The // stored M meets [2^256 / upper, 2^256 / lower] over the whole enclosure of ln 10 * 2^128. - std::optional const atanhNinth = detail::atanh_series_lower(detail::scaled_quotient(1, 9).below); + std::optional const atanhNinth = detail::atanh_series_lower( + detail::scaled_quotient(detail::UInt128::from_u64(1), detail::UInt128::from_u64(9)) + .below); REQUIRE(atanhNinth.has_value()); Word const ln10Lower = *detail::add_checked_or_none(*detail::mul_small_checked_or_none(detail::Ln2Lower, 3U), *detail::add_checked_or_none(*atanhNinth, *atanhNinth)); @@ -220,9 +265,10 @@ TEST_CASE("transcendental kernel: every reference value is enclosed and rounds a constexpr std::array placesTried { -2, -1, 0, 1, 2, 4, 9, 17, 18 }; std::size_t compared = 0; std::size_t undecided = 0; - for (Reference const& row: references) + for (std::size_t rowAt = 0; rowAt < references.size(); ++rowAt) { - INFO("row " << row.numerator << "/" << row.denominator); + Reference const& row = references[rowAt]; + INFO("row " << rowAt); std::optional const enclosure = enclosure_of(row.function, Rational { row.numerator, row.denominator }); REQUIRE(enclosure.has_value()); @@ -243,11 +289,6 @@ TEST_CASE("transcendental kernel: every reference value is enclosed and rounds a if (!decided.has_value() && referenceDecided.has_value()) { CHECK(decided.error() == formula::ArithmeticError::Overflow); - // Only the exponentials of 43 to 44, at the places whose kept integers 128 bits hold. - CHECK(places >= 17); - CHECK(row.function == Transcendental::Exponential); - CHECK(Rational { 43 } <= Rational { row.numerator, row.denominator }); - CHECK(Rational { row.numerator, row.denominator } <= Rational { 44 }); ++undecided; } else @@ -255,11 +296,11 @@ TEST_CASE("transcendental kernel: every reference value is enclosed and rounds a ++compared; } } - // 39 rows, 9 places, 7 modes: a loop over nothing fails here. - REQUIRE(compared == 2457); - // Counted: the exponentials of 43, 43.7 and 44 at 17 and 18 places, whose kept integers 128 bits - // hold and whose 37th significant digit the kernel's enclosure cannot settle. - CHECK(undecided == 38); + // 51 rows, 9 places, 7 modes: a loop over nothing fails here. + REQUIRE(compared == 3213); + // None: an exponential's 192 fraction bits and a logarithm's 128 decide every row the reference's 40 + // digits decide. At 128 fraction bits the exponentials of 43 to 44 at 17 and 18 places were not. + CHECK(undecided == 0); // Three of them written out, so that a reader sees the digits. CHECK(kernel_rounding(Transcendental::NaturalLogarithm, Rational { 2 }, 18, RoundingMode::Floor) == Rational::from_decimal(693'147'180'559'945'309, -18)); @@ -269,10 +310,11 @@ TEST_CASE("transcendental kernel: every reference value is enclosed and rounds a == Rational::from_decimal(2'718'281'828'459'045'235, -18)); } -TEST_CASE("transcendental kernel: an enclosure is at most 2^-118 wide", "[transcendental]") +TEST_CASE("transcendental kernel: an enclosure is at most 2^-120 wide for a logarithm and 2^-183 of the value for an exponential", + "[transcendental]") { // Absolute for the logarithms, whose ends share the denominator 2^128: at most 2^8 units apart (2^-120). - // Relative for the exponential: upper - lower at most lower / 2^118. + // Relative for the exponential: upper - lower at most lower / 2^183, since its lower numerator is at least 2^192. Word const unit = *detail::shift_left_checked_or_none(Word::from_u64(1), 128); for (Reference const& row: references) { @@ -284,7 +326,7 @@ TEST_CASE("transcendental kernel: an enclosure is at most 2^-118 wide", "[transc REQUIRE(nearer.denominator == farther.denominator); Word const width = *detail::sub_checked_or_none(farther.numerator, nearer.numerator); if (row.function == Transcendental::Exponential) - CHECK(*detail::shift_left_checked_or_none(width, 118) <= nearer.numerator); + CHECK(*detail::shift_left_checked_or_none(width, 183) <= nearer.numerator); else { CHECK(nearer.denominator == unit); @@ -313,11 +355,37 @@ TEST_CASE("transcendental kernel: an enclosure that straddles a tie is Overflow == Rational::from_decimal(1, -18)); } +TEST_CASE("transcendental kernel: a scaled quotient of 128-bit operands carries the bit its remainder shifts out", + "[transcendental]") +{ + // The divisor 2^128 - 1 leaves a remainder above 2^127, whose doubling passes 2^128: the shifted-out bit + // must still count. Checked against the general long division of the 384-bit word, a different route. + detail::UInt128 const divisor { ~std::uint64_t { 0 }, ~std::uint64_t { 0 } }; + detail::UInt128 const dividend { std::uint64_t { 1 } << 63, 5 }; + for (std::size_t const fractionBits: { std::size_t { 128 }, std::size_t { 192 } }) + { + INFO("fraction bits " << fractionBits); + detail::ScaledQuotient const scaled = fractionBits == 128 ? detail::scaled_quotient<128>(dividend, divisor) + : detail::scaled_quotient<192>(dividend, divisor); + detail::WideDivision const reference = detail::divmod( + *detail::shift_left_checked_or_none(Word::from_u128(dividend), fractionBits), Word::from_u128(divisor)); + CHECK(scaled.below == reference.quotient); + CHECK(scaled.exact == reference.remainder.is_zero()); + CHECK_FALSE(scaled.exact); + } + // An exact one: 3 * 2^100 / 2^100 is 3, to the last fraction bit. + detail::UInt128 const twoTo100 { std::uint64_t { 1 } << 36, 0 }; + detail::ScaledQuotient const three = detail::scaled_quotient<192>( + detail::UInt128 { std::uint64_t { 3 } << 36, 0 }, twoTo100); + CHECK(three.exact); + CHECK(three.below == *detail::shift_left_checked_or_none(Word::from_u64(3), 192)); +} + TEST_CASE("transcendental kernel: the kernel answers at compile time", "[transcendental]") { // The one deliberate compile-time check of the kernel. A whole rounding -- the enclosure and // decide_rounding -- in one constant evaluation, measured at about - // 239 000 steps on cl 19.51.36257, against a default budget of about 1 049 000. Every other check that runs the kernel + // 242 300 steps on cl 19.51.36257, against a default budget of about 1 049 000. Every other check that runs the kernel // runs at run time. STATIC_REQUIRE(kernel_rounding(Transcendental::DecimalLogarithm, Rational { 2 }, 3, RoundingMode::HalfEven) == Rational { 301, 1000 }); From 90f0d74e27637bfe11d70b21ff06dab71068fad2 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Sun, 4 Oct 2026 23:48:20 +0200 Subject: [PATCH 04/35] docs: state the logarithm and exponential limits of a 128-bit kernel Signed-off-by: Christian Parpart --- CHANGELOG.md | 6 ++++++ docs/expressions.md | 17 ++++++++++------- docs/numeric-headroom.md | 8 -------- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2fed76..efd9ec7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,12 @@ change is recorded here. count above 2^64 - 1 names the whole count. Code that reads `lookupKey` for such a step reads both. - `Rational`'s converting constructor takes every built-in integer type of at most 64 bits except `bool`, exactly, `std::uint64_t` now among them; a wider built-in integer is refused. A constructor from `Int128` is added. +- **`rounded_ln`, `rounded_log10` and `rounded_exp` take every argument a `Rational` holds.** Their integer kernel + narrowed the argument's numerator and denominator to 64 bits and answered `Overflow` beyond; `ln` of 2^70 is now + 48.5203 at 4 places. `rounded_exp` answers up to 88.7, past which no value fits a `Rational`, wherever the result + fits the declared places: e^45 to 18 places, e^88 to whole units. The exponential is computed with 192 fraction + bits, so a result as wide as a `Rational` is still decided: e^43 to 18 places, `Overflow` before though the result + fits, now answers. ## [0.3.0] - 2026-10-01 diff --git a/docs/expressions.md b/docs/expressions.md index bdd8703..95e44c4 100644 --- a/docs/expressions.md +++ b/docs/expressions.md @@ -509,19 +509,22 @@ whose `lnAt` helper evaluates `rounded_ln` at the given ratio.) The places are the method's own, and at most 18; the result must fit a `Rational` there, which any logarithm does: the `log10` of 10^18 - 1 is -reported to all 18 places. The integer kernel takes an argument whose -numerator and denominator each fit 64 bits, the range it was built for: a -wider argument, which a `Rational` can hold, is `Overflow`, as `log10` of -2^70 is, and so is `exp` of more than 44. Two kinds of wide argument never -reach the kernel: a power of ten, 10^19 up to 10^38 or one over it, is +reported to all 18 places. The integer kernel takes every argument a +`Rational` holds: `ln` of 2^127 - 1 is 88.029691931113054295 at 18 places, +under `Floor`. `exp` of more than 88.7 is `Overflow`, since e^x is then past +the largest `Rational`; below that it answers wherever the result fits the +declared places -- e^45 to 18 places, e^88 to whole units -- and is +`Overflow` where it does not, as e^88.5 is at every places. Two kinds of +argument never reach the kernel: a power of ten, 10^-38 up to 10^38, is answered exactly, so `log10` of 10^30 is 30; and `exp` of less than -43 is 0, or one unit under `Ceiling` and `AwayFromZero`, whatever its width. Only ln 1, log10 10^k and exp 0 can tie, and the mode breaks the tie as `rounded<>` does: `log10` of 10^15 at -1 places is 20, 10 or 20 under `HalfAwayFromZero`, `HalfTowardZero` and `HalfEven`. A rounding the computation cannot decide -- -a value within its width, under 2^-118 (relative, for `exp`), of a rounding -boundary -- is `Overflow`, never a guess. `rounded<...>(ln(x))` is not +a value within its width of a rounding boundary, under 2^-120 for a +logarithm and under 2^-183 of the value for `exp` -- is `Overflow`, never a +guess. `rounded<...>(ln(x))` is not `rounded_ln`: the plain logarithm fails before the rounding sees a value, as `rounded<...>(sqrt(x))` does. diff --git a/docs/numeric-headroom.md b/docs/numeric-headroom.md index d0ab03c..5e7f22c 100644 --- a/docs/numeric-headroom.md +++ b/docs/numeric-headroom.md @@ -421,14 +421,6 @@ Only the integer `Rational` stores changed. These stay as they were: - **Rounding's decimal places**, `from_decimal`'s exponents and the `_r` literal's 18 places and 64-bit mantissa ([Numbers](numbers.md#limits)). Widening them is a separate decision. -- **The logarithm and exponential kernel** (`detail/transcendental.hpp`) - takes an argument whose numerator and denominator each fit 64 bits, the - range it was built for; a wider argument, which a `Rational` can now hold, - is `Overflow`, and so is the exponential of more than 44. Two kinds of - wide argument never reach it: a power of ten, 10^19 up to 10^38 or one - over it, whose logarithm is exact, and an exponential of less than -43, - which is 0, or one unit under `Ceiling` and `AwayFromZero`, whatever its - width. - **The 64-bit fields** of `Unit`, `Band` and `Breakpoint`: `band` and `breakpoint` refuse a `Rational` bound or key that does not fit them. From d461345bb5006ae2d1da6f7d0e19a6127f5925bd Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Sun, 4 Oct 2026 23:56:11 +0200 Subject: [PATCH 05/35] feat: refuse a dimensionless unit with a scale and no symbol A number in such a unit is in a scale nothing names: a trace showed one half in hundredths as 50, and no spelling of the unit could say so, since the coherent dimensionless unit is written as nothing. Every class that holds a unit as a template argument now refuses one, beside the checks it already makes: a quantity's description and the three nodes that read a quantity, a constant, every rounding, every table's key and result, a numeric value, a conformity check, a rounding rule and its override. A dimensioned unit with no symbol is still accepted and shown in the coherent unit. This breaks code that declares such a unit: give it a symbol, or declare the quantity in scale 1. Signed-off-by: Christian Parpart --- CHANGELOG.md | 6 ++++ docs/citations.md | 10 +++--- docs/dimensions.md | 11 ++++++- docs/expressions.md | 10 +++--- docs/tracing.md | 3 +- include/formula-cpp/binning.hpp | 1 + include/formula-cpp/conformity.hpp | 1 + include/formula-cpp/critical_value.hpp | 1 + include/formula-cpp/curve.hpp | 1 + include/formula-cpp/escape.hpp | 1 + include/formula-cpp/expression.hpp | 3 ++ include/formula-cpp/lookup.hpp | 5 +++ include/formula-cpp/method.hpp | 2 ++ include/formula-cpp/observations.hpp | 1 + include/formula-cpp/opaque.hpp | 1 + include/formula-cpp/overlay.hpp | 2 ++ include/formula-cpp/quantity.hpp | 17 ++++++++++ include/formula-cpp/rounded_root.hpp | 1 + include/formula-cpp/rounding_node.hpp | 2 ++ include/formula-cpp/series.hpp | 3 ++ include/formula-cpp/snap.hpp | 1 + include/formula-cpp/trace_render.hpp | 6 +++- include/formula-cpp/unit.hpp | 33 +++++++++++++++++++ test/CMakeLists.txt | 11 +++++++ ...ed_scalar_unit_without_symbol_constant.cpp | 17 ++++++++++ ...scaled_scalar_unit_without_symbol_snap.cpp | 28 ++++++++++++++++ .../scaled_scalar_unit_without_symbol_var.cpp | 22 +++++++++++++ test/unit_tests.cpp | 25 ++++++++++++++ 28 files changed, 212 insertions(+), 13 deletions(-) create mode 100644 test/negative/scaled_scalar_unit_without_symbol_constant.cpp create mode 100644 test/negative/scaled_scalar_unit_without_symbol_snap.cpp create mode 100644 test/negative/scaled_scalar_unit_without_symbol_var.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index b2fed76..d606138 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,12 @@ change is recorded here. count above 2^64 - 1 names the whole count. Code that reads `lookupKey` for such a step reads both. - `Rational`'s converting constructor takes every built-in integer type of at most 64 bits except `bool`, exactly, `std::uint64_t` now among them; a wider built-in integer is refused. A constructor from `Int128` is added. +- **A dimensionless unit with a scale or an offset and no symbol is refused at compile time**, wherever it is + written: as a quantity's unit, a constant's, a rounding's, or a table's key or result. A trace showed a value in + such a unit as a bare number in a scale nothing named (one half in hundredths read `50`), and no spelling of the + unit could name it. This breaks code that declares one: give the unit a symbol (`%`, `ppm`, or the author's own), + or declare the quantity in scale 1. The refusal reads `formula: a dimensionless unit with a scale must have a + symbol`. A dimensioned unit with no symbol is still accepted, and shown in the coherent unit. ## [0.3.0] - 2026-10-01 diff --git a/docs/citations.md b/docs/citations.md index 5c4d3f5..363a8b7 100644 --- a/docs/citations.md +++ b/docs/citations.md @@ -145,22 +145,22 @@ which gives, verbatim but for the paths, shown relative to the repository, on MSVC's `cl.exe` (19.51, `cl-debug` preset): ``` -include\formula-cpp/expression.hpp(186): error C2338: static assertion failed: 'formula: the two sides of this addition or subtraction measure different dimensions; the offending operands appear in this diagnostic as the template arguments of RequireAddendsAgree' -include\formula-cpp/expression.hpp(186): note: the template instantiation context (the oldest one first) is +include\formula-cpp/expression.hpp(189): error C2338: static assertion failed: 'formula: the two sides of this addition or subtraction measure different dimensions; the offending operands appear in this diagnostic as the template arguments of RequireAddendsAgree' +include\formula-cpp/expression.hpp(189): note: the template instantiation context (the oldest one first) is test\negative\documented_dimension_mismatch.cpp(14): note: see reference to function template instantiation 'auto formula::operator +>,formula::VarNode>(Left,Right) noexcept' being compiled with [ Left=formula::DocumentedNode>, Right=formula::VarNode ] -include\formula-cpp/expression.hpp(271): note: see reference to class template instantiation 'formula::BinaryNode>,formula::VarNode>' being compiled -include\formula-cpp/expression.hpp(244): note: see reference to class template instantiation 'formula::detail::AdditiveDimensionsAgree' being compiled +include\formula-cpp/expression.hpp(274): note: see reference to class template instantiation 'formula::BinaryNode>,formula::VarNode>' being compiled +include\formula-cpp/expression.hpp(247): note: see reference to class template instantiation 'formula::detail::AdditiveDimensionsAgree' being compiled with [ Left=formula::DocumentedNode>, Right=formula::VarNode ] -include\formula-cpp/expression.hpp(203): note: see reference to class template instantiation 'formula::detail::RequireAddendsAgree' being compiled +include\formula-cpp/expression.hpp(206): note: see reference to class template instantiation 'formula::detail::RequireAddendsAgree' being compiled with [ Left=formula::DocumentedNode>, diff --git a/docs/dimensions.md b/docs/dimensions.md index a45aa2d..18a2045 100644 --- a/docs/dimensions.md +++ b/docs/dimensions.md @@ -130,7 +130,7 @@ A `formula::Unit` is a small aggregate, and every field earns its place: | `dimension` | which quantity this unit measures | | `magnitudeNumerator` / `magnitudeDenominator` | the exact multiplicative factor to the coherent unit -- the coherent SI unit, times one of each named base dimension -- as an integer ratio | | `offsetNumerator` / `offsetDenominator` | the exact additive offset, for an affine scale such as degrees Celsius or degrees Fahrenheit | -| `symbolText` | a fixed-capacity display symbol (a `Symbol`, not a `std::string_view`) | +| `symbolText` | a fixed-capacity display symbol (a `Symbol`, not a `std::string_view`); required for a dimensionless unit with a scale | | `decimals` | the declared display precision | | `bounds` | an optional valid range, in the unit's own scale | @@ -143,6 +143,15 @@ fixed-size integer and character-array fields here, with the convenient types (`Rational`, `std::string_view`) appearing only at the point of use, via `formula::view()` and the conversion functions below. +A dimensionless unit with a scale or an offset must have a symbol. One half in +hundredths with no symbol would be shown as `50`, a number in a scale nothing +names, and no spelling of the unit could name it: the coherent dimensionless +unit is written as nothing. Such a unit is refused wherever it is written -- as +a quantity's unit, a constant's, a rounding's, or a table's key or result -- +with `formula: a dimensionless unit with a scale must have a symbol`. A +dimensioned unit may have no symbol: its values are shown in the coherent unit, +which its dimension spells. + The `formula::unit::` namespace declares fifty-six of these: the coherent SI units (`Metre`, `Kilogram`, `Second`, `Kelvin`, `Newton`, `Pascal`, `Watt`, ...) alongside scaled ones (`Millimetre`, `Tonne`, `Hour`, `Megapascal`, diff --git a/docs/expressions.md b/docs/expressions.md index bdd8703..9dd5a3b 100644 --- a/docs/expressions.md +++ b/docs/expressions.md @@ -67,22 +67,22 @@ the repository, on MSVC's `cl.exe` (19.51, from Visual Studio's `cl-debug` preset): ``` -include\formula-cpp/expression.hpp(186): error C2338: static assertion failed: 'formula: the two sides of this addition or subtraction measure different dimensions; the offending operands appear in this diagnostic as the template arguments of RequireAddendsAgree' -include\formula-cpp/expression.hpp(186): note: the template instantiation context (the oldest one first) is +include\formula-cpp/expression.hpp(189): error C2338: static assertion failed: 'formula: the two sides of this addition or subtraction measure different dimensions; the offending operands appear in this diagnostic as the template arguments of RequireAddendsAgree' +include\formula-cpp/expression.hpp(189): note: the template instantiation context (the oldest one first) is test\negative\quantity_alias_add_dimension_mismatch.cpp(12): note: see reference to function template instantiation 'auto formula::operator +,formula::VarNode>(Left,Right) noexcept' being compiled with [ Left=formula::VarNode, Right=formula::VarNode ] -include\formula-cpp/expression.hpp(271): note: see reference to class template instantiation 'formula::BinaryNode,formula::VarNode>' being compiled -include\formula-cpp/expression.hpp(244): note: see reference to class template instantiation 'formula::detail::AdditiveDimensionsAgree' being compiled +include\formula-cpp/expression.hpp(274): note: see reference to class template instantiation 'formula::BinaryNode,formula::VarNode>' being compiled +include\formula-cpp/expression.hpp(247): note: see reference to class template instantiation 'formula::detail::AdditiveDimensionsAgree' being compiled with [ Left=formula::VarNode, Right=formula::VarNode ] -include\formula-cpp/expression.hpp(203): note: see reference to class template instantiation 'formula::detail::RequireAddendsAgree' being compiled +include\formula-cpp/expression.hpp(206): note: see reference to class template instantiation 'formula::detail::RequireAddendsAgree' being compiled with [ Left=formula::VarNode, diff --git a/docs/tracing.md b/docs/tracing.md index 5670674..a4cc92a 100644 --- a/docs/tracing.md +++ b/docs/tracing.md @@ -377,7 +377,8 @@ energy. A computed mass reads `kg`, a computed length `m`. Only a dimensionless value is a bare number: `#1 / #2` above, a ratio of two volumes, reads `3/5`. A value declared in a unit of the author's own that has no symbol reads in the coherent unit too, converted, since its number alone could not -say what scale it is on. +say what scale it is on. A dimensionless unit with a scale must have a symbol, +so a bare number is always a value at scale 1. A computed step borrows its unit off the steps it read in these cases: diff --git a/include/formula-cpp/binning.hpp b/include/formula-cpp/binning.hpp index 9ba41d0..9a53cb6 100644 --- a/include/formula-cpp/binning.hpp +++ b/include/formula-cpp/binning.hpp @@ -143,6 +143,7 @@ struct BinnedNode: SeriesNodeBase static_assert(std::conditional_t, std::true_type>::value); + static_assert(detail::RequireNamedScaledScalar::value); /// The observations counted. No `{}` initialiser, deliberately: see /// `Corrections` (`lookup.hpp`). diff --git a/include/formula-cpp/conformity.hpp b/include/formula-cpp/conformity.hpp index 83c5baa..c730bb6 100644 --- a/include/formula-cpp/conformity.hpp +++ b/include/formula-cpp/conformity.hpp @@ -346,6 +346,7 @@ struct Conformity static_assert(std::conditional_t(), detail::RequireConformityUnitMatches, std::true_type>::value); + static_assert(detail::RequireNamedScaledScalar::value); /// The series judged. S subject; diff --git a/include/formula-cpp/critical_value.hpp b/include/formula-cpp/critical_value.hpp index 6a745c8..62ed0c5 100644 --- a/include/formula-cpp/critical_value.hpp +++ b/include/formula-cpp/critical_value.hpp @@ -379,6 +379,7 @@ struct SampleSizeLookupNode: NodeBase static_assert(RequireValidSampleSizeTable::value); static_assert(detail::RequireSampleCountScalar::value); static_assert(detail::RequireSampleCountInOne::value); + static_assert(detail::RequireNamedScaledScalar::value); /// One value per declared size, in `unit`, in the table's order -- the /// table's contents, and `Corrections` for that type's reason: a short diff --git a/include/formula-cpp/curve.hpp b/include/formula-cpp/curve.hpp index e3e372e..b2a59af 100644 --- a/include/formula-cpp/curve.hpp +++ b/include/formula-cpp/curve.hpp @@ -108,6 +108,7 @@ struct DomainNode: SeriesNodeBase { static_assert(detail::RequireDomainNotEmpty::value); static_assert(RequireValidBreakpointTable::value); + static_assert(detail::RequireNamedScaledScalar::value); /// The unit the points are declared in. static constexpr Unit unit = U; diff --git a/include/formula-cpp/escape.hpp b/include/formula-cpp/escape.hpp index ff8ca50..d5b6dee 100644 --- a/include/formula-cpp/escape.hpp +++ b/include/formula-cpp/escape.hpp @@ -87,6 +87,7 @@ struct NumericValueNode: NodeBase "over a bare number rather than over a quantity; one that is empty, blank, or only " "NUL bytes defeats the only safeguard this escape hatch has"); static_assert(detail::RequireEscapeUnitMatches::value); + static_assert(detail::RequireNamedScaledScalar::value); /// The expression whose numeric value is taken. /// diff --git a/include/formula-cpp/expression.hpp b/include/formula-cpp/expression.hpp index 95c3a1d..e532224 100644 --- a/include/formula-cpp/expression.hpp +++ b/include/formula-cpp/expression.hpp @@ -50,6 +50,7 @@ struct VarNode: NodeBase "formula: this quantity describes a dimension its own unit does not measure, so " "no formula containing it can be trusted; the quantity appears in this " "diagnostic as the template argument of VarNode"); + static_assert(detail::RequireNamedScaledScalar::unit>::value); /// The quantity this node names -- the key an `Environment` is asked with. using quantity = Q; @@ -75,6 +76,8 @@ inline constexpr VarNode var {}; template struct ConstantNode: NodeBase { + static_assert(detail::RequireNamedScaledScalar::value); + /// The coefficient, in terms of `unit`. Rational number {}; diff --git a/include/formula-cpp/lookup.hpp b/include/formula-cpp/lookup.hpp index c85d778..9931fe7 100644 --- a/include/formula-cpp/lookup.hpp +++ b/include/formula-cpp/lookup.hpp @@ -767,6 +767,8 @@ struct BandedLookupNode: NodeBase { static_assert(RequireValidBandTable::value); static_assert(detail::RequireLookupKeyMatches::value); + static_assert(detail::RequireNamedScaledScalar::value); + static_assert(detail::RequireNamedScaledScalar::value); /// One correction per band, stated in `unit` -- the table's *contents*, /// runtime state for the same reason `ConstantNode::number` is. See the @@ -1173,6 +1175,7 @@ struct ExactLookupNode: NodeBase { static_assert(detail::RequireScopedEnumKey>::value); static_assert(RequireValidKeyTable::value); + static_assert(detail::RequireNamedScaledScalar::value); /// One correction per key, stated in `unit`, in the same order `keys` /// declares -- the table's *contents*, runtime state for the same reason @@ -1807,6 +1810,8 @@ struct InterpolatingLookupNode: NodeBase { static_assert(RequireValidBreakpointTable::value); static_assert(detail::RequireLookupKeyMatches::value); + static_assert(detail::RequireNamedScaledScalar::value); + static_assert(detail::RequireNamedScaledScalar::value); /// The value this table states at each breakpoint, in `unit`, in the same /// order `breakpoints` declares -- the table's *contents*, runtime state diff --git a/include/formula-cpp/method.hpp b/include/formula-cpp/method.hpp index a9eff62..71c0ab3 100644 --- a/include/formula-cpp/method.hpp +++ b/include/formula-cpp/method.hpp @@ -1081,6 +1081,8 @@ namespace detail template class RoundingRule { + static_assert(detail::RequireNamedScaledScalar::value); + public: /// The unit the rounding happens in -- see `rounding_node.hpp` for why a /// rounding that does not name one means nothing. diff --git a/include/formula-cpp/observations.hpp b/include/formula-cpp/observations.hpp index 0d69b67..1837bcd 100644 --- a/include/formula-cpp/observations.hpp +++ b/include/formula-cpp/observations.hpp @@ -58,6 +58,7 @@ struct ObservationsVarNode: ObservationsNodeBase "formula: this quantity describes a dimension its own unit does not measure, so " "no formula containing it can be trusted; the quantity appears in this " "diagnostic as the template argument of ObservationsVarNode"); + static_assert(detail::RequireNamedScaledScalar::unit>::value); /// The quantity this node names -- the key an `Environment` is asked with. using quantity = Q; diff --git a/include/formula-cpp/opaque.hpp b/include/formula-cpp/opaque.hpp index 2564f28..517e0ed 100644 --- a/include/formula-cpp/opaque.hpp +++ b/include/formula-cpp/opaque.hpp @@ -1027,6 +1027,7 @@ struct RoundedOpaqueOutputNode: NodeBase U, !OpaqueOutputNode::refused && U.dimension == OpaqueOutputNode::dimension>::value); + static_assert(detail::RequireNamedScaledScalar::value); /// The call whose output this is. Evaluating this node evaluates it whole. Call call; diff --git a/include/formula-cpp/overlay.hpp b/include/formula-cpp/overlay.hpp index cc012ae..050e210 100644 --- a/include/formula-cpp/overlay.hpp +++ b/include/formula-cpp/overlay.hpp @@ -696,6 +696,8 @@ template template struct RoundingOverride { + static_assert(detail::RequireNamedScaledScalar::value); + /// The rule that replaces the method's own, as `rounding_rule<>()` would /// spell it. using rule = RoundingRule; diff --git a/include/formula-cpp/quantity.hpp b/include/formula-cpp/quantity.hpp index 2fb4d1e..e443fd1 100644 --- a/include/formula-cpp/quantity.hpp +++ b/include/formula-cpp/quantity.hpp @@ -10,6 +10,7 @@ #include #include +#include #include namespace formula @@ -220,6 +221,21 @@ concept Described = requires { template concept DescribesConsistentDimension = Described && Describe::dimension == Describe::unit.dimension; +namespace detail +{ + /// `RequireNamedScaledScalar` of a described type's unit, asked only once the type is described: an + /// undescribed type has no unit to ask about, and is already refused, in full, by `RequireDescribed`. + template > + struct RequireDescribedUnitNamesItsScale: std::true_type + { + }; + + template + struct RequireDescribedUnitNamesItsScale: RequireNamedScaledScalar::unit> + { + }; +} // namespace detail + /// Fails to compile, in our own words, when `T` declares no metadata, or /// declares metadata whose `dimension` contradicts its own `unit`. /// @@ -246,6 +262,7 @@ struct RequireDescribed "A quantity's unit already carries a dimension; declaring a second one that " "disagrees mislabels every value read through it -- derive Describe::dimension " "from Describe::unit.dimension instead of stating it independently"); + static_assert(detail::RequireDescribedUnitNamesItsScale::value); /// Always `true` once reached -- both `static_assert`s above already failed /// compilation otherwise. Present so `::value` is the spelling that diff --git a/include/formula-cpp/rounded_root.hpp b/include/formula-cpp/rounded_root.hpp index 8d8ba23..8c6d39a 100644 --- a/include/formula-cpp/rounded_root.hpp +++ b/include/formula-cpp/rounded_root.hpp @@ -281,6 +281,7 @@ struct RoundedRootNode: NodeBase { static_assert(detail::RequireRootUnitMatches::value); static_assert(detail::RequireRootUnitWithoutOffset::value); + static_assert(detail::RequireNamedScaledScalar::value); /// The expression whose square root is taken: a variance, a mean square, /// a sum of squared uncertainties. diff --git a/include/formula-cpp/rounding_node.hpp b/include/formula-cpp/rounding_node.hpp index 0e194d5..2b8bc00 100644 --- a/include/formula-cpp/rounding_node.hpp +++ b/include/formula-cpp/rounding_node.hpp @@ -49,6 +49,7 @@ template struct RoundNode: NodeBase { static_assert(detail::RequireRoundingUnitMatches::value); + static_assert(detail::RequireNamedScaledScalar::value); /// The expression being rounded. /// @@ -75,6 +76,7 @@ template struct RoundSignificantNode: NodeBase { static_assert(detail::RequireRoundingUnitMatches::value); + static_assert(detail::RequireNamedScaledScalar::value); /// The expression being rounded. /// diff --git a/include/formula-cpp/series.hpp b/include/formula-cpp/series.hpp index f62dc61..89e8417 100644 --- a/include/formula-cpp/series.hpp +++ b/include/formula-cpp/series.hpp @@ -70,6 +70,7 @@ struct SeriesVarNode: SeriesNodeBase "formula: this quantity describes a dimension its own unit does not measure, so " "no formula containing it can be trusted; the quantity appears in this " "diagnostic as the template argument of SeriesVarNode"); + static_assert(detail::RequireNamedScaledScalar::unit>::value); static_assert(N > 0, "formula: this series has no elements; a series is a value at each point of a method's domain, " "and a domain of no points has nothing to sum, round or trace -- the quantity appears in this " @@ -179,6 +180,7 @@ struct SeriesConstantNode: SeriesNodeBase "formula: this series constant has no elements; a series is a value at each point of a method's " "domain, and a domain of no points has nothing to sum, round or trace -- give it at least one " "value"); + static_assert(detail::RequireNamedScaledScalar::value); /// The values. No `{}` initialiser, deliberately: see `Elements`. Elements elements; @@ -495,6 +497,7 @@ struct ElementwiseRoundNode: SeriesNodeBase static_assert(std::conditional_t, std::true_type>::value); + static_assert(detail::RequireNamedScaledScalar::value); /// The series rounded. No `{}` initialiser, deliberately: see /// `Corrections` (`lookup.hpp`). diff --git a/include/formula-cpp/snap.hpp b/include/formula-cpp/snap.hpp index 1ccd869..82ca274 100644 --- a/include/formula-cpp/snap.hpp +++ b/include/formula-cpp/snap.hpp @@ -169,6 +169,7 @@ struct SnapNode: NodeBase static_assert(detail::RequirePermittedSetNotEmpty::value); static_assert(std::conditional_t<(Permitted.size() > 0), RequireValidBreakpointTable, std::true_type>::value); static_assert(std::conditional_t, std::true_type>::value); + static_assert(detail::RequireNamedScaledScalar::value); /// The expression whose value is snapped. No `{}` initialiser, /// deliberately: see `Corrections` (`lookup.hpp`). diff --git a/include/formula-cpp/trace_render.hpp b/include/formula-cpp/trace_render.hpp index 2993ba8..1993733 100644 --- a/include/formula-cpp/trace_render.hpp +++ b/include/formula-cpp/trace_render.hpp @@ -659,6 +659,10 @@ namespace detail /// bound a table, a curve or a permitted set declared /// (`shown_bound_text`), so that every number on a line is in the unit /// written after it. + /// A dimensionless unit with no symbol is always at scale 1 here: + /// one with a scale is refused where it is written + /// (`RequireNamedScaledScalar`, `unit.hpp`), so its bare number is the + /// value. [[nodiscard]] inline bool spells_coherent_unit(Unit const& declared, Dimension dimension) { return view(declared.symbolText).empty() && !(dimension == dim::Scalar); @@ -675,7 +679,7 @@ namespace detail /// The text written after a value shown in `shown_unit_of(@p declared, /// @p dimension)`: the coherent unit's spelling, the declared unit's /// escaped symbol, or nothing for a dimensionless value in a unit with - /// no symbol. + /// no symbol, which is at scale 1. [[nodiscard]] inline std::string shown_unit_text(Unit const& declared, Dimension dimension) { return spells_coherent_unit(declared, dimension) ? coherent_unit_text(dimension) : unit_symbol_text(declared); diff --git a/include/formula-cpp/unit.hpp b/include/formula-cpp/unit.hpp index da5c586..4cc9e49 100644 --- a/include/formula-cpp/unit.hpp +++ b/include/formula-cpp/unit.hpp @@ -546,6 +546,39 @@ struct RequireSameUnitDimension static constexpr bool value = true; }; +namespace detail +{ + /// Whether @p candidate is a dimensionless unit with a scale and no symbol: a magnitude other than 1, or an + /// offset other than 0, compared as fractions, and an empty symbol. A number in such a unit is in a scale + /// nothing on its line can name -- one half in hundredths would read `50` -- and no spelling of the unit + /// itself can name it either, since a dimensionless coherent unit is written as nothing. A dimensioned unit + /// with no symbol is not one: its value is shown in the coherent unit, which its dimension spells. + [[nodiscard]] constexpr bool unnamed_scaled_scalar(Unit const& candidate) noexcept + { + return candidate.dimension == dim::Scalar && view(candidate.symbolText).empty() + && (candidate.magnitudeNumerator != candidate.magnitudeDenominator || candidate.offsetNumerator != 0); + } + + /// Fails to compile when @p U is a dimensionless unit with a scale and no symbol (`unnamed_scaled_scalar`). + /// Asserted in the class body of everything that holds a unit as a template argument -- a quantity's + /// description, a variable, a constant, a rounding, a key or a result of a table, a conformity check -- so + /// that such a unit is refused where it is written, never shown as a bare number in its scale. + /// + /// Same shape as `RequireSameUnitDimension` above, and the same caveat: it fires only when the type is + /// completed, so write `::value`. + template + struct RequireNamedScaledScalar + { + static_assert(!unnamed_scaled_scalar(U), + "formula: a dimensionless unit with a scale must have a symbol (for example \"%\"), or the " + "quantity must be declared in scale 1; the unit appears in this diagnostic as the template " + "argument of RequireNamedScaledScalar"); + + /// Always `true` once reached -- the `static_assert` above already failed compilation otherwise. + static constexpr bool value = true; + }; +} // namespace detail + /// Converts @p magnitude from @p from into @p to, exactly. /// /// Applies integer factors by multiply-then-divide rather than a precomputed diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 6290d5a..cf85a2e 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -338,6 +338,17 @@ formula_add_negative_test(unit_dimension_mismatch formula_add_negative_test(unit_currency_mismatch "formula: these two units measure different dimensions" EXPECT_COUNT 1) +# A dimensionless unit with a scale and no symbol: refused where a quantity +# declared in it is read, where a constant is stated in it, and where a table +# is keyed in it -- once each. +formula_add_negative_test(scaled_scalar_unit_without_symbol_var + "formula: a dimensionless unit with a scale must have a symbol" EXPECT_COUNT 1) +formula_add_negative_test(scaled_scalar_unit_without_symbol_constant + "formula: a dimensionless unit with a scale must have a symbol" EXPECT_COUNT 1) +formula_add_negative_test(scaled_scalar_unit_without_symbol_snap + "formula: a dimensionless unit with a scale must have a symbol" EXPECT_COUNT 1 + REJECT "key unit does not measure" "breakpoints do not strictly ascend") + # A conversion between measured quantities of different dimensions is refused where # it is written, with or without a value, and once. formula_add_negative_test(measured_convert_dimension_mismatch diff --git a/test/negative/scaled_scalar_unit_without_symbol_constant.cpp b/test/negative/scaled_scalar_unit_without_symbol_constant.cpp new file mode 100644 index 0000000..5abea7f --- /dev/null +++ b/test/negative/scaled_scalar_unit_without_symbol_constant.cpp @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: a dimensionless unit with a scale must have a symbol +// +// A coefficient stated in hundredths with no symbol: refused where it is +// written, once. +#include + +inline constexpr formula::Unit Hundredth { .dimension = formula::dim::Scalar, + .magnitudeNumerator = 1, + .magnitudeDenominator = 100 }; + +inline constexpr auto half = formula::constant(formula::Rational { 50 }); + +int main() +{ + return half.number == formula::Rational { 50 } ? 0 : 1; +} \ No newline at end of file diff --git a/test/negative/scaled_scalar_unit_without_symbol_snap.cpp b/test/negative/scaled_scalar_unit_without_symbol_snap.cpp new file mode 100644 index 0000000..e2a7574 --- /dev/null +++ b/test/negative/scaled_scalar_unit_without_symbol_snap.cpp @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: a dimensionless unit with a scale must have a symbol +// REJECT: key unit does not measure +// REJECT: breakpoints do not strictly ascend +// +// A snap keyed in hundredths with no symbol: its permitted values would be +// written as numbers in a scale no line names. Refused where the snap is +// written, once, and by nothing else: the key measures what the operand does, +// and the permitted set ascends. +#include + +inline constexpr formula::Unit Hundredth { .dimension = formula::dim::Scalar, + .magnitudeNumerator = 1, + .magnitudeDenominator = 100 }; + +struct Share: formula::Quantity +{ +}; + +inline constexpr formula::BreakpointTable<2> permitted { formula::breakpoint(25), formula::breakpoint(50) }; + +inline constexpr auto snap = + formula::snapped(formula::var); + +int main() +{ + return snap.tie == formula::SnapTie::TowardLower ? 0 : 1; +} \ No newline at end of file diff --git a/test/negative/scaled_scalar_unit_without_symbol_var.cpp b/test/negative/scaled_scalar_unit_without_symbol_var.cpp new file mode 100644 index 0000000..b108177 --- /dev/null +++ b/test/negative/scaled_scalar_unit_without_symbol_var.cpp @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: a dimensionless unit with a scale must have a symbol +// +// A quantity declared in hundredths with no symbol: one half would be shown as +// 50, a number in a scale no line names. Refused where the quantity is read, +// once. +#include + +inline constexpr formula::Unit Hundredth { .dimension = formula::dim::Scalar, + .magnitudeNumerator = 1, + .magnitudeDenominator = 100 }; + +struct Fraction: formula::Quantity +{ +}; + +inline constexpr auto fraction = formula::var; + +int main() +{ + return fraction.dimension == formula::dim::Scalar ? 0 : 1; +} \ No newline at end of file diff --git a/test/unit_tests.cpp b/test/unit_tests.cpp index 9a54946..1479cc8 100644 --- a/test/unit_tests.cpp +++ b/test/unit_tests.cpp @@ -1088,3 +1088,28 @@ TEST_CASE("a unit carrying a named base dimension has the same identity in every static_assert(TariffRebuilt == EuroPerKilowattHour); CHECK(formula_test::consume_tariff_unit(formula_test::TaggedUnit { 20 }) == 22); } + +TEST_CASE("a dimensionless unit with a scale and no symbol is the one a declaration refuses", "[unit]") +{ + // Hundredths with no symbol: one half would read 50, in a scale nothing names. + constexpr Unit unlabelledHundredth { .dimension = dim::Scalar, .magnitudeNumerator = 1, .magnitudeDenominator = 100 }; + // The same scale with an offset only. + constexpr Unit unlabelledShifted { .dimension = dim::Scalar, .offsetNumerator = 1, .offsetDenominator = 2 }; + // Scale 1 written as 7/7: still scale 1. + constexpr Unit unlabelledSevenSevenths { .dimension = dim::Scalar, .magnitudeNumerator = 7, .magnitudeDenominator = 7 }; + // A dimensioned unit with no symbol is shown in the coherent unit instead, and is not refused. + constexpr Unit unlabelledGram { .dimension = dim::Mass, .magnitudeNumerator = 1, .magnitudeDenominator = 1000 }; + + STATIC_REQUIRE(formula::detail::unnamed_scaled_scalar(unlabelledHundredth)); + STATIC_REQUIRE(formula::detail::unnamed_scaled_scalar(unlabelledShifted)); + STATIC_REQUIRE(!formula::detail::unnamed_scaled_scalar(unlabelledSevenSevenths)); + STATIC_REQUIRE(!formula::detail::unnamed_scaled_scalar(unlabelledGram)); + STATIC_REQUIRE(!formula::detail::unnamed_scaled_scalar(unit::One)); + // Every shipped scaled dimensionless unit has a symbol. + STATIC_REQUIRE(!formula::detail::unnamed_scaled_scalar(unit::Percent)); + STATIC_REQUIRE(!formula::detail::unnamed_scaled_scalar(unit::PerMille)); + STATIC_REQUIRE(!formula::detail::unnamed_scaled_scalar(unit::PartsPerMillion)); + STATIC_REQUIRE(!formula::detail::unnamed_scaled_scalar(unit::MilligramPerKilogram)); + STATIC_REQUIRE(formula::detail::RequireNamedScaledScalar::value); + STATIC_REQUIRE(formula::detail::RequireNamedScaledScalar::value); +} \ No newline at end of file From dd42c783963fc5a672b1b523984bda89b8114c0f Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Sun, 4 Oct 2026 23:57:33 +0200 Subject: [PATCH 06/35] fix: name the exponential series' product as the kernel's widest The series' product of a term and the reduced argument is below 2^384, the kernel word's width, and is what limits the exponential to 192 fraction bits; the comments now say so. Also pin the exponential of a negative argument with a 127-bit denominator, which rounds to 1, or one unit below it under Floor. Signed-off-by: Christian Parpart --- include/formula-cpp/detail/transcendental.hpp | 10 +++++++--- test/rounded_transcendental_tests.cpp | 5 +++++ test/transcendental_tests.cpp | 3 ++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/include/formula-cpp/detail/transcendental.hpp b/include/formula-cpp/detail/transcendental.hpp index 0bbffe2..46d5a32 100644 --- a/include/formula-cpp/detail/transcendental.hpp +++ b/include/formula-cpp/detail/transcendental.hpp @@ -46,7 +46,9 @@ /// Either way 0 <= R < L' + 1, so r < ln 2 + 2^-184, and r 2^192 - R < 128: one unit for X, and one per /// multiple of ln 2's unit, k <= 127 or m <= 63. E = sum T_j, T_0 = 2^192, /// T_j = floor(floor(T_{j-1} R / 2^192) / j), until T_j = 0 (by j = 43 for r < 0.7; `TaylorTermLimit` = 50 -/// refuses a longer one), so E <= exp(R 2^-192) 2^192 <= exp(r) 2^192. Each T_j is short by +/// refuses a longer one), so E <= exp(R 2^-192) 2^192 <= exp(r) 2^192. T_{j-1} <= 2^192 and R <= L' < +/// 0.7 2^192, so the product T_{j-1} R is below 2^384: the kernel's widest, and the reason 192 fraction +/// bits are the most 384 bits allow. Each T_j is short by /// e_j < e_{j-1} r / j + 1 < 2, and the tail after the last term is below 3, so /// exp(R 2^-192) 2^192 - E < 2 · 50 + 3; the 128 units of r add less than 2 · 1.0001 · 128 < 257. So /// exp(r) 2^192 < E + 360 <= E + `ExponentialSlack` = 512: lower = E 2^k / 2^192, @@ -76,8 +78,10 @@ namespace formula::detail { -/// The kernel's width, 384 bits: room for its widest product (under 2^262) and numerator (under 2^321), -/// and for `decide_rounding`'s scaling by up to 10^18 of every end it is handed (under 2^381). +/// The kernel's width, 384 bits: room for its widest product, the exponential series' T_{j-1} R (under +/// 2^384), for the logarithm's widest product, upper_ln (M + 1) (under 2^262), for the exponential's widest +/// numerator (under 2^321), and for `decide_rounding`'s scaling by up to 10^18 of every end it is handed +/// (under 2^381). inline constexpr std::size_t KernelLimbs = 12; /// A value in the kernel's fixed point. using KernelWord = WideUnsigned; diff --git a/test/rounded_transcendental_tests.cpp b/test/rounded_transcendental_tests.cpp index f90d7a1..e2124d4 100644 --- a/test/rounded_transcendental_tests.cpp +++ b/test/rounded_transcendental_tests.cpp @@ -269,6 +269,11 @@ TEST_CASE("rounded_transcendental: an argument as wide as a Rational holds is an CHECK(expAt(Rational { 1, largest }) == Rational { 1 }); CHECK(expAt(Rational { 1, largest }) == Rational::from_decimal(1'000'000'000'000'000'001, -18)); + // exp -1/(2^127 - 1) = 1 - 5.9 * 10^-39, a negative argument with a 127-bit denominator: 1 to nearest, + // and one unit below 1 under Floor. + CHECK(expAt(Rational { -1, largest }) == Rational { 1 }); + CHECK(expAt(Rational { -1, largest }) + == Rational::from_decimal(999'999'999'999'999'999, -18)); } TEST_CASE("rounded_transcendental: a percentage is read in the coherent unit", "[rounded_transcendental]") diff --git a/test/transcendental_tests.cpp b/test/transcendental_tests.cpp index 3fa0794..238398d 100644 --- a/test/transcendental_tests.cpp +++ b/test/transcendental_tests.cpp @@ -310,7 +310,8 @@ TEST_CASE("transcendental kernel: every reference value is enclosed and rounds a == Rational::from_decimal(2'718'281'828'459'045'235, -18)); } -TEST_CASE("transcendental kernel: an enclosure is at most 2^-120 wide for a logarithm and 2^-183 of the value for an exponential", +TEST_CASE("transcendental kernel: an enclosure is at most 2^-120 wide for a logarithm and 2^-183 of the value for an " + "exponential", "[transcendental]") { // Absolute for the logarithms, whose ends share the denominator 2^128: at most 2^8 units apart (2^-120). From 228ca60fcb321383c8bbe1640accf669a0a4dc3b Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Mon, 5 Oct 2026 00:00:07 +0200 Subject: [PATCH 07/35] docs: tidy the kernel's derivation comment Reflow the exponential bullet's paragraph, name log10's widest product as log10's own, and wrap two rows of the reference table within the column limit. Signed-off-by: Christian Parpart --- include/formula-cpp/detail/transcendental.hpp | 11 +++++------ test/transcendental_tests.cpp | 6 ++++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/include/formula-cpp/detail/transcendental.hpp b/include/formula-cpp/detail/transcendental.hpp index 46d5a32..bd088c3 100644 --- a/include/formula-cpp/detail/transcendental.hpp +++ b/include/formula-cpp/detail/transcendental.hpp @@ -36,8 +36,8 @@ /// 2^-128 apart, under 2^-120, absolute. /// - **log10** = ln log10(e). With M = floor(log10(e) 2^128): lower = floor(lower_ln M / 2^128), /// upper = floor(upper_ln (M + 1) / 2^128) + 1, under 254 · 0.44 + 89 + 2 < 203 units apart, since -/// |ln(a/b)| <= ln(2^127) < 89. upper_ln is below 89 · 2^128 + 254 < 2^135 and M + 1 below 2^127, so the -/// widest product, upper_ln (M + 1), is below 2^262. +/// |ln(a/b)| <= ln(2^127) < 89. upper_ln is below 89 · 2^128 + 254 < 2^135 and M + 1 below 2^127, so +/// log10's widest product, upper_ln (M + 1), is below 2^262. /// - **exp(x)**, x = a/b != 0, -43 <= x <= 887/10 (the rounded forms answer outside it), in F = 192 bits. /// X = floor(|x| 2^192), exact or one below. L' = floor(ln 2 2^192). For x > 0, k is the largest integer /// in [0, 127] with k (L' + 1) <= X (128 ln 2 > 887/10 bounds it), and R = X - k (L' + 1) <= r 2^192 for @@ -48,10 +48,9 @@ /// T_j = floor(floor(T_{j-1} R / 2^192) / j), until T_j = 0 (by j = 43 for r < 0.7; `TaylorTermLimit` = 50 /// refuses a longer one), so E <= exp(R 2^-192) 2^192 <= exp(r) 2^192. T_{j-1} <= 2^192 and R <= L' < /// 0.7 2^192, so the product T_{j-1} R is below 2^384: the kernel's widest, and the reason 192 fraction -/// bits are the most 384 bits allow. Each T_j is short by -/// e_j < e_{j-1} r / j + 1 < 2, and the tail after the last term is below 3, so -/// exp(R 2^-192) 2^192 - E < 2 · 50 + 3; the 128 units of r add less than 2 · 1.0001 · 128 < 257. So -/// exp(r) 2^192 < E + 360 <= E + `ExponentialSlack` = 512: lower = E 2^k / 2^192, +/// bits are the most 384 bits allow. Each T_j is short by e_j < e_{j-1} r / j + 1 < 2, and the tail after +/// the last term is below 3, so exp(R 2^-192) 2^192 - E < 2 · 50 + 3; the 128 units of r add less than +/// 2 · 1.0001 · 128 < 257. So exp(r) 2^192 < E + 360 <= E + `ExponentialSlack` = 512: lower = E 2^k / 2^192, /// upper = (E + 512) 2^k / 2^192 (for x < 0, denominator 2^(192+m)). Since E >= 2^192, the ends are at /// most 2^-183 of the value apart: at the largest result a `Rational` holds, 2^127 last kept units, that is /// 2^-56 of one unit. E < 2^193, so the widest numerator, (E + 512) 2^127, is below 2^321; diff --git a/test/transcendental_tests.cpp b/test/transcendental_tests.cpp index 238398d..fc568a4 100644 --- a/test/transcendental_tests.cpp +++ b/test/transcendental_tests.cpp @@ -190,12 +190,14 @@ constexpr std::array references { { { Transcendental::Exponential, 1, 4611686018427387904, false, "1000000000000000000216840434497100886825", 39 }, { Transcendental::Exponential, -1, 4611686018427387904, false, "9999999999999999997831595655028991132220", 40 }, { Transcendental::Exponential, 44, 1, false, "1285160011435930827580929963214309925780", 20 }, - { Transcendental::NaturalLogarithm, Rational::Int { 1 } << 70, 1, false, "4852030263919617165920624850207235976528", 38 }, + { Transcendental::NaturalLogarithm, Rational::Int { 1 } << 70, 1, false, + "4852030263919617165920624850207235976528", 38 }, { Transcendental::NaturalLogarithm, largestInt, 1, false, "8802969193111305429598847942518842414558", 38 }, { Transcendental::NaturalLogarithm, 1, largestInt, true, "8802969193111305429598847942518842414558", 38 }, { Transcendental::NaturalLogarithm, (Rational::Int { 1 } << 126) + 1, Rational::Int { 1 } << 126, false, "1175494350822287507968736537222245677811", 77 }, - { Transcendental::DecimalLogarithm, Rational::Int { 1 } << 70, 1, false, "2107209969647868366496172263071451187377", 38 }, + { Transcendental::DecimalLogarithm, Rational::Int { 1 } << 70, 1, false, + "2107209969647868366496172263071451187377", 38 }, { Transcendental::DecimalLogarithm, largestInt, 1, false, "3823080944932561179214483963001061439955", 38 }, { Transcendental::DecimalLogarithm, 1, largestInt, true, "3823080944932561179214483963001061439955", 38 }, { Transcendental::Exponential, 1, Rational::Int { 1 } << 64, false, "1000000000000000000054210108624275221701", 39 }, From 1c0e1ab34ce6401b2b84739afa0ebeaf940f6f2a Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Mon, 5 Oct 2026 00:05:20 +0200 Subject: [PATCH 08/35] test: pin the unnamed scaled unit's refusal where a quantity is measured A measurement names its quantity's description through RequireDescribed alone, with no variable node in sight, so that check is pinned by a case of its own. RequireDescribed's comments now name the three refusals it makes, the changelog entry leads with the break, and the new test files end in a newline. Signed-off-by: Christian Parpart --- CHANGELOG.md | 8 +++---- include/formula-cpp/quantity.hpp | 7 +++--- test/CMakeLists.txt | 6 +++-- ...ed_scalar_unit_without_symbol_constant.cpp | 2 +- ...ed_scalar_unit_without_symbol_measured.cpp | 22 +++++++++++++++++++ ...scaled_scalar_unit_without_symbol_snap.cpp | 2 +- .../scaled_scalar_unit_without_symbol_var.cpp | 2 +- test/unit_tests.cpp | 2 +- 8 files changed, 38 insertions(+), 13 deletions(-) create mode 100644 test/negative/scaled_scalar_unit_without_symbol_measured.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index d606138..c716fcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,12 +45,12 @@ change is recorded here. count above 2^64 - 1 names the whole count. Code that reads `lookupKey` for such a step reads both. - `Rational`'s converting constructor takes every built-in integer type of at most 64 bits except `bool`, exactly, `std::uint64_t` now among them; a wider built-in integer is refused. A constructor from `Int128` is added. -- **A dimensionless unit with a scale or an offset and no symbol is refused at compile time**, wherever it is +- **Breaking: a dimensionless unit with a scale or an offset and no symbol no longer compiles**, wherever it is written: as a quantity's unit, a constant's, a rounding's, or a table's key or result. A trace showed a value in such a unit as a bare number in a scale nothing named (one half in hundredths read `50`), and no spelling of the - unit could name it. This breaks code that declares one: give the unit a symbol (`%`, `ppm`, or the author's own), - or declare the quantity in scale 1. The refusal reads `formula: a dimensionless unit with a scale must have a - symbol`. A dimensioned unit with no symbol is still accepted, and shown in the coherent unit. + unit could name it. Give the unit a symbol (`%`, `ppm`, or the author's own), or declare the quantity in scale 1. + The refusal reads `formula: a dimensionless unit with a scale must have a symbol`. A dimensioned unit with no + symbol is still accepted, and shown in the coherent unit. ## [0.3.0] - 2026-10-01 diff --git a/include/formula-cpp/quantity.hpp b/include/formula-cpp/quantity.hpp index e443fd1..961a853 100644 --- a/include/formula-cpp/quantity.hpp +++ b/include/formula-cpp/quantity.hpp @@ -236,8 +236,9 @@ namespace detail }; } // namespace detail -/// Fails to compile, in our own words, when `T` declares no metadata, or -/// declares metadata whose `dimension` contradicts its own `unit`. +/// Fails to compile, in our own words, when `T` declares no metadata, +/// declares metadata whose `dimension` contradicts its own `unit`, or declares +/// it in a dimensionless unit with a scale and no symbol. /// /// The counterpart to `Describe` being silent: somewhere has to say what to do /// about it, and a bare "no member named 'symbol'" does not. Same shape as @@ -264,7 +265,7 @@ struct RequireDescribed "from Describe::unit.dimension instead of stating it independently"); static_assert(detail::RequireDescribedUnitNamesItsScale::value); - /// Always `true` once reached -- both `static_assert`s above already failed + /// Always `true` once reached -- the `static_assert`s above already failed /// compilation otherwise. Present so `::value` is the spelling that /// instantiates the class template; see the class comment for why that /// spelling matters. diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index cf85a2e..7a0f641 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -339,10 +339,12 @@ formula_add_negative_test(unit_currency_mismatch "formula: these two units measure different dimensions" EXPECT_COUNT 1) # A dimensionless unit with a scale and no symbol: refused where a quantity -# declared in it is read, where a constant is stated in it, and where a table -# is keyed in it -- once each. +# declared in it is read or measured, where a constant is stated in it, and +# where a table is keyed in it -- once each. formula_add_negative_test(scaled_scalar_unit_without_symbol_var "formula: a dimensionless unit with a scale must have a symbol" EXPECT_COUNT 1) +formula_add_negative_test(scaled_scalar_unit_without_symbol_measured + "formula: a dimensionless unit with a scale must have a symbol" EXPECT_COUNT 1) formula_add_negative_test(scaled_scalar_unit_without_symbol_constant "formula: a dimensionless unit with a scale must have a symbol" EXPECT_COUNT 1) formula_add_negative_test(scaled_scalar_unit_without_symbol_snap diff --git a/test/negative/scaled_scalar_unit_without_symbol_constant.cpp b/test/negative/scaled_scalar_unit_without_symbol_constant.cpp index 5abea7f..d95847b 100644 --- a/test/negative/scaled_scalar_unit_without_symbol_constant.cpp +++ b/test/negative/scaled_scalar_unit_without_symbol_constant.cpp @@ -14,4 +14,4 @@ inline constexpr auto half = formula::constant(formula::Rational { 50 int main() { return half.number == formula::Rational { 50 } ? 0 : 1; -} \ No newline at end of file +} diff --git a/test/negative/scaled_scalar_unit_without_symbol_measured.cpp b/test/negative/scaled_scalar_unit_without_symbol_measured.cpp new file mode 100644 index 0000000..081498f --- /dev/null +++ b/test/negative/scaled_scalar_unit_without_symbol_measured.cpp @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: a dimensionless unit with a scale must have a symbol +// +// A measurement of a quantity declared in hundredths with no symbol, named +// through Measured alone and never read by a formula: refused where the +// quantity's description is asked for, once. +#include + +inline constexpr formula::Unit Hundredth { .dimension = formula::dim::Scalar, + .magnitudeNumerator = 1, + .magnitudeDenominator = 100 }; + +struct Fraction: formula::Quantity +{ +}; + +int main() +{ + formula::Measured const half { formula::Rational { 50 } }; + (void) half; + return 0; +} diff --git a/test/negative/scaled_scalar_unit_without_symbol_snap.cpp b/test/negative/scaled_scalar_unit_without_symbol_snap.cpp index e2a7574..f97679d 100644 --- a/test/negative/scaled_scalar_unit_without_symbol_snap.cpp +++ b/test/negative/scaled_scalar_unit_without_symbol_snap.cpp @@ -25,4 +25,4 @@ inline constexpr auto snap = int main() { return snap.tie == formula::SnapTie::TowardLower ? 0 : 1; -} \ No newline at end of file +} diff --git a/test/negative/scaled_scalar_unit_without_symbol_var.cpp b/test/negative/scaled_scalar_unit_without_symbol_var.cpp index b108177..d88acf6 100644 --- a/test/negative/scaled_scalar_unit_without_symbol_var.cpp +++ b/test/negative/scaled_scalar_unit_without_symbol_var.cpp @@ -19,4 +19,4 @@ inline constexpr auto fraction = formula::var; int main() { return fraction.dimension == formula::dim::Scalar ? 0 : 1; -} \ No newline at end of file +} diff --git a/test/unit_tests.cpp b/test/unit_tests.cpp index 1479cc8..861cc73 100644 --- a/test/unit_tests.cpp +++ b/test/unit_tests.cpp @@ -1112,4 +1112,4 @@ TEST_CASE("a dimensionless unit with a scale and no symbol is the one a declarat STATIC_REQUIRE(!formula::detail::unnamed_scaled_scalar(unit::MilligramPerKilogram)); STATIC_REQUIRE(formula::detail::RequireNamedScaledScalar::value); STATIC_REQUIRE(formula::detail::RequireNamedScaledScalar::value); -} \ No newline at end of file +} From ace879852e6f57d76c75dd583d34d2f3c77020bc Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Mon, 5 Oct 2026 00:12:21 +0200 Subject: [PATCH 09/35] fix(trace): spell an inverse-only coherent unit with negative exponents A coherent unit with nothing above the slash was written 1/kg, so after a fraction a line read 20000/413 1/kg: one fraction divided again, at a glance. It is now kg^-1, m^-1 s^-1, JPY^-1, kg^(-1/2). A unit with a numerator keeps its slash: m/s, EUR s^2/(m^2 kg). Signed-off-by: Christian Parpart --- CHANGELOG.md | 4 ++++ docs/dimensions.md | 2 +- docs/tracing.md | 6 ++++-- include/formula-cpp/trace_render.hpp | 14 ++++++++++++-- test/opaque_tests.cpp | 27 ++++++++++++++++++++++----- test/trace_render_tests.cpp | 2 +- test/trace_shown_unit_tests.cpp | 6 ++++-- 7 files changed, 48 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c716fcb..e15a511 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,10 @@ change is recorded here. unit could name it. Give the unit a symbol (`%`, `ppm`, or the author's own), or declare the quantity in scale 1. The refusal reads `formula: a dimensionless unit with a scale must have a symbol`. A dimensioned unit with no symbol is still accepted, and shown in the coherent unit. +- A coherent unit with no positive exponent is spelt with negative exponents in a trace: `kg^-1`, `s^-1`, + `m^-1 s^-1`, `JPY^-1`, where it was `1/kg`, `1/s`, `1/(m s)`, `1/JPY`. After a number in the fraction style, + `20000/413 1/kg` read as a fraction divided again. A unit with a numerator keeps its slash: `m/s`, `EUR/JPY`. + A trace text pinned in a test changes where it showed such a unit. ## [0.3.0] - 2026-10-01 diff --git a/docs/dimensions.md b/docs/dimensions.md index 18a2045..d9209c6 100644 --- a/docs/dimensions.md +++ b/docs/dimensions.md @@ -460,7 +460,7 @@ unit, and the trace spells that unit out after its number (see an opaque operation's output that no input's unit fits ([Opaque operations and bounded retry](opaque-and-retry.md)). A named base is written by its name, ahead of the SI units on its side of the slash: -`EUR s^2/(m^2 kg)` for euros per joule, then `1/JPY`, `EUR/JPY`, `EUR^(1/2)`. +`EUR s^2/(m^2 kg)` for euros per joule, then `JPY^-1`, `EUR/JPY`, `EUR^(1/2)`. The money comes first because a tariff is read as money per energy. ## Limits diff --git a/docs/tracing.md b/docs/tracing.md index a4cc92a..514e9d4 100644 --- a/docs/tracing.md +++ b/docs/tracing.md @@ -372,8 +372,10 @@ order"`.) `kg^2` and `kg^2/m^3` are no symbols anyone declared. `coherent()` (`evaluate.hpp`) hands a computed step a `Unit` with no symbol at all, and the renderer spells such a unit from the SI base units -- `m`, `kg`, `s`, `A`, `K`, `mol`, `cd` -- with the name of each named base dimension ahead of them: -`kg/(m s^2)` for a pressure, `EUR` for a price per kilowatt-hour times an -energy. A computed mass reads `kg`, a computed length `m`. Only a +`kg/(m s^2)` for a pressure, `s^-1` for a frequency -- a unit with nothing +above the slash is written with negative exponents, so that `20000/413 kg^-1` +cannot read as a fraction divided again -- `EUR` for a price per kilowatt-hour +times an energy. A computed mass reads `kg`, a computed length `m`. Only a dimensionless value is a bare number: `#1 / #2` above, a ratio of two volumes, reads `3/5`. A value declared in a unit of the author's own that has no symbol reads in the coherent unit too, converted, since its number alone could not diff --git a/include/formula-cpp/trace_render.hpp b/include/formula-cpp/trace_render.hpp index 1993733..3d42505 100644 --- a/include/formula-cpp/trace_render.hpp +++ b/include/formula-cpp/trace_render.hpp @@ -602,11 +602,16 @@ namespace detail /// A named base dimension is spelt by its name -- the name is also the /// symbol of its coherent unit -- ahead of the SI units on its side of the /// slash, in the dimension's own order: `EUR`, `EUR s^2/(m^2 kg)` for euros - /// per joule, `1/JPY`, `EUR/JPY`, `EUR^(1/2)`. First, because a tariff is + /// per joule, `JPY^-1`, `EUR/JPY`, `EUR^(1/2)`. First, because a tariff is /// read as money per energy, not as seconds squared of money per metre. /// Each name goes through `escaped_author_text`: `base_dimension()` admits /// only letters and digits, but a hand-filled `namedBases` can hold /// anything. + /// + /// A dimension with no positive exponent is written with negative + /// exponents and no slash: `kg^-1`, `m^-1 s^-1`, `kg^(-1/2)`. After a + /// number in the fraction style, `20000/413 1/kg` would read as one + /// fraction divided again; `20000/413 kg^-1` cannot. [[nodiscard]] inline std::string coherent_unit_text(Dimension dimension) { struct BaseUnit @@ -628,6 +633,7 @@ namespace detail }; std::string above; std::string below; + std::string inverse; std::size_t belowCount = 0; auto const place = [&](std::string_view symbolText, Exponent baseExponent) { if (baseExponent.numerator > 0) @@ -637,6 +643,8 @@ namespace detail { below += (below.empty() ? "" : " ") + unitPower(symbolText, -baseExponent.numerator, baseExponent.denominator); + inverse += (inverse.empty() ? "" : " ") + + unitPower(symbolText, baseExponent.numerator, baseExponent.denominator); ++belowCount; } }; @@ -646,7 +654,9 @@ namespace detail place(base.symbol, base.exponent); if (below.empty()) return above; - return (above.empty() ? std::string { "1" } : above) + "/" + (belowCount > 1 ? "(" + below + ")" : below); + if (above.empty()) + return inverse; + return above + "/" + (belowCount > 1 ? "(" + below + ")" : below); } /// Whether a value of @p dimension in @p declared is shown in the coherent diff --git a/test/opaque_tests.cpp b/test/opaque_tests.cpp index b9cbd06..c5157ef 100644 --- a/test/opaque_tests.cpp +++ b/test/opaque_tests.cpp @@ -1049,7 +1049,7 @@ TEST_CASE("the coherent unit is spelt from its base units", "[opaque][trace]") CHECK(formula::detail::coherent_unit_text(formula::dim::Scalar).empty()); CHECK(formula::detail::coherent_unit_text(formula::dim::Mass) == "kg"); CHECK(formula::detail::coherent_unit_text(formula::dim::Velocity) == "m/s"); - CHECK(formula::detail::coherent_unit_text(formula::dim::Frequency) == "1/s"); + CHECK(formula::detail::coherent_unit_text(formula::dim::Frequency) == "s^-1"); CHECK(formula::detail::coherent_unit_text(formula::dim::Density) == "kg/m^3"); CHECK(formula::detail::coherent_unit_text(formula::dim::Pressure) == "kg/(m s^2)"); CHECK(formula::detail::coherent_unit_text(formula::Dimension { .length = formula::exponent(1, 2) }) == "m^(1/2)"); @@ -1063,11 +1063,11 @@ TEST_CASE("a named base dimension is spelt by its name and ahead of the SI units // A tariff in euros per joule: the name leads the numerator, and the SI // units follow in their usual order on either side. CHECK(formula::detail::coherent_unit_text(euros / formula::dim::Energy) == "EUR s^2/(m^2 kg)"); - CHECK(formula::detail::coherent_unit_text(formula::power(yen, -1)) == "1/JPY"); + CHECK(formula::detail::coherent_unit_text(formula::power(yen, -1)) == "JPY^-1"); CHECK(formula::detail::coherent_unit_text(euros / yen) == "EUR/JPY"); CHECK(formula::detail::coherent_unit_text(formula::nth_root(euros, 2)) == "EUR^(1/2)"); // It leads the denominator too. - CHECK(formula::detail::coherent_unit_text(formula::dim::Scalar / (formula::dim::Time * euros)) == "1/(EUR s)"); + CHECK(formula::detail::coherent_unit_text(formula::dim::Scalar / (formula::dim::Time * euros)) == "EUR^-1 s^-1"); // `base_dimension` admits only letters and digits, but `namedBases` is a // public member: a name filled in by hand is escaped as author text is. @@ -1076,6 +1076,23 @@ TEST_CASE("a named base dimension is spelt by its name and ahead of the SI units CHECK(formula::detail::coherent_unit_text(handFilled) == "a\\]b"); } +TEST_CASE("an inverse-only coherent unit is spelt with negative exponents", "[opaque][trace]") +{ + // `1/kg` after a fraction reads as the fraction divided again: `20000/413 1/kg`. + CHECK(formula::detail::coherent_unit_text(formula::dim::Scalar / formula::dim::Mass) == "kg^-1"); + CHECK(formula::detail::coherent_unit_text(formula::dim::Frequency) == "s^-1"); + CHECK(formula::detail::coherent_unit_text(formula::power(formula::dim::Length, -3)) == "m^-3"); + CHECK(formula::detail::coherent_unit_text(formula::dim::Scalar / (formula::dim::Length * formula::dim::Time)) + == "m^-1 s^-1"); + CHECK(formula::detail::coherent_unit_text(formula::nth_root(formula::dim::Scalar / formula::dim::Mass, 2)) + == "kg^(-1/2)"); + constexpr formula::Dimension yen = formula::base_dimension("JPY"); + CHECK(formula::detail::coherent_unit_text(formula::power(yen, -1)) == "JPY^-1"); + // A unit with a numerator keeps its slash. + CHECK(formula::detail::coherent_unit_text(formula::dim::Velocity) == "m/s"); + CHECK(formula::detail::coherent_unit_text(formula::base_dimension("EUR") / yen) == "EUR/JPY"); +} + TEST_CASE("a quotient of two units is not offered when its dimension would need a fifth named base", "[opaque][trace]") { @@ -1201,7 +1218,7 @@ TEST_CASE("a borrowed quotient brackets a denominator of more than one unit word TEST_CASE("a quotient never borrows a dimensionless unit", "[opaque][trace]") { - // 12.7 % over 1.03 mm is a per-length, in no percentage: 12700/103 1/m in + // 12.7 % over 1.03 mm is a per-length, in no percentage: 12700/103 m^-1 in // the coherent unit, never 1270/103 %/mm. constexpr auto perGap = formula::opaque_output<"quotient">(formula::opaque({}, formula::var, formula::var)); @@ -1209,7 +1226,7 @@ TEST_CASE("a quotient never borrows a dimensionless unit", "[opaque][trace]") formula::environment(formula::Measured { rat(127, 10) }, formula::Measured { rat(103, 100) }); formula::Trace<> recorded {}; (void) formula::detail::dispatch(perGap, specimen, formula::RecordingSink { recorded }); - CHECK(formula::render_trace(recorded, { .maxSteps = 10 }).ends_with("4. quotient of #3 = 12700/103 1/m\n")); + CHECK(formula::render_trace(recorded, { .maxSteps = 10 }).ends_with("4. quotient of #3 = 12700/103 m^-1\n")); } namespace { diff --git a/test/trace_render_tests.cpp b/test/trace_render_tests.cpp index a6fbc12..25cf7f1 100644 --- a/test/trace_render_tests.cpp +++ b/test/trace_render_tests.cpp @@ -2252,7 +2252,7 @@ TEST_CASE("a series scaled by a pure number reads in the series' unit", "[series CHECK(derivation(factor / retained) == "1. 3/2\n" "2. m_r = 137 g; 213 g; 293 g\n" - "3. #1 / #2 = 1500/137 1/kg; 500/71 1/kg; 1500/293 1/kg\n"); + "3. #1 / #2 = 1500/137 kg^-1; 500/71 kg^-1; 1500/293 kg^-1\n"); // Celsius readings doubled are no readings: 593.7 K is not 2 x 23.7 degC. formula::Trace<> doubled {}; diff --git a/test/trace_shown_unit_tests.cpp b/test/trace_shown_unit_tests.cpp index 336053e..7c3f8f5 100644 --- a/test/trace_shown_unit_tests.cpp +++ b/test/trace_shown_unit_tests.cpp @@ -384,11 +384,11 @@ TEST_CASE("a value scaled by a pure number reads in its own unit", "[trace-rende == "1. m = 413/10 g\n" "2. 2\n" "3. #1 / #2 = 413/20 g\n"); - // A pure number divided by a mass is no mass: the coherent unit, 1/kg. + // A pure number divided by a mass is no mass: the coherent unit, kg^-1. CHECK(trace_text(Rational { 2 } / var, inputs) == "1. 2\n" "2. m = 413/10 g\n" - "3. #1 / #2 = 20000/413 1/kg\n"); + "3. #1 / #2 = 20000/413 kg^-1\n"); } TEST_CASE("a sum of two values in one unit reads in it, at the finer precision", "[trace-render][shown-unit]") @@ -708,6 +708,8 @@ TEST_CASE("every value a trace shows is in the unit written after it", "[trace-r -var), inputs)); check_each_value_is_in_the_unit_written_after_it(recorded_trace(var * Rational { 2 } - var, inputs)); + // A pure number over a mass: kg^-1 after a fraction. + check_each_value_is_in_the_unit_written_after_it(recorded_trace(Rational { 2 } / var, inputs)); } TEST_CASE("every value of a rejection, a bill, the statistics, a precision limit and an opaque call is in the unit written after it", From c2a69abfb0e1d6890a5236c2650b2f089a2b5bab Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Mon, 5 Oct 2026 00:15:46 +0200 Subject: [PATCH 10/35] test: measure how wide the exact curve fit's integers grow The overflow census is told the bits every wide integer of LinearLeastSquares::compute_exact used, and the headroom page's least-squares table gains a generated column with the widest at the sizes that answer: 68 of 256 bits on readings at 3 decimal places, 249 on a different denominator for every point. A test pins the widths of the exact outputs of the fifty-readings fit the opaque guide quotes, and the guide cites it. The fifty readings now live once, in examples/fifty_readings.hpp, which the example and the least-squares tests both include. Signed-off-by: Christian Parpart --- CHANGELOG.md | 3 ++ docs/numeric-headroom.md | 29 ++++++------ docs/opaque-and-retry.md | 21 ++++----- examples/fifty_readings.hpp | 51 ++++++++++++++++++++++ examples/opaque_and_retry.cpp | 24 +++------- include/formula-cpp/detail/checked_int.hpp | 41 ++++++++++++++--- include/formula-cpp/least_squares.hpp | 24 ++++++++++ support/census_tally.cpp | 12 +++++ support/census_tally.hpp | 5 ++- test/CMakeLists.txt | 3 ++ test/least_squares_tests.cpp | 48 ++++++++++++-------- test/overflow_census_tests.cpp | 27 ++++++++++-- 12 files changed, 216 insertions(+), 72 deletions(-) create mode 100644 examples/fifty_readings.hpp diff --git a/CHANGELOG.md b/CHANGELOG.md index efd9ec7..a033698 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,9 @@ change is recorded here. fits the declared places: e^45 to 18 places, e^88 to whole units. The exponential is computed with 192 fraction bits, so a result as wide as a `Rational` is still decided: e^43 to 18 places, `Overflow` before though the result fits, now answers. +- The numeric headroom page's least-squares table gives the most bits the exact curve fit's wide integers used, as + the overflow census measures it, in place of figures no test checked; the opaque-operation guide's widths of an + exact fit's outputs are pinned by a test. ## [0.3.0] - 2026-10-01 diff --git a/docs/numeric-headroom.md b/docs/numeric-headroom.md index 5e7f22c..411bd01 100644 --- a/docs/numeric-headroom.md +++ b/docs/numeric-headroom.md @@ -330,24 +330,23 @@ is checked against it at 27 and 28 points on a different denominator for every point, and at 128 on the readings at 3 decimal places. The last two rows fit the same shapes the way `rounded_output` does: the slope reported to 4 decimal places of N/s, computed by `LinearLeastSquares::compute_exact` in -256-bit integers and rounded exactly; the node is checked against that at 57, -58 and 128 points. For those two rows the last column counts `Rational`'s -128-bit integers only, -the rounded result and its conversion among them, and not the fit's 256-bit -intermediates, which the census does not see: they reach 68 bits on the -readings at 3 decimal places, and up to 249 of the 256 on a different -denominator for every point, at the sizes that still answer. So a large -figure there says nothing of how close the fit came to its 256 bits. +wide integers and rounded exactly; the node is checked against that at 57, +58 and 128 points. For those two rows the fourth column counts `Rational`'s +128-bit integers only, the rounded result and its conversion among them. The +fit itself computes in wider integers, and the last column gives the most +bits any of them used at the sizes that still answer: how near the fit came +to its width, which the column's heading states. The first three rows +compute in `Rational` and form no wide integer. -| data (invented) | sizes that overflow | first to overflow | least headroom otherwise | -|---|---|---|---| -| readings at 1 dp (realistic) | 0 of 127 | none | 93 | -| readings at 3 dp near 2410 N, a load cell's (realistic) | 0 of 127 | none | 54 | -| a different denominator on every point (stress control) | 101 of 127 | 28 points | 6 | -| the slope rounded to 4 dp by rounded_output: readings at 3 dp near 2410 N (realistic) | 0 of 127 | none | 105 | -| the slope rounded to 4 dp by rounded_output: a different denominator on every point (stress control) | 71 of 127 | 58 points | 112 | +| data (invented) | sizes that overflow | first to overflow | least headroom otherwise | widest fit intermediate (of 256 bits) | +|---|---|---|---|---| +| readings at 1 dp (realistic) | 0 of 127 | none | 93 | -- | +| readings at 3 dp near 2410 N, a load cell's (realistic) | 0 of 127 | none | 54 | -- | +| a different denominator on every point (stress control) | 101 of 127 | 28 points | 6 | -- | +| the slope rounded to 4 dp by rounded_output: readings at 3 dp near 2410 N (realistic) | 0 of 127 | none | 105 | 68 | +| the slope rounded to 4 dp by rounded_output: a different denominator on every point (stress control) | 71 of 127 | 58 points | 112 | 249 | diff --git a/docs/opaque-and-retry.md b/docs/opaque-and-retry.md index cf64536..f028a86 100644 --- a/docs/opaque-and-retry.md +++ b/docs/opaque-and-retry.md @@ -323,16 +323,17 @@ the four. ### When the exact fractions do not fit -An exact fit through fifty readings at eight decimals does not fit -`Rational`. Computed with Python's fractions, the slope is a fraction of 65 -and 73 bits and the intercept of 93 and 91, which fit, but R² needs 130 bits -over 130. `opaque_output` then answers `Overflow` -- for every -output of the call, since its outputs answer or fail together. A formula -that declares the precision it reports a coefficient at -- a unit, decimal -places and a rounding mode, as `rounded<>` does -- gets the correctly -rounded decimal instead, as long as the fit stays within the wide integers -the kernel computes in (`detail/least_squares_kernel.hpp`; beyond them the -answer is `Overflow` again, and the +An exact fit through fifty readings at eight decimals does not fit `Rational`. +The slope is a fraction of 65 and 73 bits and the intercept of 93 and 91, +which fit, but R² needs 130 bits over 130 (`test/least_squares_tests.cpp`, "a +line through fifty readings at eight decimals: how wide each exact output +is"). `opaque_output` then answers `Overflow` -- for every output of the call, +since its outputs answer or fail together. A formula that declares the +precision it reports a coefficient at -- a unit, decimal places and a rounding +mode, as `rounded<>` does -- gets the correctly rounded decimal instead, as +long as the fit stays within the wide integers the kernel computes in +(`detail/least_squares_kernel.hpp`; beyond them the answer is `Overflow` +again, and the [numeric headroom](numeric-headroom.md#regression-over-observations-realistic-and-one-stress-control) page measures where). [Displaying numbers](display.md#values-the-exact-layer-cannot-hold) explains values the exact layer cannot hold. The example declares the slope diff --git a/examples/fifty_readings.hpp b/examples/fifty_readings.hpp new file mode 100644 index 0000000..1f3ca22 --- /dev/null +++ b/examples/fifty_readings.hpp @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Fifty invented readings of a length against time, which +// opaque_and_retry.cpp fits a line through and test/least_squares_tests.cpp +// pins the figures of. One source for both, so that the example and the widths +// docs/opaque-and-retry.md quotes describe the same data. Not a public header +// and not installed. +#pragma once + +#include + +#include +#include +#include + +namespace formula_examples +{ + +/// Fifty readings at four decimals: t = k + 1 + (7919 k mod 997) / 10^4 s and +/// L = 2410 + 3.17 k + ((3217 k mod 1009) - 504) / 10^4 mm, for k from 0. At +/// eight, each gains (1237 k mod 10^4) / 10^8 s and (4111 k mod 10^4) / 10^8 mm. +struct FiftyReadings +{ + /// The times t, in seconds. + std::array seconds; + /// The lengths L, in millimetres. + std::array millimetres; +}; + +/// The fifty readings at four decimals when @p morePlaces is 1, and at eight +/// when it is 10'000. +[[nodiscard]] inline FiftyReadings fifty_readings(std::int64_t const morePlaces) +{ + FiftyReadings made {}; + for (std::size_t at = 0; at < 50; ++at) + { + auto const position = static_cast(at); + made.seconds[at] = formula::Rational { + (10'000 * (position + 1) + (7919 * position) % 997) * morePlaces + (1237 * position) % morePlaces, + 10'000 * morePlaces + }; + made.millimetres[at] = formula::Rational { + (24'100'000 + 31'700 * position + (3217 * position) % 1009 - 504) * morePlaces + + (4111 * position) % morePlaces, + 10'000 * morePlaces + }; + } + return made; +} + +} // namespace formula_examples diff --git a/examples/opaque_and_retry.cpp b/examples/opaque_and_retry.cpp index d794696..613c8ad 100644 --- a/examples/opaque_and_retry.cpp +++ b/examples/opaque_and_retry.cpp @@ -34,6 +34,8 @@ #include #include +#include "fifty_readings.hpp" + #include #include #include @@ -235,26 +237,12 @@ constexpr auto observedPoints = formula::environment(formula::MeasuredObservations(1_r, 2_r, 4_r, 7_r), formula::MeasuredObservations(10.2_r, 10.9_r, 12.1_r, 14.3_r)); -/// Fifty readings at eight decimals: t = k + 1 + (7919 k mod 997) / 10^4 -/// + (1237 k mod 10^4) / 10^8 s and L = 2410 + 3.17 k + ((3217 k mod 1009) -/// - 504) / 10^4 + (4111 k mod 10^4) / 10^8 mm, for k from 0. +/// Fifty readings at eight decimals (fifty_readings.hpp). auto fiftyReadings() { - std::array times; - std::array lengths; - for (std::size_t k = 0; k < 50; ++k) - { - auto const position = static_cast(k); - times[k] = formula::Rational { - (10'000 * (position + 1) + (7919 * position) % 997) * 10'000 + (1237 * position) % 10'000, 100'000'000 - }; - lengths[k] = formula::Rational { - (24'100'000 + 31'700 * position + (3217 * position) % 1009 - 504) * 10'000 + (4111 * position) % 10'000, - 100'000'000 - }; - } - return formula::MeasuredObservations::from(times).and_then([&](auto const& timesMade) { - return formula::MeasuredObservations::from(lengths).transform( + formula_examples::FiftyReadings const atEight = formula_examples::fifty_readings(10'000); + return formula::MeasuredObservations::from(atEight.seconds).and_then([&](auto const& timesMade) { + return formula::MeasuredObservations::from(atEight.millimetres).transform( [&](auto const& lengthsMade) { return formula::environment(timesMade, lengthsMade); }); }); } diff --git a/include/formula-cpp/detail/checked_int.hpp b/include/formula-cpp/detail/checked_int.hpp index 0128d1e..d588e62 100644 --- a/include/formula-cpp/detail/checked_int.hpp +++ b/include/formula-cpp/detail/checked_int.hpp @@ -31,13 +31,18 @@ /// `census_record`, which the census program defines /// (`support/census_tally.cpp`), so that it can say how many of the bits /// `Rational::Int` holds real formulas use (`docs/numeric-headroom.md`). A -/// constant evaluation reports nothing. Without the macro -- every build but -/// the census's -- `FORMULA_CENSUS_NOTE` expands to nothing, its arguments -/// are never evaluated, and none of the census's names exist: no call, no -/// symbol, no cost. +/// computation in wide integers that reports itself -- the exact curve fit, +/// `LinearLeastSquares::compute_exact` -- tells `census_record_width`, which +/// the census program defines too, the bits each of its intermediates used, +/// through `FORMULA_CENSUS_NOTE_WIDTH`. A constant evaluation reports +/// nothing. Without the macro -- every build but the census's -- +/// `FORMULA_CENSUS_NOTE` and `FORMULA_CENSUS_NOTE_WIDTH` expand to nothing, +/// their arguments are never evaluated, and none of the census's names +/// exist: no call, no symbol, no cost. #include +#include #include #include #include @@ -67,14 +72,17 @@ inline constexpr Int IntMin = -IntMax - 1; #if defined(FORMULA_OVERFLOW_CENSUS) /// What an integer the overflow census is told of was: a numerator or a -/// denominator handed to `Rational::make`, any other signed intermediate, or -/// an unsigned one (`rounded_sqrt`'s, which has 128 bits to use). +/// denominator handed to `Rational::make`, any other signed intermediate, an +/// unsigned one (`rounded_sqrt`'s, which has 128 bits to use), or an +/// intermediate of a computation in wide integers (`detail/wide_int.hpp`), +/// told as the bits it used. enum class CensusRole : std::uint8_t { Numerator, Denominator, Intermediate, Unsigned, + Wide, }; /// Told the magnitude of an integer formed at run time, as 128 bits. Declared @@ -97,13 +105,34 @@ constexpr void census_note(CensusRole role, std::uint64_t magnitudeSeen) noexcep census_note(role, UInt128::from_u64(magnitudeSeen)); } +/// Told how many bits an intermediate of a computation in wide integers used, +/// formed at run time: how near it came to its width. Declared here and +/// defined only by the census program, never by the library. +void census_record_width(CensusRole role, std::size_t bitsUsed) noexcept; + +/// Tells the overflow census that a wide intermediate used @p bitsUsed bits, +/// unless this is a constant evaluation. +constexpr void census_note_width(CensusRole role, std::size_t bitsUsed) noexcept +{ + if !consteval + { + census_record_width(role, bitsUsed); + } +} + /// Tells the overflow census that an integer of @p magnitudeSeen was formed /// in @p role (a `CensusRole` enumerator's name). See the file comment. #define FORMULA_CENSUS_NOTE(role, magnitudeSeen) \ ::formula::detail::census_note(::formula::detail::CensusRole::role, (magnitudeSeen)) + /// Tells the overflow census that a wide intermediate in @p role (a + /// `CensusRole` enumerator's name) used @p bitsUsed bits. + #define FORMULA_CENSUS_NOTE_WIDTH(role, bitsUsed) \ + ::formula::detail::census_note_width(::formula::detail::CensusRole::role, (bitsUsed)) #else /// Nothing: this is not a census build. The arguments are not evaluated. #define FORMULA_CENSUS_NOTE(role, magnitudeSeen) static_cast(0) + /// Nothing: this is not a census build. The arguments are not evaluated. + #define FORMULA_CENSUS_NOTE_WIDTH(role, bitsUsed) static_cast(0) #endif /// True when `leftOperand + rightOperand` is not representable. diff --git a/include/formula-cpp/least_squares.hpp b/include/formula-cpp/least_squares.hpp index f0d5240..d002d09 100644 --- a/include/formula-cpp/least_squares.hpp +++ b/include/formula-cpp/least_squares.hpp @@ -84,6 +84,7 @@ #include #include +#include #include #include #include @@ -236,6 +237,8 @@ struct LinearLeastSquares /// `DomainError`. A common denominator, a sum or a product that leaves 256 /// bits is `Overflow` -- on readings with a different denominator on every /// point from 58 points (`docs/numeric-headroom.md`), never a wrong line. + /// The overflow census (`docs/numeric-headroom.md`) is told the bits each + /// of these integers used. static constexpr std::expected, 2>, ArithmeticError> compute_exact( std::span domainPoints, std::span pointValues) noexcept { @@ -249,6 +252,8 @@ struct LinearLeastSquares std::optional const valueScale = detail::common_denominator(pointValues); if (!pointScale || !valueScale) return std::unexpected { ArithmeticError::Overflow }; + FORMULA_CENSUS_NOTE_WIDTH(Wide, pointScale->bit_length()); + FORMULA_CENSUS_NOTE_WIDTH(Wide, valueScale->bit_length()); // The four integer sums: of X, of Y, of X^2 and of X Y. Signed sumOfPoints {}; @@ -271,6 +276,14 @@ struct LinearLeastSquares productTerm ? detail::add_checked_or_none(sumOfProducts, *productTerm) : std::nullopt; if (!withPoint || !withValue || !withSquare || !withProduct) return std::unexpected { ArithmeticError::Overflow }; + FORMULA_CENSUS_NOTE_WIDTH(Wide, scaledPoint->magnitude.bit_length()); + FORMULA_CENSUS_NOTE_WIDTH(Wide, scaledValue->magnitude.bit_length()); + FORMULA_CENSUS_NOTE_WIDTH(Wide, squareTerm->magnitude.bit_length()); + FORMULA_CENSUS_NOTE_WIDTH(Wide, productTerm->magnitude.bit_length()); + FORMULA_CENSUS_NOTE_WIDTH(Wide, withPoint->magnitude.bit_length()); + FORMULA_CENSUS_NOTE_WIDTH(Wide, withValue->magnitude.bit_length()); + FORMULA_CENSUS_NOTE_WIDTH(Wide, withSquare->magnitude.bit_length()); + FORMULA_CENSUS_NOTE_WIDTH(Wide, withProduct->magnitude.bit_length()); sumOfPoints = *withPoint; sumOfValues = *withValue; sumOfSquares = *withSquare; @@ -287,11 +300,20 @@ struct LinearLeastSquares std::optional const pointsByProducts = detail::mul_checked_or_none(sumOfPoints, sumOfProducts); if (!countedSquares || !squaredSum || !countedProducts || !crossSum || !valuesBySquares || !pointsByProducts) return std::unexpected { ArithmeticError::Overflow }; + FORMULA_CENSUS_NOTE_WIDTH(Wide, countedSquares->magnitude.bit_length()); + FORMULA_CENSUS_NOTE_WIDTH(Wide, squaredSum->magnitude.bit_length()); + FORMULA_CENSUS_NOTE_WIDTH(Wide, countedProducts->magnitude.bit_length()); + FORMULA_CENSUS_NOTE_WIDTH(Wide, crossSum->magnitude.bit_length()); + FORMULA_CENSUS_NOTE_WIDTH(Wide, valuesBySquares->magnitude.bit_length()); + FORMULA_CENSUS_NOTE_WIDTH(Wide, pointsByProducts->magnitude.bit_length()); std::optional const pointSpread = detail::sub_checked_or_none(*countedSquares, *squaredSum); std::optional const riseTerm = detail::sub_checked_or_none(*countedProducts, *crossSum); std::optional const interceptTerm = detail::sub_checked_or_none(*valuesBySquares, *pointsByProducts); if (!pointSpread || !riseTerm || !interceptTerm) return std::unexpected { ArithmeticError::Overflow }; + FORMULA_CENSUS_NOTE_WIDTH(Wide, pointSpread->magnitude.bit_length()); + FORMULA_CENSUS_NOTE_WIDTH(Wide, riseTerm->magnitude.bit_length()); + FORMULA_CENSUS_NOTE_WIDTH(Wide, interceptTerm->magnitude.bit_length()); // A backstop only: distinct points were checked above. if (pointSpread->negative || pointSpread->magnitude.is_zero()) return std::unexpected { ArithmeticError::DomainError }; @@ -300,6 +322,8 @@ struct LinearLeastSquares std::optional const sharedDenominator = detail::mul_checked_or_none(pointSpread->magnitude, *valueScale); if (!slopeNumerator || !sharedDenominator) return std::unexpected { ArithmeticError::Overflow }; + FORMULA_CENSUS_NOTE_WIDTH(Wide, slopeNumerator->bit_length()); + FORMULA_CENSUS_NOTE_WIDTH(Wide, sharedDenominator->bit_length()); return std::array { detail::WideRatio { interceptTerm->negative, interceptTerm->magnitude, *sharedDenominator }, detail::WideRatio { riseTerm->negative, *slopeNumerator, *sharedDenominator } }; diff --git a/support/census_tally.cpp b/support/census_tally.cpp index 46de906..49ea5b7 100644 --- a/support/census_tally.cpp +++ b/support/census_tally.cpp @@ -16,6 +16,9 @@ namespace /// program, one thread: the census programs evaluate on the main thread only. std::array largestSeen {}; +/// The most bits a wide intermediate used since the last reset. +std::size_t widestWideBits = 0; + [[nodiscard]] std::size_t slot(formula::detail::CensusRole role) noexcept { return static_cast(role); @@ -32,6 +35,12 @@ void census_record(CensusRole role, UInt128 magnitudeSeen) noexcept largestSeen[slot(role)] = magnitudeSeen; } +void census_record_width(CensusRole role, std::size_t bitsUsed) noexcept +{ + if (role == CensusRole::Wide && widestWideBits < bitsUsed) + widestWideBits = bitsUsed; +} + } // namespace formula::detail namespace formula_census @@ -39,6 +48,8 @@ namespace formula_census int bits_used(formula::detail::CensusRole role) noexcept { + if (role == formula::detail::CensusRole::Wide) + return static_cast(widestWideBits); return largestSeen[slot(role)].bit_width(); } @@ -55,6 +66,7 @@ int signed_bits_used() noexcept void reset() noexcept { largestSeen.fill(formula::detail::UInt128 {}); + widestWideBits = 0; } } // namespace formula_census diff --git a/support/census_tally.hpp b/support/census_tally.hpp index 1a33cc9..b48cd62 100644 --- a/support/census_tally.hpp +++ b/support/census_tally.hpp @@ -14,8 +14,9 @@ namespace formula_census { /// The bits the largest magnitude of @p role used since the last `reset` -- -/// 0 when none was seen. The largest `Rational::Int` uses all but its sign -/// bit; `rounded_sqrt`'s unsigned intermediates may use every bit. +/// 0 when none was seen. For `CensusRole::Wide`, the most bits a wide +/// intermediate used. The largest `Rational::Int` uses all but its sign bit; +/// `rounded_sqrt`'s unsigned intermediates may use every bit. [[nodiscard]] int bits_used(formula::detail::CensusRole role) noexcept; /// The largest of the three signed roles' bits: what is left of 127 is the diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 6290d5a..87a1eb0 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -109,6 +109,9 @@ add_executable(formula-cpp-tests yields_tests.cpp "${PROJECT_SOURCE_DIR}/support/fail_without_dialogs.cpp") target_link_libraries(formula-cpp-tests PRIVATE formula-cpp::formula-cpp Catch2::Catch2WithMain) +# The fifty readings examples/opaque_and_retry.cpp fits a line through, whose +# figures the least-squares tests pin (examples/fifty_readings.hpp). +target_include_directories(formula-cpp-tests PRIVATE "${PROJECT_SOURCE_DIR}/examples") formula_apply_warnings(formula-cpp-tests) catch_discover_tests(formula-cpp-tests) diff --git a/test/least_squares_tests.cpp b/test/least_squares_tests.cpp index 8501c14..faae5d2 100644 --- a/test/least_squares_tests.cpp +++ b/test/least_squares_tests.cpp @@ -8,6 +8,8 @@ #include #include +#include "fifty_readings.hpp" + #include #include @@ -1279,25 +1281,13 @@ TEST_CASE("a line through observations in double agrees with the exact line", "[ namespace { -// Fifty readings at four decimals: t_k = k + 1 + (7919 k mod 997) / 10^4 s, -// L_k = 2410 + 3.17 k + ((3217 k mod 1009) - 504) / 10^4 mm. At eight, each -// gains (1237 k mod 10^4) / 10^8 s and (4111 k mod 10^4) / 10^8 mm. -// Reference values computed with Python's fractions. +// The fifty readings of examples/fifty_readings.hpp, at four decimals or, with +// morePlaces 10'000, at eight. Reference values computed with Python's fractions. [[nodiscard]] auto fifty_readings(std::int64_t const morePlaces = 1) { - std::array times; - std::array lengths; - for (std::size_t at = 0; at < 50; ++at) - { - auto const position = static_cast(at); - times[at] = rat((10'000 * (position + 1) + (7919 * position) % 997) * morePlaces + (1237 * position) % morePlaces, - 10'000 * morePlaces); - lengths[at] = rat((24'100'000 + 31'700 * position + (3217 * position) % 1009 - 504) * morePlaces - + (4111 * position) % morePlaces, - 10'000 * morePlaces); - } - return formula::environment(*formula::MeasuredObservations::from(times), - *formula::MeasuredObservations::from(lengths)); + formula_examples::FiftyReadings const made = formula_examples::fifty_readings(morePlaces); + return formula::environment(*formula::MeasuredObservations::from(made.seconds), + *formula::MeasuredObservations::from(made.millimetres)); } constexpr auto fiftyFit = formula::linear_least_squares( @@ -1385,6 +1375,30 @@ TEST_CASE("a line through fifty readings at eight decimals overflows exactly and CHECK(fitQuality->measurement().value() == rat(249999, 250'000)); } +TEST_CASE("a line through fifty readings at eight decimals: how wide each exact output is", + "[least-squares][observations]") +{ + // The widths docs/opaque-and-retry.md quotes. In coherent units -- seconds and metres -- as the fit sees + // them, each output reduced to lowest terms as a Rational would hold it: the intercept and the slope fit + // 127 bits, R^2 does not, so the call answers Overflow for all its outputs. + formula_examples::FiftyReadings const atEight = formula_examples::fifty_readings(10'000); + std::array metres {}; + for (std::size_t at = 0; at < metres.size(); ++at) + metres[at] = + formula::Rational { atEight.millimetres[at].numerator(), atEight.millimetres[at].denominator() * 1000 }; + auto const exact = formula::LinearLeastSquaresOfObservations::compute_exact( + std::span { atEight.seconds }, std::span { metres }); + REQUIRE(exact.has_value()); + auto const bitsOf = [&exact](std::size_t outputAt) { + auto const lowest = formula::detail::reduced((*exact)[outputAt]); + return std::array { lowest.numerator.bit_length(), lowest.denominator.bit_length() }; + }; + // intercept, slope, r squared: numerator bits, then denominator bits. + CHECK(bitsOf(0) == std::array { 93, 91 }); + CHECK(bitsOf(1) == std::array { 65, 73 }); + CHECK(bitsOf(2) == std::array { 130, 130 }); +} + TEST_CASE("an observation that fails to convert fails the fit at that observation", "[least-squares][observations][trace]") { // 1.03 x 10^36 km is 1.03 x 10^39 m: the third length. The fit relays it. diff --git a/test/overflow_census_tests.cpp b/test/overflow_census_tests.cpp index 6424d13..3b97bc1 100644 --- a/test/overflow_census_tests.cpp +++ b/test/overflow_census_tests.cpp @@ -50,6 +50,8 @@ struct Used int denominatorBits; int intermediateBits; int unsignedBits; + /// The most bits a wide intermediate used (`CensusRole::Wide`), 0 when none was formed. + int wideBits; [[nodiscard]] int headroom() const noexcept { @@ -66,7 +68,8 @@ template return Used { formula_census::bits_used(CensusRole::Numerator), formula_census::bits_used(CensusRole::Denominator), formula_census::bits_used(CensusRole::Intermediate), - formula_census::bits_used(CensusRole::Unsigned) }; + formula_census::bits_used(CensusRole::Unsigned), + formula_census::bits_used(CensusRole::Wide) }; } /// Prints one line of the page's table @p table, for @@ -370,12 +373,16 @@ struct FitScan { std::vector overflowing; int leastHeadroom = 127; + /// The most bits a wide intermediate of the fit used, over the sizes that answered; empty for a route + /// that computes in `Rational` and forms none. + std::optional widestWide; [[nodiscard]] std::string row(char const* label) const { return "| " + std::string { label } + " | " + std::to_string(overflowing.size()) + " of 127 | " + (overflowing.empty() ? std::string { "none" } : std::to_string(overflowing.front()) + " points") + " | " - + std::to_string(leastHeadroom) + " |"; + + std::to_string(leastHeadroom) + " | " + + (widestWide.has_value() ? std::to_string(*widestWide) : std::string { "--" }) + " |"; } }; @@ -467,7 +474,10 @@ template if (overflowed) found.overflowing.push_back(count); else + { found.leastHeadroom = std::min(found.leastHeadroom, used.headroom()); + found.widestWide = std::max(found.widestWide.value_or(0), used.wideBits); + } } return found; } @@ -983,8 +993,11 @@ TEST_CASE("census: least squares over 2 to 128 points", "[census]") FitScan const distinct = scan_fit(distinct_denominators_point); FitScan const roundedThree = scan_rounded_fit(three_decimals_point); FitScan const roundedDistinct = scan_rounded_fit(distinct_denominators_point); - emit("least-squares", "| data (invented) | sizes that overflow | first to overflow | least headroom otherwise |"); - emit("least-squares", "|---|---|---|---|"); + std::string const wideHeader = + "widest fit intermediate (of " + std::to_string(formula::LinearLeastSquares::exact_limbs * 32) + " bits)"; + emit("least-squares", + "| data (invented) | sizes that overflow | first to overflow | least headroom otherwise | " + wideHeader + " |"); + emit("least-squares", "|---|---|---|---|---|"); emit("least-squares", oneDecimal.row("readings at 1 dp (realistic)")); emit("least-squares", threeDecimals.row("readings at 3 dp near 2410 N, a load cell's (realistic)")); emit("least-squares", distinct.row("a different denominator on every point (stress control)")); @@ -1017,6 +1030,12 @@ TEST_CASE("census: least squares over 2 to 128 points", "[census]") CHECK(!rounded_fit_node_overflows<128>(three_decimals_point)); CHECK(!rounded_fit_node_overflows<57>(distinct_denominators_point)); CHECK(rounded_fit_node_overflows<58>(distinct_denominators_point)); + // How close the exact fit came to its width, at the sizes that answered: readings at 3 dp use 68 of its + // bits, a different denominator on every point 249. The Rational routes form no wide integer. + CHECK(roundedThree.widestWide == 68); + CHECK(roundedDistinct.widestWide == 249); + CHECK_FALSE(oneDecimal.widestWide.has_value()); + CHECK(formula::LinearLeastSquares::exact_limbs * 32 == 256); } TEST_CASE("census: a line through observations, exact and rounded, over 2 to 128 points", "[census]") From a872745e4ff681f89ece1d6b94f42fa3408f951a Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Mon, 5 Oct 2026 00:16:06 +0200 Subject: [PATCH 11/35] docs: place a named base among negated factors, and tighten the inverse-unit note A named base leads the SI units on its side of the slash, or among the negated factors when nothing stands above it, as in EUR^-1 s^-1. The tracing guide's note on negative exponents becomes short sentences. Signed-off-by: Christian Parpart --- docs/dimensions.md | 5 +++-- docs/tracing.md | 19 ++++++++++--------- include/formula-cpp/trace_render.hpp | 13 +++++++------ 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/docs/dimensions.md b/docs/dimensions.md index d9209c6..dadcf68 100644 --- a/docs/dimensions.md +++ b/docs/dimensions.md @@ -459,8 +459,9 @@ unit, and the trace spells that unit out after its number (see [Tracing and audit trails](tracing.md#reading-a-derivation)) -- as it does for an opaque operation's output that no input's unit fits ([Opaque operations and bounded retry](opaque-and-retry.md)). A named base is -written by its name, ahead of the SI units on its side of the slash: -`EUR s^2/(m^2 kg)` for euros per joule, then `JPY^-1`, `EUR/JPY`, `EUR^(1/2)`. +written by its name, ahead of the SI units: on its side of the slash, or +among the negated factors when nothing stands above it. So `EUR s^2/(m^2 kg)` +for euros per joule, `EUR^-1 s^-1`, `JPY^-1`, `EUR/JPY`, `EUR^(1/2)`. The money comes first because a tariff is read as money per energy. ## Limits diff --git a/docs/tracing.md b/docs/tracing.md index 514e9d4..5116541 100644 --- a/docs/tracing.md +++ b/docs/tracing.md @@ -372,15 +372,16 @@ order"`.) `kg^2` and `kg^2/m^3` are no symbols anyone declared. `coherent()` (`evaluate.hpp`) hands a computed step a `Unit` with no symbol at all, and the renderer spells such a unit from the SI base units -- `m`, `kg`, `s`, `A`, `K`, `mol`, `cd` -- with the name of each named base dimension ahead of them: -`kg/(m s^2)` for a pressure, `s^-1` for a frequency -- a unit with nothing -above the slash is written with negative exponents, so that `20000/413 kg^-1` -cannot read as a fraction divided again -- `EUR` for a price per kilowatt-hour -times an energy. A computed mass reads `kg`, a computed length `m`. Only a -dimensionless value is a bare number: `#1 / #2` above, a ratio of two volumes, -reads `3/5`. A value declared in a unit of the author's own that has no symbol -reads in the coherent unit too, converted, since its number alone could not -say what scale it is on. A dimensionless unit with a scale must have a symbol, -so a bare number is always a value at scale 1. +`kg/(m s^2)` for a pressure, `s^-1` for a frequency, `EUR` for a price per +kilowatt-hour times an energy. A unit with nothing above the slash is written +with negative exponents. So `20000/413 kg^-1` cannot read as a fraction +divided again, as `20000/413 1/kg` would. A computed mass reads `kg`, a +computed length `m`. Only a dimensionless value is a bare number: `#1 / #2` +above, a ratio of two volumes, reads `3/5`. A value declared in a unit of the +author's own that has no symbol reads in the coherent unit too, converted, +since its number alone could not say what scale it is on. A dimensionless +unit with a scale must have a symbol, so a bare number is always a value at +scale 1. A computed step borrows its unit off the steps it read in these cases: diff --git a/include/formula-cpp/trace_render.hpp b/include/formula-cpp/trace_render.hpp index 3d42505..d972449 100644 --- a/include/formula-cpp/trace_render.hpp +++ b/include/formula-cpp/trace_render.hpp @@ -600,12 +600,13 @@ namespace detail /// that a slope in metres per second does not read as a pure number. /// /// A named base dimension is spelt by its name -- the name is also the - /// symbol of its coherent unit -- ahead of the SI units on its side of the - /// slash, in the dimension's own order: `EUR`, `EUR s^2/(m^2 kg)` for euros - /// per joule, `JPY^-1`, `EUR/JPY`, `EUR^(1/2)`. First, because a tariff is - /// read as money per energy, not as seconds squared of money per metre. - /// Each name goes through `escaped_author_text`: `base_dimension()` admits - /// only letters and digits, but a hand-filled `namedBases` can hold + /// symbol of its coherent unit -- ahead of the SI units, in the dimension's + /// own order: on its side of the slash, or among the negated factors when + /// nothing stands above it. `EUR`, `EUR s^2/(m^2 kg)` for euros per joule, + /// `EUR^-1 s^-1`, `JPY^-1`, `EUR/JPY`, `EUR^(1/2)`. First, because a + /// tariff is read as money per energy, not as seconds squared of money per + /// metre. Each name goes through `escaped_author_text`: `base_dimension()` + /// admits only letters and digits, but a hand-filled `namedBases` can hold /// anything. /// /// A dimension with no positive exponent is written with negative From e311b196e775c19df55ec28e06ee1c2e70e1a153 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Mon, 5 Oct 2026 00:18:38 +0200 Subject: [PATCH 12/35] docs: say where a named base stands when nothing is above the slash The test of a named base dimension's place, and the paragraph on it in the dimensions guide, still spoke of the denominator for EUR^-1 s^-1, which has none. They now say that the name leads the negated factors when nothing stands above the slash, and the guide's list of examples reads as a sentence. Signed-off-by: Christian Parpart --- docs/dimensions.md | 5 +++-- test/opaque_tests.cpp | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/dimensions.md b/docs/dimensions.md index dadcf68..b4ba041 100644 --- a/docs/dimensions.md +++ b/docs/dimensions.md @@ -460,8 +460,9 @@ unit, and the trace spells that unit out after its number (see an opaque operation's output that no input's unit fits ([Opaque operations and bounded retry](opaque-and-retry.md)). A named base is written by its name, ahead of the SI units: on its side of the slash, or -among the negated factors when nothing stands above it. So `EUR s^2/(m^2 kg)` -for euros per joule, `EUR^-1 s^-1`, `JPY^-1`, `EUR/JPY`, `EUR^(1/2)`. +among the negated factors when nothing stands above it. So euros per joule +are written `EUR s^2/(m^2 kg)`, and the others read `EUR^-1 s^-1`, `JPY^-1`, +`EUR/JPY` and `EUR^(1/2)`. The money comes first because a tariff is read as money per energy. ## Limits diff --git a/test/opaque_tests.cpp b/test/opaque_tests.cpp index c5157ef..830687d 100644 --- a/test/opaque_tests.cpp +++ b/test/opaque_tests.cpp @@ -1055,7 +1055,9 @@ TEST_CASE("the coherent unit is spelt from its base units", "[opaque][trace]") CHECK(formula::detail::coherent_unit_text(formula::Dimension { .length = formula::exponent(1, 2) }) == "m^(1/2)"); } -TEST_CASE("a named base dimension is spelt by its name and ahead of the SI units on its side", "[opaque][trace]") +TEST_CASE("a named base dimension is spelt by its name and ahead of the SI units on its side of the slash, " + "or among the negated factors when nothing stands above it", + "[opaque][trace]") { constexpr formula::Dimension euros = formula::base_dimension("EUR"); constexpr formula::Dimension yen = formula::base_dimension("JPY"); @@ -1066,7 +1068,7 @@ TEST_CASE("a named base dimension is spelt by its name and ahead of the SI units CHECK(formula::detail::coherent_unit_text(formula::power(yen, -1)) == "JPY^-1"); CHECK(formula::detail::coherent_unit_text(euros / yen) == "EUR/JPY"); CHECK(formula::detail::coherent_unit_text(formula::nth_root(euros, 2)) == "EUR^(1/2)"); - // It leads the denominator too. + // With nothing above the slash, it leads the negated factors. CHECK(formula::detail::coherent_unit_text(formula::dim::Scalar / (formula::dim::Time * euros)) == "EUR^-1 s^-1"); // `base_dimension` admits only letters and digits, but `namedBases` is a From 6a789038a287eaed79921bcf327f295d2e7906ec Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Mon, 5 Oct 2026 00:21:05 +0200 Subject: [PATCH 13/35] build: hold the examples' headers to the SPDX and NOLINT rules The SPDX and NOLINT hygiene checks globbed only the examples' sources and build files, so a header beside the examples, such as fifty_readings.hpp, escaped both. They now scan examples/*.hpp as well. Signed-off-by: Christian Parpart --- cmake/CheckNolintUsage.cmake | 1 + cmake/CheckSpdxHeaders.cmake | 1 + 2 files changed, 2 insertions(+) diff --git a/cmake/CheckNolintUsage.cmake b/cmake/CheckNolintUsage.cmake index 9c279bc..adda6a5 100644 --- a/cmake/CheckNolintUsage.cmake +++ b/cmake/CheckNolintUsage.cmake @@ -8,6 +8,7 @@ file(GLOB_RECURSE sources "${SOURCE_DIR}/test/*.hpp" "${SOURCE_DIR}/test/*CMakeLists.txt" "${SOURCE_DIR}/examples/*.cpp" + "${SOURCE_DIR}/examples/*.hpp" "${SOURCE_DIR}/examples/*CMakeLists.txt" "${SOURCE_DIR}/cmake/*.cmake" "${SOURCE_DIR}/cmake/*.cmake.in") diff --git a/cmake/CheckSpdxHeaders.cmake b/cmake/CheckSpdxHeaders.cmake index 360f386..daf074a 100644 --- a/cmake/CheckSpdxHeaders.cmake +++ b/cmake/CheckSpdxHeaders.cmake @@ -7,6 +7,7 @@ file(GLOB_RECURSE sources "${SOURCE_DIR}/test/*.hpp" "${SOURCE_DIR}/test/*CMakeLists.txt" "${SOURCE_DIR}/examples/*.cpp" + "${SOURCE_DIR}/examples/*.hpp" "${SOURCE_DIR}/examples/*CMakeLists.txt" "${SOURCE_DIR}/cmake/*.cmake" "${SOURCE_DIR}/cmake/*.cmake.in") From 0be1b82e373ccbde3ba6765351a3fcf289baaf15 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Mon, 5 Oct 2026 00:29:47 +0200 Subject: [PATCH 14/35] fix: name a rounding's unit by its size when it has no symbol A rounding to places of a unit with no symbol wrote no unit clause, while the value after it read in the coherent unit: round(#1, to 2 dp) = 3/1000 kg let "2 dp" read as places of a kilogram. The clause now names the unit by its size in the coherent unit, round(#1, to 2 dp of 1/1000 kg), and an offset unit by its size and its zero, to 1 dp of 1 K from 5463/20 K, in render() and in a trace alike. numeric(x, in ) names such a unit the same way, and render() writes a constant in it in the coherent unit, 3/1000 kg, where it wrote a bare 3. The coherent unit's spelling moves into render.hpp so that both can write it. Signed-off-by: Christian Parpart --- CHANGELOG.md | 6 + docs/display.md | 3 +- docs/rounding-and-conditionals.md | 6 +- docs/tracing.md | 3 +- include/formula-cpp/render.hpp | 214 +++++++++++++++++++++++++-- include/formula-cpp/trace_render.hpp | 130 +++------------- test/render_tests.cpp | 57 +++++++ test/trace_shown_unit_tests.cpp | 56 +++++++ 8 files changed, 347 insertions(+), 128 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e15a511..84313cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,12 @@ change is recorded here. `m^-1 s^-1`, `JPY^-1`, where it was `1/kg`, `1/s`, `1/(m s)`, `1/JPY`. After a number in the fraction style, `20000/413 1/kg` read as a fraction divided again. A unit with a numerator keeps its slash: `m/s`, `EUR/JPY`. A trace text pinned in a test changes where it showed such a unit. +- A rounding in a unit with no symbol names that unit by its size in the coherent unit, in `render()` and in a + trace: `round(#1, to 2 dp of 1/1000 kg) = 157/50000 kg`, where it wrote `round(#1, to 2 dp)`, which read as places + of the kilogram written after it. A unit with an offset is named by its size and its zero, `to 1 dp of 1 K from + 5463/20 K`. Only a dimensionless unit at scale 1 still writes no unit clause. A constant in such a unit renders in + the coherent unit (`3/1000 kg`, where it wrote a bare `3`), and `numeric(x, in )` names the unit by its size + too. ## [0.3.0] - 2026-10-01 diff --git a/docs/display.md b/docs/display.md index 006bdc6..67b7b65 100644 --- a/docs/display.md +++ b/docs/display.md @@ -265,7 +265,8 @@ the dish's mass in its declared grams: ≈4.2 g constant (the moisture trace's line 5, `25.5 g`), a table's bound or row, a permitted value, a limit. Rounding it would print a number nobody wrote. The dish's typed 1/3 has no exact decimal, and even the rounding style writes it -`1/3` (line 3 above). +`1/3` (line 3 above). A constant in a dimensioned unit with no symbol is +written in the coherent unit, exact: `3/1000 kg`. **Nor is either side of a comparison that a trace line states beside its verdict.** Two specimens' moisture contents, checked against a limit of at diff --git a/docs/rounding-and-conditionals.md b/docs/rounding-and-conditionals.md index f1a69c9..36b97ab 100644 --- a/docs/rounding-and-conditionals.md +++ b/docs/rounding-and-conditionals.md @@ -201,7 +201,11 @@ generated page must not depend on which inputs happened to be passed in. A rounding node renders as `round(, to dp of )`, or `sf` in place of `dp` for significant digits; `numeric_value_of` (below) -renders as `numeric(, in )`. The operand comes first and the +renders as `numeric(, in )`. A unit with no symbol is named +by its size in the coherent unit, `round(m, to 2 dp of 1/1000 kg)`, and one +with an offset by its size and its zero, `to 1 dp of 1 K from 5463/20 K`, so +that the places say what they count in; only a dimensionless unit at scale 1 +writes no unit clause, `round(x, to 2 dp)`. The operand comes first and the granularity second, comma-separated, deliberately -- not because it looks tidier, but because the alternative shapes both have a real failure mode a review actually caught. A trailing suffix with nothing separating it from diff --git a/docs/tracing.md b/docs/tracing.md index 5116541..27e7fcc 100644 --- a/docs/tracing.md +++ b/docs/tracing.md @@ -381,7 +381,8 @@ above, a ratio of two volumes, reads `3/5`. A value declared in a unit of the author's own that has no symbol reads in the coherent unit too, converted, since its number alone could not say what scale it is on. A dimensionless unit with a scale must have a symbol, so a bare number is always a value at -scale 1. +scale 1. A rounding in a unit with no symbol names that unit by its size in +the coherent unit: `round(#1, to 2 dp of 1/1000 kg) = 157/50000 kg`. A computed step borrows its unit off the steps it read in these cases: diff --git a/include/formula-cpp/render.hpp b/include/formula-cpp/render.hpp index 4629b0a..79ca0dd 100644 --- a/include/formula-cpp/render.hpp +++ b/include/formula-cpp/render.hpp @@ -214,13 +214,14 @@ namespace detail /// reads like a unary minus, and a unit with a symbol renders as *two* /// tokens ("150 mm") rather than one -- so `pow<2>` of it would otherwise /// read as `150 mm^2`, i.e. `150 * mm^2`, when the tree means `(150 mm)^2`. - /// Both cases must bracket exactly where a `UnaryNode` would. + /// A dimensioned unit with no symbol writes a unit too, the coherent one + /// (`3/1000 kg`). Both cases must bracket exactly where a `UnaryNode` would. template [[nodiscard]] constexpr Precedence precedence_of(ConstantNode const& node) noexcept { constexpr Unit declaredUnit = U; - bool const hasUnitSymbol = !view(declaredUnit.symbolText).empty(); - return node.number.sign() < 0 || hasUnitSymbol ? Precedence::Unary : Precedence::Atom; + bool const writesUnit = !view(declaredUnit.symbolText).empty() || !(declaredUnit.dimension == dim::Scalar); + return node.number.sign() < 0 || writesUnit ? Precedence::Unary : Precedence::Atom; } /// A wrapper's *type* answer forwards correctly (`PrecedenceOf` above), @@ -475,6 +476,164 @@ namespace detail return unitSymbol.empty() ? std::string {} : "\\mathrm{" + latex_math_words(unitSymbol) + "}"; } + /// How a caller writes a piece of author text that it states inside a + /// unit's spelling -- a unit's symbol, or a named base dimension's name: + /// as it is, in `render()`'s text (`verbatim_text`), or escaped, in a + /// trace line (`escaped_author_text`, `trace_render.hpp`). + using AuthorTextSpelling = std::string (*)(std::string_view); + + /// @p authored as it is: `render()` writes a unit's symbol verbatim in + /// plain text and Markdown, and LaTeX escapes the whole unit text + /// afterwards (`latex_unit`). + [[nodiscard]] inline std::string verbatim_text(std::string_view authored) + { + return std::string { authored }; + } + + /// The coherent unit of @p dimension, spelt from its base units: + /// `m/s`, `kg/m^3`, `kg/(m s^2)`, `m^(1/2)`; empty for a dimensionless + /// one. Written after every dimensioned value whose unit has no symbol, so + /// that a slope in metres per second does not read as a pure number. + /// + /// A named base dimension is spelt by its name -- the name is also the + /// symbol of its coherent unit -- ahead of the SI units, in the dimension's + /// own order: on its side of the slash, or among the negated factors when + /// nothing stands above it. `EUR`, `EUR s^2/(m^2 kg)` for euros per joule, + /// `EUR^-1 s^-1`, `JPY^-1`, `EUR/JPY`, `EUR^(1/2)`. First, because a + /// tariff is read as money per energy, not as seconds squared of money per + /// metre. Each name is written by @p spellName: escaped in a trace, as it is + /// in `render()`. `base_dimension()` admits only letters and digits, but a + /// hand-filled `namedBases` can hold anything. + /// + /// A dimension with no positive exponent is written with negative + /// exponents and no slash: `kg^-1`, `m^-1 s^-1`, `kg^(-1/2)`. After a + /// number in the fraction style, `20000/413 1/kg` would read as one + /// fraction divided again; `20000/413 kg^-1` cannot. + [[nodiscard]] inline std::string coherent_unit_spelling(Dimension dimension, AuthorTextSpelling spellName) + { + struct BaseUnit + { + std::string_view symbol; + Exponent exponent; + }; + std::array const bases { BaseUnit { "m", dimension.length }, BaseUnit { "kg", dimension.mass }, + BaseUnit { "s", dimension.time }, BaseUnit { "A", dimension.current }, + BaseUnit { "K", dimension.temperature }, BaseUnit { "mol", dimension.amount }, + BaseUnit { "cd", dimension.luminosity } }; + auto const unitPower = [](std::string_view symbolText, std::int32_t numeratorPart, std::int32_t denominatorPart) { + std::string factorText { symbolText }; + if (denominatorPart != 1) + factorText += "^(" + std::to_string(numeratorPart) + "/" + std::to_string(denominatorPart) + ")"; + else if (numeratorPart != 1) + factorText += "^" + std::to_string(numeratorPart); + return factorText; + }; + std::string above; + std::string below; + std::string inverse; + std::size_t belowCount = 0; + auto const place = [&](std::string_view symbolText, Exponent baseExponent) { + if (baseExponent.numerator > 0) + above += (above.empty() ? "" : " ") + + unitPower(symbolText, baseExponent.numerator, baseExponent.denominator); + else if (baseExponent.numerator < 0) + { + below += (below.empty() ? "" : " ") + + unitPower(symbolText, -baseExponent.numerator, baseExponent.denominator); + inverse += (inverse.empty() ? "" : " ") + + unitPower(symbolText, baseExponent.numerator, baseExponent.denominator); + ++belowCount; + } + }; + for (std::size_t slot = 0; named_base_in_use(dimension, slot); ++slot) + place(spellName(view(dimension.namedBases[slot].name)), dimension.namedBases[slot].exponent); + for (BaseUnit const& base: bases) + place(base.symbol, base.exponent); + if (below.empty()) + return above; + if (above.empty()) + return inverse; + return above + "/" + (belowCount > 1 ? "(" + below + ")" : below); + } + + /// The unit a rounding's places or digits count in, or a numeric value's + /// bare number is taken in, as the clause after `of` or `in` names it: + /// `round(m, to 2 dp of )`. + /// + /// - A unit with a symbol: its symbol, as @p spellAuthorText writes it. + /// - A dimensionless unit with no symbol: nothing, so the clause is + /// dropped. Such a unit is at scale 1 (`RequireNamedScaledScalar`, + /// `unit.hpp`), and its places are places of the bare number. + /// - A dimensioned unit with no symbol: its size in the coherent unit, + /// exact, then that unit's spelling: `1/1000 kg`, `1000 kg^-1`. The + /// value after a trace's `=` is written in the same coherent unit, so + /// the line says what the places count in. + /// - The same with an offset: its size and its zero, both in the coherent + /// unit: `1 K from 27315/100 K`. The places count steps of the size from + /// that zero, which is how the rounding computes them. + /// + /// Nothing for a magnitude or offset that names no rational (a zero + /// denominator): a malformed unit, refused by every conversion, whose + /// rounding never reaches a value to show. + [[nodiscard]] inline std::string rounding_unit_text(Unit const& roundedIn, AuthorTextSpelling spellAuthorText) + { + std::string_view const symbolText = view(roundedIn.symbolText); + if (!symbolText.empty()) + return spellAuthorText(symbolText); + if (roundedIn.dimension == dim::Scalar) + return {}; + std::expected const unitSize = + Rational::make(roundedIn.magnitudeNumerator, roundedIn.magnitudeDenominator); + if (!unitSize.has_value()) + return {}; + std::string const coherentText = coherent_unit_spelling(roundedIn.dimension, spellAuthorText); + NumberText const sizeText = fraction_text(*unitSize); + std::string spelled = std::string { sizeText.view() } + " " + coherentText; + if (roundedIn.offsetNumerator == 0) + return spelled; + std::expected const unitZero = + Rational::make(roundedIn.offsetNumerator, roundedIn.offsetDenominator); + if (!unitZero.has_value()) + return {}; + NumberText const zeroText = fraction_text(*unitZero); + return spelled + " from " + std::string { zeroText.view() } + " " + coherentText; + } + + /// Whether a value of @p dimension in @p declared is shown in the coherent + /// unit, spelt by `coherent_unit_text`, rather than in @p declared: when + /// @p declared has no symbol and @p dimension is not dimensionless. A + /// unit with no symbol cannot say what scale its number is on, so the + /// number is moved into the one scale its spelling names. The one rule + /// for every place a number is written with its unit: a step's value, a + /// squared deviation, a conformity row, a derivation's header, and a + /// bound a table, a curve or a permitted set declared + /// (`shown_bound_text`), so that every number on a line is in the unit + /// written after it. + /// A dimensionless unit with no symbol is always at scale 1 here: + /// one with a scale is refused where it is written + /// (`RequireNamedScaledScalar`, `unit.hpp`), so its bare number is the + /// value. + [[nodiscard]] constexpr bool spells_coherent_unit(Unit const& declared, Dimension dimension) + { + return view(declared.symbolText).empty() && !(dimension == dim::Scalar); + } + + /// The unit a value of @p dimension declared in @p declared is shown in: + /// the coherent unit where `spells_coherent_unit` says so, @p declared + /// otherwise. + [[nodiscard]] inline Unit shown_unit_of(Unit const& declared, Dimension dimension) + { + return spells_coherent_unit(declared, dimension) ? coherent(dimension) : declared; + } + + /// A value no line can spell, and why: `(not shown: )`. The one + /// spelling of it, for a value its unit cannot show and for a value its + /// style cannot spell in that unit alike. + [[nodiscard]] inline std::string not_shown_text(ArithmeticError whyNot) + { + return "(not shown: " + std::string { describe(whyNot) } + ")"; + } + /// A bound a table declared as a numerator/denominator pair -- a band's /// low or high bound, or a breakpoint's key -- as text, a number in /// @p declaredIn. @@ -979,7 +1138,9 @@ template /// constant holding the same number does -- see that helper. The number is /// written as @p vocabulary's style says, exact and unpadded /// (`detail::typed_number_text`): `863/1000` by default, `0.863` under -/// `NumberStyle::exact_decimal()`. +/// `NumberStyle::exact_decimal()`. A constant in a dimensioned unit with no +/// symbol is written in the coherent unit, exact (`3/1000 kg`), for the reason +/// a trace is. /// /// In LaTeX the symbol is set upright after a thin space, `150\,\mathrm{mm}` /// and `5\,\mathrm{\%}`, as the rounding clause sets it (`detail::latex_unit`): @@ -989,7 +1150,25 @@ template [[nodiscard]] std::string render_node(ConstantNode const& node, V const& vocabulary) { constexpr Unit declaredUnit = U; - if constexpr (D == Dialect::LaTeX) + // A unit with no symbol cannot say what scale its number is on, so the + // constant is written in the coherent unit, exact, as a trace writes it + // (`detail::spells_coherent_unit`). + if constexpr (detail::spells_coherent_unit(declaredUnit, declaredUnit.dimension)) + { + constexpr Unit coherentUnit = coherent(declaredUnit.dimension); + std::expected const inCoherent = + checked_convert(node.number, declaredUnit, coherentUnit); + std::string const numberText = + inCoherent.has_value() + ? detail::styled_number_text(*inCoherent, typed_number_style(vocabulary).exact_only(), coherentUnit) + : detail::not_shown_text(inCoherent.error()); + std::string const coherentText = detail::coherent_unit_spelling(declaredUnit.dimension, detail::verbatim_text); + if constexpr (D == Dialect::LaTeX) + return numberText + detail::unit_clause("\\,", detail::latex_unit(coherentText)); + else + return detail::number_with_unit(numberText, coherentText); + } + else if constexpr (D == Dialect::LaTeX) return detail::typed_number_text(node.number, declaredUnit, vocabulary) + detail::unit_clause("\\,", detail::latex_unit(view(declaredUnit.symbolText))); else @@ -1064,15 +1243,16 @@ namespace detail /// A per-element rounding renders as `RoundNode` does, with every element's /// granularity in the series' order: `round(p(i), to 0/0/1 dp of %)`, and in /// LaTeX `\operatorname{round}_{0/-1/2\,\mathrm{mm}}(...)`. The unit clause is -/// `RoundNode`'s: set upright and escaped in LaTeX (`detail::latex_unit`), and -/// dropped for a unit with no symbol. The mode is absent, for `RoundNode`'s -/// reason, and appears in the trace. +/// `RoundNode`'s: set upright and escaped in LaTeX (`detail::latex_unit`), and, +/// for a unit with no symbol, its size in the coherent unit +/// (`rounding_unit_text`). The mode is absent, for `RoundNode`'s reason, and +/// appears in the trace. template [[nodiscard]] std::string render_node(ElementwiseRoundNode const& node, V const& vocabulary) { std::string const inner = render(node.operand, vocabulary); constexpr Unit roundedIn = U; - std::string const unitSymbol { view(roundedIn.symbolText) }; + std::string const unitSymbol = detail::rounding_unit_text(roundedIn, detail::verbatim_text); // Places already refused (`countMatches`) are not a table to list; the // text is never seen, since the program does not compile. std::string placesText = "(refused)"; @@ -1256,7 +1436,8 @@ namespace detail /// @p inner rounded to @p places decimal places of the unit whose symbol is @p unitSymbol, in dialect /// @p D: `round(, to dp of )`, and in LaTeX /// `\operatorname{round}_{\,}()`, the unit set upright and escaped (`latex_unit`). - /// No unit clause for a unit with no symbol. The one spelling of every node that rounds to one + /// The unit is `rounding_unit_text`'s: a unit with no symbol is named by its size, and only a + /// dimensionless unit at scale 1 has no clause. The one spelling of every node that rounds to one /// number of decimal places -- `RoundNode`, `RoundedRootNode`, `RoundedTranscendentalNode`, /// `RoundedOpaqueOutputNode` -- and of a trace's line for one (`trace_render.hpp`). A rounding of /// each element to its own places (`ElementwiseRoundNode`) has its own spelling. @@ -1332,7 +1513,7 @@ template ( - render(node.operand, vocabulary), Places, std::string { view(declaredUnit.symbolText) }); + render(node.operand, vocabulary), Places, detail::rounding_unit_text(declaredUnit, detail::verbatim_text)); } /// A significant-digits rounding node, spelled the same way as `RoundNode` @@ -1345,7 +1526,7 @@ template (node.operand, vocabulary); constexpr Unit declaredUnit = U; - std::string const unitSymbol { view(declaredUnit.symbolText) }; + std::string const unitSymbol = detail::rounding_unit_text(declaredUnit, detail::verbatim_text); std::string const digitsText = std::to_string(Digits.value); if constexpr (D == Dialect::LaTeX) @@ -1369,7 +1550,7 @@ template (node.radicand, vocabulary); constexpr Unit declaredUnit = U; - std::string const unitSymbol { view(declaredUnit.symbolText) }; + std::string const unitSymbol = detail::rounding_unit_text(declaredUnit, detail::verbatim_text); if constexpr (D == Dialect::LaTeX) return detail::rounding_call("\\sqrt{" + inner + "}", Places, unitSymbol); else @@ -1407,7 +1588,7 @@ template (node.operand, vocabulary); constexpr Unit declaredUnit = U; - std::string const unitSymbol { view(declaredUnit.symbolText) }; + std::string const unitSymbol = detail::rounding_unit_text(declaredUnit, detail::verbatim_text); if constexpr (D == Dialect::LaTeX) return "\\{" + inner + detail::unit_clause("/", detail::latex_unit(unitSymbol)) + "\\}"; @@ -1954,8 +2135,9 @@ template , U, Places, Mode, Origin> const& node, V const& vocabulary) { constexpr Unit declaredUnit = U; - return detail::rounding_call( - render(detail::unrounded(node), vocabulary), Places, std::string { view(declaredUnit.symbolText) }); + return detail::rounding_call(render(detail::unrounded(node), vocabulary), + Places, + detail::rounding_unit_text(declaredUnit, detail::verbatim_text)); } /// A predicate renders as ` `. Not a `Node`, so it diff --git a/include/formula-cpp/trace_render.hpp b/include/formula-cpp/trace_render.hpp index d972449..0d0a2fd 100644 --- a/include/formula-cpp/trace_render.hpp +++ b/include/formula-cpp/trace_render.hpp @@ -586,105 +586,12 @@ namespace detail : std::to_string(recorded.lookupKey)); } - /// A value no line can spell, and why: `(not shown: )`. The one - /// spelling of it, for a value its unit cannot show and for a value its - /// style cannot spell in that unit alike. - [[nodiscard]] inline std::string not_shown_text(ArithmeticError whyNot) - { - return "(not shown: " + std::string { describe(whyNot) } + ")"; - } - - /// The coherent unit of @p dimension, spelt from its base units: - /// `m/s`, `kg/m^3`, `kg/(m s^2)`, `m^(1/2)`; empty for a dimensionless - /// one. Written after every dimensioned value whose unit has no symbol, so - /// that a slope in metres per second does not read as a pure number. - /// - /// A named base dimension is spelt by its name -- the name is also the - /// symbol of its coherent unit -- ahead of the SI units, in the dimension's - /// own order: on its side of the slash, or among the negated factors when - /// nothing stands above it. `EUR`, `EUR s^2/(m^2 kg)` for euros per joule, - /// `EUR^-1 s^-1`, `JPY^-1`, `EUR/JPY`, `EUR^(1/2)`. First, because a - /// tariff is read as money per energy, not as seconds squared of money per - /// metre. Each name goes through `escaped_author_text`: `base_dimension()` - /// admits only letters and digits, but a hand-filled `namedBases` can hold - /// anything. - /// - /// A dimension with no positive exponent is written with negative - /// exponents and no slash: `kg^-1`, `m^-1 s^-1`, `kg^(-1/2)`. After a - /// number in the fraction style, `20000/413 1/kg` would read as one - /// fraction divided again; `20000/413 kg^-1` cannot. + /// The coherent unit of @p dimension spelt from its base units, as a + /// trace line writes it: `coherent_unit_spelling` (`render.hpp`), with + /// each named base dimension's name escaped as author text. [[nodiscard]] inline std::string coherent_unit_text(Dimension dimension) { - struct BaseUnit - { - std::string_view symbol; - Exponent exponent; - }; - std::array const bases { BaseUnit { "m", dimension.length }, BaseUnit { "kg", dimension.mass }, - BaseUnit { "s", dimension.time }, BaseUnit { "A", dimension.current }, - BaseUnit { "K", dimension.temperature }, BaseUnit { "mol", dimension.amount }, - BaseUnit { "cd", dimension.luminosity } }; - auto const unitPower = [](std::string_view symbolText, std::int32_t numeratorPart, std::int32_t denominatorPart) { - std::string factorText { symbolText }; - if (denominatorPart != 1) - factorText += "^(" + std::to_string(numeratorPart) + "/" + std::to_string(denominatorPart) + ")"; - else if (numeratorPart != 1) - factorText += "^" + std::to_string(numeratorPart); - return factorText; - }; - std::string above; - std::string below; - std::string inverse; - std::size_t belowCount = 0; - auto const place = [&](std::string_view symbolText, Exponent baseExponent) { - if (baseExponent.numerator > 0) - above += (above.empty() ? "" : " ") - + unitPower(symbolText, baseExponent.numerator, baseExponent.denominator); - else if (baseExponent.numerator < 0) - { - below += (below.empty() ? "" : " ") - + unitPower(symbolText, -baseExponent.numerator, baseExponent.denominator); - inverse += (inverse.empty() ? "" : " ") - + unitPower(symbolText, baseExponent.numerator, baseExponent.denominator); - ++belowCount; - } - }; - for (std::size_t slot = 0; named_base_in_use(dimension, slot); ++slot) - place(escaped_author_text(view(dimension.namedBases[slot].name)), dimension.namedBases[slot].exponent); - for (BaseUnit const& base: bases) - place(base.symbol, base.exponent); - if (below.empty()) - return above; - if (above.empty()) - return inverse; - return above + "/" + (belowCount > 1 ? "(" + below + ")" : below); - } - - /// Whether a value of @p dimension in @p declared is shown in the coherent - /// unit, spelt by `coherent_unit_text`, rather than in @p declared: when - /// @p declared has no symbol and @p dimension is not dimensionless. A - /// unit with no symbol cannot say what scale its number is on, so the - /// number is moved into the one scale its spelling names. The one rule - /// for every place a number is written with its unit: a step's value, a - /// squared deviation, a conformity row, a derivation's header, and a - /// bound a table, a curve or a permitted set declared - /// (`shown_bound_text`), so that every number on a line is in the unit - /// written after it. - /// A dimensionless unit with no symbol is always at scale 1 here: - /// one with a scale is refused where it is written - /// (`RequireNamedScaledScalar`, `unit.hpp`), so its bare number is the - /// value. - [[nodiscard]] inline bool spells_coherent_unit(Unit const& declared, Dimension dimension) - { - return view(declared.symbolText).empty() && !(dimension == dim::Scalar); - } - - /// The unit a value of @p dimension declared in @p declared is shown in: - /// the coherent unit where `spells_coherent_unit` says so, @p declared - /// otherwise. - [[nodiscard]] inline Unit shown_unit_of(Unit const& declared, Dimension dimension) - { - return spells_coherent_unit(declared, dimension) ? coherent(dimension) : declared; + return coherent_unit_spelling(dimension, escaped_author_text); } /// The text written after a value shown in `shown_unit_of(@p declared, @@ -1093,8 +1000,9 @@ namespace detail /// `round(#1, to 2 dp of mm)`: @p inner rounded to @p granularity decimal places of the unit whose /// symbol is @p unitSymbolText, in `render()`'s words (`rounding_call`), for every step that rounds to - /// one number of decimal places; an element-wise rounding has its own spelling. No unit clause for a - /// unit with no symbol. + /// one number of decimal places; an element-wise rounding has its own spelling. The unit is + /// `rounding_unit_text`'s (`render.hpp`), so that a unit with no symbol is named by its size, in the + /// coherent unit the value after `=` is written in. [[nodiscard]] inline std::string rounding_call_text(std::string const& inner, int granularity, std::string const& unitSymbolText) @@ -1175,17 +1083,20 @@ namespace detail case StepKind::VariantSelected: return sole_operand(shownStep); case StepKind::Round: - return rounding_call_text(sole_operand(shownStep), shownStep.granularity, unit_symbol_text(shownStep.unit)); + return rounding_call_text(sole_operand(shownStep), + shownStep.granularity, + rounding_unit_text(shownStep.unit, escaped_author_text)); case StepKind::RoundSignificant: return "round(" + sole_operand(shownStep) + ", to " + std::to_string(shownStep.granularity) + " sf" - + unit_clause(" of ", unit_symbol_text(shownStep.unit)) + ")"; + + unit_clause(" of ", rounding_unit_text(shownStep.unit, escaped_author_text)) + ")"; // The unit only: the granularity belongs with whose rule it is, // in the suffix -- see `rounding_rule_suffix`. case StepKind::RoundingRuleApplied: - return "round(" + sole_operand(shownStep) + unit_clause(", in ", unit_symbol_text(shownStep.unit)) + ")"; + return "round(" + sole_operand(shownStep) + + unit_clause(", in ", rounding_unit_text(shownStep.unit, escaped_author_text)) + ")"; case StepKind::NumericValue: - return "numeric(" + sole_operand(shownStep) + unit_clause(", in ", unit_symbol_text(shownStep.sourceUnit)) - + ")"; + return "numeric(" + sole_operand(shownStep) + + unit_clause(", in ", rounding_unit_text(shownStep.sourceUnit, escaped_author_text)) + ")"; case StepKind::Conditional: return conditional_expression(shownStep); case StepKind::Constraint: @@ -1258,7 +1169,7 @@ namespace detail // for `Round`. case StepKind::ElementwiseRound: return "round(" + sole_operand(shownStep) + ", to " + granularities_text(shownStep.elementGranularities) - + " dp" + unit_clause(" of ", unit_symbol_text(shownStep.unit)) + ")"; + + " dp" + unit_clause(" of ", rounding_unit_text(shownStep.unit, escaped_author_text)) + ")"; // A declared domain's line is its points, as a per-element // constant's is its values -- see `series_step_line`. case StepKind::SeriesDomain: @@ -1287,8 +1198,9 @@ namespace detail // `render()`'s spelling, `round(sqrt(...), to ...)`: one shownStep, and // the root inside it, because the root itself was never a value. case StepKind::RoundedRoot: - return rounding_call_text( - "sqrt(" + sole_operand(shownStep) + ")", shownStep.granularity, unit_symbol_text(shownStep.unit)); + return rounding_call_text("sqrt(" + sole_operand(shownStep) + ")", + shownStep.granularity, + rounding_unit_text(shownStep.unit, escaped_author_text)); case StepKind::RoundedNaturalLogarithm: return rounded_transcendental_expression(Transcendental::NaturalLogarithm, shownStep); case StepKind::RoundedDecimalLogarithm: @@ -1356,7 +1268,7 @@ namespace detail return rounding_call_text(shownStep.operands.empty() ? std::string { "an opaque output" } : "output of " + sole_operand(shownStep), shownStep.granularity, - unit_symbol_text(shownStep.unit)); + rounding_unit_text(shownStep.unit, escaped_author_text)); // A retry's steps name its result as `render()` does, `w(k)` for // an attempt's value and `w(k-1)` for the one before; the // attempt's and the retry's own lines are `retry_attempt_line` and @@ -3021,7 +2933,7 @@ namespace detail { std::string lineText = rounding_call_text(opaque_output_label(recorded, opaqueLine), recorded.granularity, - unit_symbol_text(recorded.unit)) + rounding_unit_text(recorded.unit, escaped_author_text)) + " = "; bool const callFailed = opaqueLine.call != nullptr && opaqueLine.call->failure != OpaqueFailure::None; if (recorded.error.has_value() && callFailed) diff --git a/test/render_tests.cpp b/test/render_tests.cpp index aeddfa0..93f55e9 100644 --- a/test/render_tests.cpp +++ b/test/render_tests.cpp @@ -45,6 +45,30 @@ struct Strength: formula::Quantity +{ +}; +struct UnlabelledReading: formula::Quantity +{ +}; +struct UnlabelledLoading: formula::Quantity +{ +}; +// A mass unit of magnitude 1 with no symbol: the kilogram's size under no name. +inline constexpr formula::Unit UnlabelledKilogram { .dimension = formula::dim::Mass }; +struct UnlabelledHeft: formula::Quantity +{ +}; + /// A gram squared, for a variance of masses in grams. inline constexpr formula::Unit GramSquared { .dimension = formula::dim::Mass * formula::dim::Mass, .magnitudeNumerator = 1, @@ -471,6 +495,39 @@ TEST_CASE("render: a significant-digits rounding node renders as round(..., to N CHECK(formula::render(rounded) == "\\operatorname{round}_{2\\mathrm{sf},\\,\\mathrm{mm}}(d)"); } +TEST_CASE("render: a rounding in a unit with no symbol names that unit by its size", "[render][rounding]") +{ + constexpr auto toHundredths = + formula::rounded( + var); + CHECK(formula::render(toHundredths) == "round(w, to 2 dp of 1/1000 kg)"); + CHECK(formula::render(toHundredths) == "\\operatorname{round}_{2\\,\\mathrm{1/1000\\ kg}}(w)"); + CHECK(formula::render( + formula::rounded_to_digits( + var)) + == "round(w, to 3 sf of 1/1000 kg)"); + CHECK(formula::render( + formula::rounded( + var)) + == "round(t, to 1 dp of 1 K from 5463/20 K)"); + CHECK(formula::render( + formula::rounded( + var)) + == "round(q, to 0 dp of 1000 kg^-1)"); + // A dimensioned unit of magnitude 1 with no symbol is still named by its + // size, never bare and never "of kg" alone: the reader cannot tell it from + // the coherent unit otherwise. + CHECK(formula::render( + formula::rounded( + var)) + == "round(h, to 2 dp of 1 kg)"); + // A dimensionless unit at scale 1 still writes no clause. + CHECK(formula::render( + formula::rounded( + formula::number(formula::Rational { 1, 3 }))) + == "round(1/3, to 2 dp)"); +} + TEST_CASE("render: a significant-digits rounding node inside a power and inside a product keeps no extra bracket", "[render][rounding]") { diff --git a/test/trace_shown_unit_tests.cpp b/test/trace_shown_unit_tests.cpp index 7c3f8f5..de89fdf 100644 --- a/test/trace_shown_unit_tests.cpp +++ b/test/trace_shown_unit_tests.cpp @@ -88,6 +88,14 @@ struct Share: formula::Quantity { }; +// A Celsius scale with no symbol: an offset unit the rounding clause must name by its size and its zero. +inline constexpr formula::Unit UnnamedCelsius { .dimension = formula::dim::Temperature, + .offsetNumerator = 27315, + .offsetDenominator = 100 }; +struct UnnamedReading: formula::Quantity +{ +}; + template formula::Trace<> recorded_trace(Expression const& formulaExpression, Bound const& inputs) { @@ -319,6 +327,50 @@ TEST_CASE("a value in a unit with no symbol is shown in the coherent unit, with CHECK(trace_text(var * Rational { 2 }, inputs).starts_with("1. m_u = 3/1000 kg\n")); } +TEST_CASE("a rounding in a unit with no symbol names that unit by its size", "[trace-render][shown-unit][rounding]") +{ + // 3.141 of the unnamed gram to 2 places is 3.14 of it: 157/50000 kg. The + // places count in the unnamed gram, and the line says so in the coherent + // unit the value is written in. + auto const masses = formula::environment(formula::Measured { Rational { 3141, 1000 } }); + CHECK(trace_text(formula::rounded( + var), + masses) + == "1. m_u = 3141/1000000 kg\n" + "2. round(#1, to 2 dp of 1/1000 kg) = 157/50000 kg [nearest, ties to even]\n"); + CHECK(trace_text(formula::rounded_to_digits( + var), + masses) + .find("2. round(#1, to 2 sf of 1/1000 kg) = 31/10000 kg") + != std::string::npos); + // 20.5 on the unnamed Celsius scale, rounded to 0 places of it: 21, which + // is 294.15 K. The places count from that scale's zero, 273.15 K. + auto const readings = formula::environment(formula::Measured { Rational { 41, 2 } }); + CHECK(trace_text(formula::rounded( + var), + readings) + == "1. T_u = 5873/20 K\n" + "2. round(#1, to 0 dp of 1 K from 5463/20 K) = 5883/20 K [nearest, ties away from zero]\n"); +} + +TEST_CASE("a constant and a numeric value in a unit with no symbol say what scale their number is on", + "[trace][units]") +{ + // A constant typed as 3 of a unit of 1/1000 kg with no symbol: render() + // writes it in the coherent unit, as a trace does, never as a bare 3. + auto const typedMass = formula::constant(formula::Rational { 3 }); + CHECK(formula::render(typedMass) == "3/1000 kg"); + // Two tokens, so a power of it brackets as one of a constant with a symbol does. + CHECK(formula::render(formula::pow<2>(typedMass)) == "(3/1000 kg)^2"); + + // numeric(x, in ) names the unit its bare number is taken in by its + // size, in render() and in the trace line alike. + auto const bareMass = formula::numeric_value_of(typedMass); + CHECK(formula::render(bareMass) == "numeric(3/1000 kg, in 1/1000 kg)"); + std::string const traced = trace_text(bareMass, formula::environment()); + CHECK(traced.find("numeric(#1, in 1/1000 kg)") != std::string::npos); +} + TEST_CASE("a dimensionless value is still a bare number", "[trace-render][shown-unit]") { auto const inputs = formula::environment(formula::Measured { Rational { 413, 10 } }, @@ -710,6 +762,10 @@ TEST_CASE("every value a trace shows is in the unit written after it", "[trace-r check_each_value_is_in_the_unit_written_after_it(recorded_trace(var * Rational { 2 } - var, inputs)); // A pure number over a mass: kg^-1 after a fraction. check_each_value_is_in_the_unit_written_after_it(recorded_trace(Rational { 2 } / var, inputs)); + // A rounding in a unit with no symbol. + check_each_value_is_in_the_unit_written_after_it(recorded_trace( + formula::rounded(var), + inputs)); } TEST_CASE("every value of a rejection, a bill, the statistics, a precision limit and an opaque call is in the unit written after it", From 704522b27c3a3e3b71bad3eb7cdbdba8a7291a2a Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Mon, 5 Oct 2026 00:43:11 +0200 Subject: [PATCH 15/35] fix(render): set a unit named by its size as LaTeX, and group it In LaTeX a rounding's size clause read as a mixed number, \operatorname{round}_{2\,\mathrm{1/1000\ kg}} as 2 1/1000 kg, and a coherent unit's powers came out as an escaped caret. The coherent spelling now takes a notation: plain text and a trace keep kg^-1, and LaTeX sets each symbol upright with its power raised, 2000\,\mathrm{kg}^{-1}. A size clause is grouped in LaTeX, round_{2\,(1/1000\,\mathrm{kg})}, and a numeric value's quotient too. A constant that cannot be moved into the coherent unit now reads only "(not shown: ...)", with no unit after it, as in a trace. Signed-off-by: Christian Parpart --- CHANGELOG.md | 3 +- include/formula-cpp/render.hpp | 214 ++++++++++++++++++++++---------- test/render_tests.cpp | 48 ++++++- test/trace_shown_unit_tests.cpp | 20 +++ 4 files changed, 212 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84313cf..201ae81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,7 +60,8 @@ change is recorded here. of the kilogram written after it. A unit with an offset is named by its size and its zero, `to 1 dp of 1 K from 5463/20 K`. Only a dimensionless unit at scale 1 still writes no unit clause. A constant in such a unit renders in the coherent unit (`3/1000 kg`, where it wrote a bare `3`), and `numeric(x, in )` names the unit by its size - too. + too. In LaTeX the size is grouped and the unit set upright with raised powers: + `\operatorname{round}_{2\,(1/1000\,\mathrm{kg})}`, `2000\,\mathrm{kg}^{-1}`. ## [0.3.0] - 2026-10-01 diff --git a/include/formula-cpp/render.hpp b/include/formula-cpp/render.hpp index 79ca0dd..1623342 100644 --- a/include/formula-cpp/render.hpp +++ b/include/formula-cpp/render.hpp @@ -209,18 +209,40 @@ namespace detail return PrecedenceOf::value; } + /// Whether a value of @p dimension in @p declared is shown in the coherent + /// unit, spelt by `coherent_unit_spelling`, rather than in @p declared: + /// when @p declared has no symbol and @p dimension is not dimensionless. A + /// unit with no symbol cannot say what scale its number is on, so the + /// number is moved into the one scale its spelling names. The one rule + /// for every place a number is written with its unit: in a trace, a + /// step's value, a squared deviation, a conformity row, a derivation's + /// header, and a bound a table, a curve or a permitted set declared + /// (`shown_bound_text`); in `render()`, a constant + /// (`render_node(ConstantNode)`). So every number is in the unit written + /// after it. + /// A dimensionless unit with no symbol is always at scale 1 here: + /// one with a scale is refused where it is written + /// (`RequireNamedScaledScalar`, `unit.hpp`), so its bare number is the + /// value. + [[nodiscard]] constexpr bool spells_coherent_unit(Unit const& declared, Dimension dimension) + { + return view(declared.symbolText).empty() && !(dimension == dim::Scalar); + } + /// A constant's rendered text is not always an atom in two data-dependent /// ways the type does not carry: a negative number opens with a `-` that /// reads like a unary minus, and a unit with a symbol renders as *two* /// tokens ("150 mm") rather than one -- so `pow<2>` of it would otherwise /// read as `150 mm^2`, i.e. `150 * mm^2`, when the tree means `(150 mm)^2`. /// A dimensioned unit with no symbol writes a unit too, the coherent one - /// (`3/1000 kg`). Both cases must bracket exactly where a `UnaryNode` would. + /// (`3/1000 kg`, `spells_coherent_unit`). Both cases must bracket exactly + /// where a `UnaryNode` would. template [[nodiscard]] constexpr Precedence precedence_of(ConstantNode const& node) noexcept { constexpr Unit declaredUnit = U; - bool const writesUnit = !view(declaredUnit.symbolText).empty() || !(declaredUnit.dimension == dim::Scalar); + bool const writesUnit = + !view(declaredUnit.symbolText).empty() || spells_coherent_unit(declaredUnit, declaredUnit.dimension); return node.number.sign() < 0 || writesUnit ? Precedence::Unary : Precedence::Atom; } @@ -490,6 +512,55 @@ namespace detail return std::string { authored }; } + /// The power a base unit's factor is raised to, as plain text and a trace + /// write it: `^-1`, `^(1/2)`, `^(-1/2)`, and nothing for a power of 1. + [[nodiscard]] inline std::string plain_unit_power(std::int32_t numeratorPart, std::int32_t denominatorPart) + { + if (denominatorPart != 1) + return "^(" + std::to_string(numeratorPart) + "/" + std::to_string(denominatorPart) + ")"; + if (numeratorPart != 1) + return "^" + std::to_string(numeratorPart); + return {}; + } + + /// The same power as LaTeX sets it, a superscript: `^{-1}`, `^{1/2}`, + /// `^{-1/2}`, and nothing for a power of 1. + [[nodiscard]] inline std::string latex_unit_power(std::int32_t numeratorPart, std::int32_t denominatorPart) + { + if (denominatorPart != 1) + return "^{" + std::to_string(numeratorPart) + "/" + std::to_string(denominatorPart) + "}"; + if (numeratorPart != 1) + return "^{" + std::to_string(numeratorPart) + "}"; + return {}; + } + + /// How a unit this library spells itself -- a coherent unit, or a unit + /// with no symbol named by its size -- is set in one notation. Plain text + /// and a trace write `1000 kg^-1` and `1 K from 5463/20 K` + /// (`PlainUnitNotation`). LaTeX sets each symbol upright, a power as a + /// superscript, and a thin space between factors: + /// `1000\,\mathrm{kg}^{-1}` (`LatexUnitNotation`), where escaping the + /// whole plain text would have written the caret as a character. + struct UnitNotation + { + /// A base unit's symbol, or a named base dimension's name, as a factor. + AuthorTextSpelling symbol; + /// The power after a factor: `plain_unit_power` or `latex_unit_power`. + std::string (*power)(std::int32_t numeratorPart, std::int32_t denominatorPart); + /// What stands between two factors, and between a number and its unit. + std::string_view between; + /// What stands between a unit's size and its zero (`rounding_unit_text`). + std::string_view from; + }; + + /// Plain text and a trace: `kg/(m s^2)`, `1 K from 5463/20 K`. + inline constexpr UnitNotation PlainUnitNotation { verbatim_text, plain_unit_power, " ", " from " }; + + /// LaTeX: `\mathrm{kg}/(\mathrm{m}\,\mathrm{s}^{2})`, + /// `1\,\mathrm{K}\text{ from }5463/20\,\mathrm{K}`. Each symbol and name is + /// escaped as `latex_unit` escapes a unit's symbol. + inline constexpr UnitNotation LatexUnitNotation { latex_unit, latex_unit_power, "\\,", "\\text{ from }" }; + /// The coherent unit of @p dimension, spelt from its base units: /// `m/s`, `kg/m^3`, `kg/(m s^2)`, `m^(1/2)`; empty for a dimensionless /// one. Written after every dimensioned value whose unit has no symbol, so @@ -509,7 +580,13 @@ namespace detail /// exponents and no slash: `kg^-1`, `m^-1 s^-1`, `kg^(-1/2)`. After a /// number in the fraction style, `20000/413 1/kg` would read as one /// fraction divided again; `20000/413 kg^-1` cannot. - [[nodiscard]] inline std::string coherent_unit_spelling(Dimension dimension, AuthorTextSpelling spellName) + /// + /// Each factor, its power and the space between two are set in + /// @p notation: as above by default, and in LaTeX + /// `\mathrm{kg}/(\mathrm{m}\,\mathrm{s}^{2})` (`LatexUnitNotation`). + [[nodiscard]] inline std::string coherent_unit_spelling(Dimension dimension, + AuthorTextSpelling spellName, + UnitNotation const& notation = PlainUnitNotation) { struct BaseUnit { @@ -520,27 +597,23 @@ namespace detail BaseUnit { "s", dimension.time }, BaseUnit { "A", dimension.current }, BaseUnit { "K", dimension.temperature }, BaseUnit { "mol", dimension.amount }, BaseUnit { "cd", dimension.luminosity } }; - auto const unitPower = [](std::string_view symbolText, std::int32_t numeratorPart, std::int32_t denominatorPart) { - std::string factorText { symbolText }; - if (denominatorPart != 1) - factorText += "^(" + std::to_string(numeratorPart) + "/" + std::to_string(denominatorPart) + ")"; - else if (numeratorPart != 1) - factorText += "^" + std::to_string(numeratorPart); - return factorText; + auto const unitPower = [&](std::string_view symbolText, std::int32_t numeratorPart, std::int32_t denominatorPart) { + return notation.symbol(symbolText) + notation.power(numeratorPart, denominatorPart); }; + std::string const between { notation.between }; std::string above; std::string below; std::string inverse; std::size_t belowCount = 0; auto const place = [&](std::string_view symbolText, Exponent baseExponent) { if (baseExponent.numerator > 0) - above += (above.empty() ? "" : " ") + above += (above.empty() ? "" : between) + unitPower(symbolText, baseExponent.numerator, baseExponent.denominator); else if (baseExponent.numerator < 0) { - below += (below.empty() ? "" : " ") + below += (below.empty() ? "" : between) + unitPower(symbolText, -baseExponent.numerator, baseExponent.denominator); - inverse += (inverse.empty() ? "" : " ") + inverse += (inverse.empty() ? "" : between) + unitPower(symbolText, baseExponent.numerator, baseExponent.denominator); ++belowCount; } @@ -569,26 +642,32 @@ namespace detail /// value after a trace's `=` is written in the same coherent unit, so /// the line says what the places count in. /// - The same with an offset: its size and its zero, both in the coherent - /// unit: `1 K from 27315/100 K`. The places count steps of the size from + /// unit: `1 K from 5463/20 K`. The places count steps of the size from /// that zero, which is how the rounding computes them. /// /// Nothing for a magnitude or offset that names no rational (a zero /// denominator): a malformed unit, refused by every conversion, whose /// rounding never reaches a value to show. - [[nodiscard]] inline std::string rounding_unit_text(Unit const& roundedIn, AuthorTextSpelling spellAuthorText) + /// + /// Set in @p notation: as above by default, and in LaTeX + /// `1/1000\,\mathrm{kg}` (`LatexUnitNotation`), a symbol as `latex_unit` + /// sets it. + [[nodiscard]] inline std::string rounding_unit_text(Unit const& roundedIn, + AuthorTextSpelling spellAuthorText, + UnitNotation const& notation = PlainUnitNotation) { std::string_view const symbolText = view(roundedIn.symbolText); if (!symbolText.empty()) - return spellAuthorText(symbolText); + return notation.symbol(spellAuthorText(symbolText)); if (roundedIn.dimension == dim::Scalar) return {}; std::expected const unitSize = Rational::make(roundedIn.magnitudeNumerator, roundedIn.magnitudeDenominator); if (!unitSize.has_value()) return {}; - std::string const coherentText = coherent_unit_spelling(roundedIn.dimension, spellAuthorText); + std::string const coherentText = coherent_unit_spelling(roundedIn.dimension, spellAuthorText, notation); NumberText const sizeText = fraction_text(*unitSize); - std::string spelled = std::string { sizeText.view() } + " " + coherentText; + std::string spelled = std::string { sizeText.view() } + std::string { notation.between } + coherentText; if (roundedIn.offsetNumerator == 0) return spelled; std::expected const unitZero = @@ -596,26 +675,25 @@ namespace detail if (!unitZero.has_value()) return {}; NumberText const zeroText = fraction_text(*unitZero); - return spelled + " from " + std::string { zeroText.view() } + " " + coherentText; + return spelled + std::string { notation.from } + std::string { zeroText.view() } + std::string { notation.between } + + coherentText; } - /// Whether a value of @p dimension in @p declared is shown in the coherent - /// unit, spelt by `coherent_unit_text`, rather than in @p declared: when - /// @p declared has no symbol and @p dimension is not dimensionless. A - /// unit with no symbol cannot say what scale its number is on, so the - /// number is moved into the one scale its spelling names. The one rule - /// for every place a number is written with its unit: a step's value, a - /// squared deviation, a conformity row, a derivation's header, and a - /// bound a table, a curve or a permitted set declared - /// (`shown_bound_text`), so that every number on a line is in the unit - /// written after it. - /// A dimensionless unit with no symbol is always at scale 1 here: - /// one with a scale is refused where it is written - /// (`RequireNamedScaledScalar`, `unit.hpp`), so its bare number is the - /// value. - [[nodiscard]] constexpr bool spells_coherent_unit(Unit const& declared, Dimension dimension) + /// The unit clause of a rounding or a numeric value in render()'s dialect + /// @p D: `rounding_unit_text`, with each symbol written as its author + /// wrote it. In LaTeX a size is grouped, `(1/1000\,\mathrm{kg})`, so that + /// `2\,(1/1000\,\mathrm{kg})` cannot read as the mixed number 2 1/1000; a + /// symbol stands alone, `\mathrm{mm}`, as it always has. + template + [[nodiscard]] std::string rounding_unit_in(Unit const& roundedIn) { - return view(declared.symbolText).empty() && !(dimension == dim::Scalar); + if constexpr (D == Dialect::LaTeX) + { + std::string const spelled = rounding_unit_text(roundedIn, verbatim_text, LatexUnitNotation); + return spelled.empty() || !view(roundedIn.symbolText).empty() ? spelled : "(" + spelled + ")"; + } + else + return rounding_unit_text(roundedIn, verbatim_text); } /// The unit a value of @p dimension declared in @p declared is shown in: @@ -1158,15 +1236,18 @@ template constexpr Unit coherentUnit = coherent(declaredUnit.dimension); std::expected const inCoherent = checked_convert(node.number, declaredUnit, coherentUnit); - std::string const numberText = - inCoherent.has_value() - ? detail::styled_number_text(*inCoherent, typed_number_style(vocabulary).exact_only(), coherentUnit) - : detail::not_shown_text(inCoherent.error()); - std::string const coherentText = detail::coherent_unit_spelling(declaredUnit.dimension, detail::verbatim_text); + // A number that cannot be shown has no unit after it, as in a trace. + if (!inCoherent.has_value()) + return detail::not_shown_text(inCoherent.error()); + std::string const numberText = detail::typed_number_text(*inCoherent, coherentUnit, vocabulary); if constexpr (D == Dialect::LaTeX) - return numberText + detail::unit_clause("\\,", detail::latex_unit(coherentText)); + return numberText + + detail::unit_clause("\\,", + detail::coherent_unit_spelling( + declaredUnit.dimension, detail::verbatim_text, detail::LatexUnitNotation)); else - return detail::number_with_unit(numberText, coherentText); + return detail::number_with_unit(numberText, + detail::coherent_unit_spelling(declaredUnit.dimension, detail::verbatim_text)); } else if constexpr (D == Dialect::LaTeX) return detail::typed_number_text(node.number, declaredUnit, vocabulary) @@ -1252,7 +1333,7 @@ template (node.operand, vocabulary); constexpr Unit roundedIn = U; - std::string const unitSymbol = detail::rounding_unit_text(roundedIn, detail::verbatim_text); + std::string const unitText = detail::rounding_unit_in(roundedIn); // Places already refused (`countMatches`) are not a table to list; the // text is never seen, since the program does not compile. std::string placesText = "(refused)"; @@ -1260,10 +1341,9 @@ template (); if constexpr (D == Dialect::LaTeX) - return "\\operatorname{round}_{" + placesText + detail::unit_clause("\\,", detail::latex_unit(unitSymbol)) + "}(" - + inner + ")"; + return "\\operatorname{round}_{" + placesText + detail::unit_clause("\\,", unitText) + "}(" + inner + ")"; else - return "round(" + inner + ", to " + placesText + " dp" + detail::unit_clause(" of ", unitSymbol) + ")"; + return "round(" + inner + ", to " + placesText + " dp" + detail::unit_clause(" of ", unitText) + ")"; } /// A running total renders as a call naming its end: `cumulative(m_r(i), from @@ -1433,22 +1513,22 @@ namespace detail return std::string { transcendental_name(function) } + "(" + argumentText + ")"; } - /// @p inner rounded to @p places decimal places of the unit whose symbol is @p unitSymbol, in dialect - /// @p D: `round(, to dp of )`, and in LaTeX - /// `\operatorname{round}_{\,}()`, the unit set upright and escaped (`latex_unit`). - /// The unit is `rounding_unit_text`'s: a unit with no symbol is named by its size, and only a - /// dimensionless unit at scale 1 has no clause. The one spelling of every node that rounds to one + /// @p inner rounded to @p places decimal places of the unit @p unitText names, in dialect @p D: + /// `round(, to dp of )`, and in LaTeX + /// `\operatorname{round}_{\,}()`. @p unitText is `rounding_unit_in`'s, already in + /// @p D's notation: a unit with no symbol is named by its size, and only a dimensionless unit at scale 1 + /// has no clause. The one spelling of every node that rounds to one /// number of decimal places -- `RoundNode`, `RoundedRootNode`, `RoundedTranscendentalNode`, /// `RoundedOpaqueOutputNode` -- and of a trace's line for one (`trace_render.hpp`). A rounding of /// each element to its own places (`ElementwiseRoundNode`) has its own spelling. template - [[nodiscard]] std::string rounding_call(std::string const& inner, DecimalPlaces places, std::string const& unitSymbol) + [[nodiscard]] std::string rounding_call(std::string const& inner, DecimalPlaces places, std::string const& unitText) { std::string const placesText = std::to_string(places.value); if constexpr (D == Dialect::LaTeX) - return "\\operatorname{round}_{" + placesText + unit_clause("\\,", latex_unit(unitSymbol)) + "}(" + inner + ")"; + return "\\operatorname{round}_{" + placesText + unit_clause("\\,", unitText) + "}(" + inner + ")"; else - return "round(" + inner + ", to " + placesText + " dp" + unit_clause(" of ", unitSymbol) + ")"; + return "round(" + inner + ", to " + placesText + " dp" + unit_clause(" of ", unitText) + ")"; } } // namespace detail @@ -1512,8 +1592,7 @@ template const& node, V const& vocabulary) { constexpr Unit declaredUnit = U; - return detail::rounding_call( - render(node.operand, vocabulary), Places, detail::rounding_unit_text(declaredUnit, detail::verbatim_text)); + return detail::rounding_call(render(node.operand, vocabulary), Places, detail::rounding_unit_in(declaredUnit)); } /// A significant-digits rounding node, spelled the same way as `RoundNode` @@ -1526,14 +1605,14 @@ template (node.operand, vocabulary); constexpr Unit declaredUnit = U; - std::string const unitSymbol = detail::rounding_unit_text(declaredUnit, detail::verbatim_text); + std::string const unitText = detail::rounding_unit_in(declaredUnit); std::string const digitsText = std::to_string(Digits.value); if constexpr (D == Dialect::LaTeX) return "\\operatorname{round}_{" + digitsText + "\\mathrm{sf}" - + detail::unit_clause(",\\,", detail::latex_unit(unitSymbol)) + "}(" + inner + ")"; + + detail::unit_clause(",\\,", unitText) + "}(" + inner + ")"; else - return "round(" + inner + ", to " + digitsText + " sf" + detail::unit_clause(" of ", unitSymbol) + ")"; + return "round(" + inner + ", to " + digitsText + " sf" + detail::unit_clause(" of ", unitText) + ")"; } /// A rounded square root renders as what it computes, a rounding of a root: @@ -1550,11 +1629,11 @@ template (node.radicand, vocabulary); constexpr Unit declaredUnit = U; - std::string const unitSymbol = detail::rounding_unit_text(declaredUnit, detail::verbatim_text); + std::string const unitText = detail::rounding_unit_in(declaredUnit); if constexpr (D == Dialect::LaTeX) - return detail::rounding_call("\\sqrt{" + inner + "}", Places, unitSymbol); + return detail::rounding_call("\\sqrt{" + inner + "}", Places, unitText); else - return detail::rounding_call("sqrt(" + inner + ")", Places, unitSymbol); + return detail::rounding_call("sqrt(" + inner + ")", Places, unitText); } /// A rounded logarithm or exponential renders as what it computes, a rounding of the call: @@ -1588,12 +1667,12 @@ template (node.operand, vocabulary); constexpr Unit declaredUnit = U; - std::string const unitSymbol = detail::rounding_unit_text(declaredUnit, detail::verbatim_text); + std::string const unitText = detail::rounding_unit_in(declaredUnit); if constexpr (D == Dialect::LaTeX) - return "\\{" + inner + detail::unit_clause("/", detail::latex_unit(unitSymbol)) + "\\}"; + return "\\{" + inner + detail::unit_clause("/", unitText) + "\\}"; else - return "numeric(" + inner + detail::unit_clause(", in ", unitSymbol) + ")"; + return "numeric(" + inner + detail::unit_clause(", in ", unitText) + ")"; } /// Pi renders as `\pi` in LaTeX, and as `pi` in every other dialect. @@ -2135,9 +2214,8 @@ template , U, Places, Mode, Origin> const& node, V const& vocabulary) { constexpr Unit declaredUnit = U; - return detail::rounding_call(render(detail::unrounded(node), vocabulary), - Places, - detail::rounding_unit_text(declaredUnit, detail::verbatim_text)); + return detail::rounding_call( + render(detail::unrounded(node), vocabulary), Places, detail::rounding_unit_in(declaredUnit)); } /// A predicate renders as ` `. Not a `Node`, so it diff --git a/test/render_tests.cpp b/test/render_tests.cpp index 93f55e9..147db58 100644 --- a/test/render_tests.cpp +++ b/test/render_tests.cpp @@ -68,6 +68,11 @@ inline constexpr formula::Unit UnlabelledKilogram { .dimension = formula::dim::M struct UnlabelledHeft: formula::Quantity { }; +// A mass unit of a whole thousand kilograms with no symbol. +inline constexpr formula::Unit UnlabelledTonne { .dimension = formula::dim::Mass, .magnitudeNumerator = 1000 }; +struct UnlabelledLoad: formula::Quantity +{ +}; /// A gram squared, for a variance of masses in grams. inline constexpr formula::Unit GramSquared { .dimension = formula::dim::Mass * formula::dim::Mass, @@ -501,7 +506,9 @@ TEST_CASE("render: a rounding in a unit with no symbol names that unit by its si formula::rounded( var); CHECK(formula::render(toHundredths) == "round(w, to 2 dp of 1/1000 kg)"); - CHECK(formula::render(toHundredths) == "\\operatorname{round}_{2\\,\\mathrm{1/1000\\ kg}}(w)"); + // In LaTeX the size is grouped, so that it cannot read as the mixed + // number 2 1/1000, and the unit is set upright with its powers raised. + CHECK(formula::render(toHundredths) == "\\operatorname{round}_{2\\,(1/1000\\,\\mathrm{kg})}(w)"); CHECK(formula::render( formula::rounded_to_digits( var)) @@ -510,10 +517,33 @@ TEST_CASE("render: a rounding in a unit with no symbol names that unit by its si formula::rounded( var)) == "round(t, to 1 dp of 1 K from 5463/20 K)"); + CHECK(formula::render( + formula::rounded( + var)) + == "\\operatorname{round}_{1\\,(1\\,\\mathrm{K}\\text{ from }5463/20\\,\\mathrm{K})}(t)"); + constexpr auto perGramToUnits = + formula::rounded( + var); + CHECK(formula::render(perGramToUnits) == "round(q, to 0 dp of 1000 kg^-1)"); + CHECK(formula::render(perGramToUnits) == "\\operatorname{round}_{0\\,(1000\\,\\mathrm{kg}^{-1})}(q)"); + // A unit of a whole number of kilograms is named by that number. CHECK(formula::render( - formula::rounded( - var)) - == "round(q, to 0 dp of 1000 kg^-1)"); + formula::rounded( + var)) + == "round(L, to 2 dp of 1000 kg)"); + // A rounded root and a rounding of each element name the unit the same way. + constexpr auto rootToHundredths = + formula::rounded_sqrt( + var * var); + CHECK(formula::render(rootToHundredths) == "round(sqrt(w * w), to 2 dp of 1/1000 kg)"); + CHECK(formula::render(rootToHundredths) + == "\\operatorname{round}_{2\\,(1/1000\\,\\mathrm{kg})}(\\sqrt{w \\cdot w})"); + constexpr formula::PlacesTable<2> elementPlaces { formula::DecimalPlaces { 0 }, formula::DecimalPlaces { 2 } }; + constexpr auto eachToPlaces = + formula::rounded_elementwise( + formula::series); + CHECK(formula::render(eachToPlaces) == "round(w(i), to 0/2 dp of 1/1000 kg)"); + CHECK(formula::render(eachToPlaces) == "\\operatorname{round}_{0/2\\,(1/1000\\,\\mathrm{kg})}({w}_{i})"); // A dimensioned unit of magnitude 1 with no symbol is still named by its // size, never bare and never "of kg" alone: the reader cannot tell it from // the coherent unit otherwise. @@ -526,6 +556,16 @@ TEST_CASE("render: a rounding in a unit with no symbol names that unit by its si formula::rounded( formula::number(formula::Rational { 1, 3 }))) == "round(1/3, to 2 dp)"); + + // A numeric value and a constant in such a unit, in LaTeX: the size + // grouped after the quotient's slash, and the coherent unit set upright. + constexpr auto bareWeight = formula::numeric_value_of(var); + CHECK(formula::render(bareWeight) == "numeric(w, in 1/1000 kg)"); + CHECK(formula::render(bareWeight) == "\\{w/(1/1000\\,\\mathrm{kg})\\}"); + CHECK(formula::render(formula::constant(formula::Rational { 3 })) == "3/1000\\,\\mathrm{kg}"); + CHECK(formula::render(formula::constant(formula::Rational { 2 })) == "2000 kg^-1"); + CHECK(formula::render(formula::constant(formula::Rational { 2 })) + == "2000\\,\\mathrm{kg}^{-1}"); } TEST_CASE("render: a significant-digits rounding node inside a power and inside a product keeps no extra bracket", diff --git a/test/trace_shown_unit_tests.cpp b/test/trace_shown_unit_tests.cpp index de89fdf..914cc5a 100644 --- a/test/trace_shown_unit_tests.cpp +++ b/test/trace_shown_unit_tests.cpp @@ -351,6 +351,26 @@ TEST_CASE("a rounding in a unit with no symbol names that unit by its size", "[t readings) == "1. T_u = 5873/20 K\n" "2. round(#1, to 0 dp of 1 K from 5463/20 K) = 5883/20 K [nearest, ties away from zero]\n"); + + // A rounded root, a rounded output of an opaque operation and a method's + // rounding rule name the unit the same way. + CHECK(trace_text(formula::rounded_sqrt( + var * var), + masses) + .find("round(sqrt(#3), to 2 dp of 1/1000 kg) = 157/50000 kg") + != std::string::npos); + CHECK(trace_text(formula::rounded_output<"span", UnnamedGram, formula::DecimalPlaces { 2 }, formula::RoundingMode::HalfEven>( + lowestAndSpan), + determinations) + .find(", to 2 dp of 1/1000 kg) = 7/2000 kg") + != std::string::npos); + auto const doubled = formula::method( + formula::variants(formula::variant(var * Rational { 2 })), + formula::rounding_rule(), + formula::constraints()); + formula::Trace<> ruleTrace {}; + (void) formula::evaluate_method(doubled, masses, formula::RecordingSink<> { ruleTrace }); + CHECK(formula::render_trace(ruleTrace, { .maxSteps = 20 }).find(", in 1/1000 kg) = 157/25000 kg") != std::string::npos); } TEST_CASE("a constant and a numeric value in a unit with no symbol say what scale their number is on", From 2701859001df625aef38868323620ab71a03ee51 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Mon, 5 Oct 2026 00:49:13 +0200 Subject: [PATCH 16/35] docs(render): say how LaTeX escapes a unit's factors, and name the rounding clause's helper `verbatim_text` now says LaTeX escapes each factor as `LatexUnitNotation` sets it, and an element-wise rounding's comment names the clause's helper, `rounding_unit_in`, and the LaTeX grouping of a unit named by its size. `rounding_call_text`'s parameter is `unitText`, since it receives a size clause as well as a symbol. A test pins the LaTeX of a significant-digits rounding in a unit with no symbol. Signed-off-by: Christian Parpart --- include/formula-cpp/render.hpp | 12 ++++++------ include/formula-cpp/trace_render.hpp | 12 ++++++------ test/render_tests.cpp | 4 ++++ 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/include/formula-cpp/render.hpp b/include/formula-cpp/render.hpp index 1623342..19602c7 100644 --- a/include/formula-cpp/render.hpp +++ b/include/formula-cpp/render.hpp @@ -505,8 +505,8 @@ namespace detail using AuthorTextSpelling = std::string (*)(std::string_view); /// @p authored as it is: `render()` writes a unit's symbol verbatim in - /// plain text and Markdown, and LaTeX escapes the whole unit text - /// afterwards (`latex_unit`). + /// plain text and Markdown, and LaTeX escapes each factor as it sets it + /// (`LatexUnitNotation`). [[nodiscard]] inline std::string verbatim_text(std::string_view authored) { return std::string { authored }; @@ -1324,10 +1324,10 @@ namespace detail /// A per-element rounding renders as `RoundNode` does, with every element's /// granularity in the series' order: `round(p(i), to 0/0/1 dp of %)`, and in /// LaTeX `\operatorname{round}_{0/-1/2\,\mathrm{mm}}(...)`. The unit clause is -/// `RoundNode`'s: set upright and escaped in LaTeX (`detail::latex_unit`), and, -/// for a unit with no symbol, its size in the coherent unit -/// (`rounding_unit_text`). The mode is absent, for `RoundNode`'s reason, and -/// appears in the trace. +/// `RoundNode`'s (`detail::rounding_unit_in`): a symbol set upright and escaped +/// in LaTeX, and, for a unit with no symbol, its size in the coherent unit, +/// grouped in LaTeX, `0/2\,(1/1000\,\mathrm{kg})`. The mode is absent, for +/// `RoundNode`'s reason, and appears in the trace. template [[nodiscard]] std::string render_node(ElementwiseRoundNode const& node, V const& vocabulary) { diff --git a/include/formula-cpp/trace_render.hpp b/include/formula-cpp/trace_render.hpp index 0d0a2fd..06ef555 100644 --- a/include/formula-cpp/trace_render.hpp +++ b/include/formula-cpp/trace_render.hpp @@ -998,16 +998,16 @@ namespace detail + " for " + tag_words(compared.subject()); } - /// `round(#1, to 2 dp of mm)`: @p inner rounded to @p granularity decimal places of the unit whose - /// symbol is @p unitSymbolText, in `render()`'s words (`rounding_call`), for every step that rounds to - /// one number of decimal places; an element-wise rounding has its own spelling. The unit is - /// `rounding_unit_text`'s (`render.hpp`), so that a unit with no symbol is named by its size, in the + /// `round(#1, to 2 dp of mm)`: @p inner rounded to @p granularity decimal places of the unit + /// @p unitText names, in `render()`'s words (`rounding_call`), for every step that rounds to + /// one number of decimal places; an element-wise rounding has its own spelling. @p unitText is + /// `rounding_unit_text`'s (`render.hpp`): a unit's symbol, or for a unit with no symbol its size, in the /// coherent unit the value after `=` is written in. [[nodiscard]] inline std::string rounding_call_text(std::string const& inner, int granularity, - std::string const& unitSymbolText) + std::string const& unitText) { - return rounding_call(inner, DecimalPlaces { granularity }, unitSymbolText); + return rounding_call(inner, DecimalPlaces { granularity }, unitText); } /// `round(ln(#1), to 4 dp)`: `render()`'s spelling, one step with the function inside it, because the diff --git a/test/render_tests.cpp b/test/render_tests.cpp index 147db58..46d1cd0 100644 --- a/test/render_tests.cpp +++ b/test/render_tests.cpp @@ -513,6 +513,10 @@ TEST_CASE("render: a rounding in a unit with no symbol names that unit by its si formula::rounded_to_digits( var)) == "round(w, to 3 sf of 1/1000 kg)"); + CHECK(formula::render( + formula::rounded_to_digits( + var)) + == "\\operatorname{round}_{3\\mathrm{sf},\\,(1/1000\\,\\mathrm{kg})}(w)"); CHECK(formula::render( formula::rounded( var)) From 681409703c3f50bcd291d757bc919c6d6e59965d Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Mon, 5 Oct 2026 00:59:51 +0200 Subject: [PATCH 17/35] fix(render): write an unnamed unit's declared numbers in the coherent unit A number a formula declares in a dimensioned unit with no symbol is now moved exactly into the coherent unit and followed by that unit's spelling, in render() as in a trace, so that no number is shown in a scale the text after it does not name. A move that fails writes `(not shown: ...)`, never a number in the wrong scale. A unit with a symbol, and a dimensionless unit with no symbol, which is at scale 1, are never converted, and render as before. The rule now covers a constant, a per-element constant's values (`values(3/1000 kg, 1/200 kg)`), the bands, rows and values of a banded, an exact and an interpolating lookup, a binning's classes, a snap's permitted values, a domain's points and an envelope's limits (`from 1/4 to 1/2 kg`). One helper states the rule, `shown_number`, and `render()` and the trace share it: the bound helper `shown_bound_text`, the unit's spelling `shown_unit_spelling` and the envelope row `shown_limit_row` move from the trace into render.hpp, with the author-text spelling as a parameter. Signed-off-by: Christian Parpart --- CHANGELOG.md | 5 +- docs/display.md | 7 +- include/formula-cpp/render.hpp | 306 +++++++++++++++++++-------- include/formula-cpp/trace_render.hpp | 79 ++----- test/render_tests.cpp | 76 +++++++ 5 files changed, 320 insertions(+), 153 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 201ae81..82f90a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,7 +61,10 @@ change is recorded here. 5463/20 K`. Only a dimensionless unit at scale 1 still writes no unit clause. A constant in such a unit renders in the coherent unit (`3/1000 kg`, where it wrote a bare `3`), and `numeric(x, in )` names the unit by its size too. In LaTeX the size is grouped and the unit set upright with raised powers: - `\operatorname{round}_{2\,(1/1000\,\mathrm{kg})}`, `2000\,\mathrm{kg}^{-1}`. + `\operatorname{round}_{2\,(1/1000\,\mathrm{kg})}`, `2000\,\mathrm{kg}^{-1}`. Every other number a formula declares + in such a unit renders in the coherent unit too, as its trace writes it: a per-element constant's values + (`values(3/1000 kg, 1/200 kg)`), a lookup's bands, rows and the values it gives, a binning's classes, a snap's + permitted values, a domain's points and an envelope's limits. ## [0.3.0] - 2026-10-01 diff --git a/docs/display.md b/docs/display.md index 67b7b65..0c851a2 100644 --- a/docs/display.md +++ b/docs/display.md @@ -265,8 +265,11 @@ the dish's mass in its declared grams: ≈4.2 g constant (the moisture trace's line 5, `25.5 g`), a table's bound or row, a permitted value, a limit. Rounding it would print a number nobody wrote. The dish's typed 1/3 has no exact decimal, and even the rounding style writes it -`1/3` (line 3 above). A constant in a dimensioned unit with no symbol is -written in the coherent unit, exact: `3/1000 kg`. +`1/3` (line 3 above). Every number a formula declares in a dimensioned +unit with no symbol is written in the coherent unit, exact, as its trace +writes it: a constant `3/1000 kg`, a per-element constant +`values(3/1000 kg, 1/200 kg)`, a table's band `1/4 to under 1/2 kg`, a limit +`at least 3/4 kg`. **Nor is either side of a comparison that a trace line states beside its verdict.** Two specimens' moisture contents, checked against a limit of at diff --git a/include/formula-cpp/render.hpp b/include/formula-cpp/render.hpp index 19602c7..8bee92c 100644 --- a/include/formula-cpp/render.hpp +++ b/include/formula-cpp/render.hpp @@ -217,9 +217,10 @@ namespace detail /// for every place a number is written with its unit: in a trace, a /// step's value, a squared deviation, a conformity row, a derivation's /// header, and a bound a table, a curve or a permitted set declared - /// (`shown_bound_text`); in `render()`, a constant - /// (`render_node(ConstantNode)`). So every number is in the unit written - /// after it. + /// (`shown_bound_text`); in `render()`, every number a formula declares + /// -- a constant, a per-element constant's values, a table's bounds and + /// rows, a permitted value, a limit (`shown_number`). So every number is + /// in the unit written after it. /// A dimensionless unit with no symbol is always at scale 1 here: /// one with a scale is refused where it is written /// (`RequireNamedScaledScalar`, `unit.hpp`), so its bare number is the @@ -455,23 +456,16 @@ namespace detail return std::string { exactFraction.view() }; } - /// @p typedNumber, a number its author typed in @p typedIn, as a formula's - /// text under @p vocabulary writes it (`typed_number_style`): `0.863` - /// under an exact-decimal style, `863/1000` under the default fraction. - template - [[nodiscard]] std::string typed_number_text(Rational typedNumber, Unit const& typedIn, V const& vocabulary) - { - return styled_number_text(typedNumber, typed_number_style(vocabulary), typedIn); - } - /// A number followed by its unit's symbol, or the number alone when the /// unit has none (`unit::One`) -- `139 mm`, `863/1000`. /// - /// Factored out of `render_node(ConstantNode)`, which is the spelling this - /// library already had, rather than invented for the lookup tables below: - /// a table states a number in a unit on every one of its rows, and a row - /// that spelled a number differently from a constant holding that same - /// number would be two surfaces disagreeing inside one rendered formula. + /// The spelling `render_node(ConstantNode)` already had, rather than one + /// invented for the lookup tables below: a table states a number in a unit + /// on every one of its rows, and a row that spelled a number differently + /// from a constant holding that same number would be two surfaces + /// disagreeing inside one rendered formula. A constant and a table's row + /// both write a number with its unit through `shown_value_text`, in this + /// spelling outside LaTeX. [[nodiscard]] inline std::string number_with_unit(std::string const& numberText, std::string_view unitSymbol) { return unitSymbol.empty() ? numberText : numberText + " " + std::string { unitSymbol }; @@ -712,6 +706,80 @@ namespace detail return "(not shown: " + std::string { describe(whyNot) } + ")"; } + /// The text written after a number shown in `shown_unit_of(@p declared, + /// @p dimension)`: the coherent unit's spelling, @p declared's symbol, or + /// nothing for a dimensionless value in a unit with no symbol, which is at + /// scale 1. Each symbol and name is written by @p spellAuthorText, set in + /// @p notation: as it is in `render()`, `kg`, and in LaTeX + /// `\mathrm{kg}` (`LatexUnitNotation`); escaped in a trace + /// (`shown_unit_text`, `trace_render.hpp`). + [[nodiscard]] inline std::string shown_unit_spelling(Unit const& declared, + Dimension dimension, + AuthorTextSpelling spellAuthorText, + UnitNotation const& notation = PlainUnitNotation) + { + if (spells_coherent_unit(declared, dimension)) + return coherent_unit_spelling(dimension, spellAuthorText, notation); + return notation.symbol(spellAuthorText(view(declared.symbolText))); + } + + /// @p declaredNumber, a number of @p dimension declared in @p declared, + /// moved exactly into the unit it is shown in (`shown_unit_of`): the + /// coherent unit for a dimensioned unit with no symbol, and @p declared, + /// unchanged, otherwise. + /// + /// **The one rule for every number written with its unit**, in a + /// formula's text and in its trace alike: a bound or a row a table + /// declares, a permitted value, a limit, a constant and a per-element + /// constant's values, so that no number is in a scale the text after it + /// does not name. Only the move can fail -- for a malformed unit, or a + /// unit with an offset whose sum overflows -- and the caller then writes + /// `not_shown_text`, never the number in the wrong scale. + [[nodiscard]] inline std::expected shown_number(Rational declaredNumber, + Unit const& declared, + Dimension dimension) + { + if (!spells_coherent_unit(declared, dimension)) + return declaredNumber; + return checked_convert(declaredNumber, declared, coherent(dimension)); + } + + /// @p declaredNumber, declared in @p declaredIn, as text: moved into the + /// unit it is shown in (`shown_number`) and spelled exact in + /// @p numberStyle there (`styled_number_text`), without that unit's text. + /// A number a formula declares is never shown rounded. The error of the + /// move, where it fails. + [[nodiscard]] inline std::expected shown_number_text(Rational declaredNumber, + Unit const& declaredIn, + NumberStyle numberStyle) + { + std::expected const shownValue = + shown_number(declaredNumber, declaredIn, declaredIn.dimension); + if (!shownValue.has_value()) + return std::unexpected { shownValue.error() }; + return styled_number_text(*shownValue, numberStyle.exact_only(), shown_unit_of(declaredIn, declaredIn.dimension)); + } + + /// @p declaredNumber, declared in @p declaredIn, as `render()` writes it + /// with its unit: `shown_number_text`, then the unit it is shown in set + /// in @p notation (`shown_unit_spelling`) -- `3/1000 kg` for a unit with + /// no symbol, `150 mm`, `863/1000`, and in LaTeX `3/1000\,\mathrm{kg}`. A + /// number that cannot be shown reads `(not shown: ...)`, with no unit + /// after it, as in a trace. + [[nodiscard]] inline std::string shown_value_text(Rational declaredNumber, + Unit const& declaredIn, + NumberStyle numberStyle, + UnitNotation const& notation = PlainUnitNotation) + { + std::expected const numberText = + shown_number_text(declaredNumber, declaredIn, numberStyle); + if (!numberText.has_value()) + return not_shown_text(numberText.error()); + return *numberText + + unit_clause(notation.between, + shown_unit_spelling(declaredIn, declaredIn.dimension, verbatim_text, notation)); + } + /// A bound a table declared as a numerator/denominator pair -- a band's /// low or high bound, or a breakpoint's key -- as text, a number in /// @p declaredIn. @@ -738,6 +806,41 @@ namespace detail return std::to_string(declaredNumerator) + "/" + std::to_string(declaredDenominator); } + /// A bound declared in @p declaredIn as a numerator/denominator pair -- a + /// band's, a curve's row or a permitted value -- spelled exact + /// (`declared_number_text`) in the unit a value declared in @p declaredIn + /// is shown in (`shown_number_text`), without that unit's text: the caller + /// writes `shown_unit_spelling` after the bounds it lists. A bound of a + /// unit with no symbol is moved into the coherent unit, as the value it is + /// compared with is, so that no number in a table is in a scale the text + /// does not name. `render()` and a trace both write a bound through here. + /// + /// Only that move can fail, and the bound then reads `(not shown: ...)` + /// rather than as a number in the wrong scale. A 64-bit pair times a + /// well-formed unit's 64-bit magnitude always fits a `Rational`, so the + /// move fails only for a pair that names no rational, a zero denominator; + /// for a malformed unit, one whose magnitude is zero (`DomainError`) or + /// whose magnitude or offset has a zero denominator (`DivisionByZero`); + /// and for a unit with an offset, whose sum can overflow: a bound of + /// 1/(2^63 - 1) in a unit of magnitude 1/(2^63 - 25) and offset + /// 1/(2^63 - 165) does. A bound of a unit with a symbol is never + /// converted, and never fails. + [[nodiscard]] inline std::string shown_bound_text(std::int64_t declaredNumerator, + std::int64_t declaredDenominator, + Unit const& declaredIn, + NumberStyle numberStyle) + { + if (!spells_coherent_unit(declaredIn, declaredIn.dimension)) + return declared_number_text(declaredNumerator, declaredDenominator, declaredIn, numberStyle); + std::expected const declared = Rational::make(declaredNumerator, declaredDenominator); + if (!declared) + return not_shown_text(declared.error()); + std::expected const shownText = shown_number_text(*declared, declaredIn, numberStyle); + if (!shownText) + return not_shown_text(shownText.error()); + return *shownText; + } + /// The words of a half-open interval whose bounds are already spelled: /// `103 to under 197 mm`, or the bounds alone when @p unitSymbol is /// empty. `band_text` writes every band through it, and so does a trace @@ -754,18 +857,19 @@ namespace detail /// half-open interval in this library** -- see this file's comment for the /// ruling and for the published defect that bought it. /// - /// @p keySymbol is @p keyUnit's symbol as the caller writes it -- the - /// trace escapes it, `render()` does not -- and @p keyUnit is the unit the - /// bounds are numbers in (`declared_number_text`). + /// @p keyUnit is the unit the bounds are declared in, and they are shown + /// as `shown_bound_text` shows them: in the coherent unit for a unit with + /// no symbol. @p keyUnitText is the unit they are shown in, as the caller + /// writes it (`shown_unit_spelling`). [[nodiscard]] inline std::string band_text(Band const& shownBand, - std::string_view keySymbol, + std::string_view keyUnitText, Unit const& keyUnit, NumberStyle numberStyle) { return half_open_text( - declared_number_text(shownBand.lowNumerator, shownBand.lowDenominator, keyUnit, numberStyle), - declared_number_text(shownBand.highNumerator, shownBand.highDenominator, keyUnit, numberStyle), - keySymbol); + shown_bound_text(shownBand.lowNumerator, shownBand.lowDenominator, keyUnit, numberStyle), + shown_bound_text(shownBand.highNumerator, shownBand.highDenominator, keyUnit, numberStyle), + keyUnitText); } /// Author-supplied words -- a key's name -- made literal in Markdown, so @@ -1211,11 +1315,11 @@ template /// A constant renders as its number, followed by its unit's symbol when it has one. /// -/// The number-and-unit spelling is `detail::number_with_unit`, shared with the -/// lookup tables below so that a table's row states a number exactly as a -/// constant holding the same number does -- see that helper. The number is +/// The number-and-unit spelling is `detail::shown_value_text`, shared with the +/// lookup tables and the lists below so that a table's row states a number +/// exactly as a constant holding the same number does. The number is /// written as @p vocabulary's style says, exact and unpadded -/// (`detail::typed_number_text`): `863/1000` by default, `0.863` under +/// (`typed_number_style`): `863/1000` by default, `0.863` under /// `NumberStyle::exact_decimal()`. A constant in a dimensioned unit with no /// symbol is written in the coherent unit, exact (`3/1000 kg`), for the reason /// a trace is. @@ -1230,31 +1334,12 @@ template constexpr Unit declaredUnit = U; // A unit with no symbol cannot say what scale its number is on, so the // constant is written in the coherent unit, exact, as a trace writes it - // (`detail::spells_coherent_unit`). - if constexpr (detail::spells_coherent_unit(declaredUnit, declaredUnit.dimension)) - { - constexpr Unit coherentUnit = coherent(declaredUnit.dimension); - std::expected const inCoherent = - checked_convert(node.number, declaredUnit, coherentUnit); - // A number that cannot be shown has no unit after it, as in a trace. - if (!inCoherent.has_value()) - return detail::not_shown_text(inCoherent.error()); - std::string const numberText = detail::typed_number_text(*inCoherent, coherentUnit, vocabulary); - if constexpr (D == Dialect::LaTeX) - return numberText - + detail::unit_clause("\\,", - detail::coherent_unit_spelling( - declaredUnit.dimension, detail::verbatim_text, detail::LatexUnitNotation)); - else - return detail::number_with_unit(numberText, - detail::coherent_unit_spelling(declaredUnit.dimension, detail::verbatim_text)); - } - else if constexpr (D == Dialect::LaTeX) - return detail::typed_number_text(node.number, declaredUnit, vocabulary) - + detail::unit_clause("\\,", detail::latex_unit(view(declaredUnit.symbolText))); + // (`detail::shown_value_text`). + if constexpr (D == Dialect::LaTeX) + return detail::shown_value_text( + node.number, declaredUnit, typed_number_style(vocabulary), detail::LatexUnitNotation); else - return detail::number_with_unit(detail::typed_number_text(node.number, declaredUnit, vocabulary), - view(declaredUnit.symbolText)); + return detail::shown_value_text(node.number, declaredUnit, typed_number_style(vocabulary)); } /// A unary node renders as its operator followed by its (parenthesised if @@ -1424,13 +1509,15 @@ template /// A per-element constant renders as its list of values, `values(0.7 mm, /// 1.9 mm, ...)`, each spelled as a constant holding it would be -/// (`detail::number_with_unit`), separated as a lookup's rows are. A list -/// already reads as many values, so it carries no index marker. A -/// formula's own values are never truncated. +/// (`detail::shown_value_text`): in the coherent unit for a dimensioned unit +/// with no symbol, `values(3/1000 kg, 1/200 kg)`. The values are separated as +/// a lookup's rows are. A list already reads as many values, so it carries no +/// index marker. A formula's own values are never truncated. template [[nodiscard]] std::string render_node(SeriesConstantNode const& node, V const& vocabulary) { constexpr Unit statedIn = U; + NumberStyle const typedStyle = typed_number_style(vocabulary); std::string listed; for (std::size_t at = 0; at < N; ++at) { @@ -1438,11 +1525,10 @@ template listed += detail::lookup_separator(); // Each value spelled as a `ConstantNode` holding it is, in every // dialect: in LaTeX its unit set upright and escaped. - std::string const elementText = detail::typed_number_text(node.elements[at], statedIn, vocabulary); if constexpr (D == Dialect::LaTeX) - listed += elementText + detail::unit_clause("\\,", detail::latex_unit(view(statedIn.symbolText))); + listed += detail::shown_value_text(node.elements[at], statedIn, typedStyle, detail::LatexUnitNotation); else - listed += detail::number_with_unit(elementText, view(statedIn.symbolText)); + listed += detail::shown_value_text(node.elements[at], statedIn, typedStyle); } if constexpr (D == Dialect::LaTeX) return "\\operatorname{values}(" + listed + ")"; @@ -1753,14 +1839,13 @@ template () + detail::lookup_words_in_dialect(detail::lookup_row_text( - detail::band_text(Bands[bandIndex], view(keyUnit.symbolText), keyUnit, tableStyle), - detail::number_with_unit( - detail::typed_number_text(node.corrections[bandIndex], resultUnit, vocabulary), - view(resultUnit.symbolText)))); + detail::band_text(Bands[bandIndex], keyUnitText, keyUnit, tableStyle), + detail::shown_value_text(node.corrections[bandIndex], resultUnit, tableStyle))); return detail::lookup_call("lookup", render(node.operand, vocabulary), rowText); } @@ -1787,14 +1872,13 @@ template { constexpr Unit resultUnit = ResultUnit; + NumberStyle const tableStyle = typed_number_style(vocabulary); std::string rowText; for (std::size_t keyIndex = 0; keyIndex < Keys.size(); ++keyIndex) rowText += detail::lookup_separator() - + detail::lookup_words_in_dialect(detail::lookup_row_text( - detail::key_text(Keys[keyIndex]), - detail::number_with_unit( - detail::typed_number_text(node.corrections[keyIndex], resultUnit, vocabulary), - view(resultUnit.symbolText)))); + + detail::lookup_words_in_dialect( + detail::lookup_row_text(detail::key_text(Keys[keyIndex]), + detail::shown_value_text(node.corrections[keyIndex], resultUnit, tableStyle))); return detail::lookup_call( "lookup", detail::lookup_words_in_dialect(detail::key_text(node.key)), rowText); @@ -1818,18 +1902,17 @@ template () - + detail::lookup_words_in_dialect(detail::lookup_row_text( - "at " - + detail::number_with_unit( - detail::declared_number_text( - Points[pointIndex].numerator, Points[pointIndex].denominator, keyUnit, tableStyle), - view(keyUnit.symbolText)), - detail::number_with_unit(detail::typed_number_text(node.corrections[pointIndex], resultUnit, vocabulary), - view(resultUnit.symbolText)))); + rowText += detail::lookup_separator() + + detail::lookup_words_in_dialect(detail::lookup_row_text( + "at " + + detail::number_with_unit( + detail::shown_bound_text( + Points[pointIndex].numerator, Points[pointIndex].denominator, keyUnit, tableStyle), + keyUnitText), + detail::shown_value_text(node.corrections[pointIndex], resultUnit, tableStyle))); return detail::lookup_call("interpolate", render(node.operand, vocabulary), rowText); } @@ -1849,12 +1932,13 @@ template 0) listed += ", "; - listed += detail::declared_number_text( + listed += detail::shown_bound_text( Permitted[pointIndex].numerator, Permitted[pointIndex].denominator, keyUnit, tableStyle); } std::string const permittedField = detail::lookup_separator() - + detail::lookup_words_in_dialect(detail::number_with_unit("to " + listed, view(keyUnit.symbolText))); + + detail::lookup_words_in_dialect(detail::number_with_unit( + "to " + listed, detail::shown_unit_spelling(keyUnit, keyUnit.dimension, detail::verbatim_text))); return detail::lookup_call("snap", render(node.operand, vocabulary), permittedField); } @@ -1872,11 +1956,11 @@ template { if (pointIndex > 0) listed += ", "; - listed += detail::declared_number_text( + listed += detail::shown_bound_text( Points[pointIndex].numerator, Points[pointIndex].denominator, declaredIn, tableStyle); } - std::string const pointsText = - detail::lookup_words_in_dialect(detail::number_with_unit(listed, view(declaredIn.symbolText))); + std::string const pointsText = detail::lookup_words_in_dialect(detail::number_with_unit( + listed, detail::shown_unit_spelling(declaredIn, declaredIn.dimension, detail::verbatim_text))); if constexpr (D == Dialect::LaTeX) return "\\operatorname{domain}(" + pointsText + ")"; else @@ -1908,11 +1992,12 @@ template () + detail::lookup_words_in_dialect( - detail::band_text(Classes[classIndex], view(keyUnit.symbolText), keyUnit, tableStyle)); + detail::band_text(Classes[classIndex], keyUnitText, keyUnit, tableStyle)); return detail::lookup_call("bin", render_node(node.source, vocabulary), classText); } @@ -2711,11 +2796,60 @@ namespace detail return "at most " + number_with_unit(styled_number_text(*upperValue, limitStyle, limitsIn), unitSymbol); return "any value"; } + + /// @p limitRow, whose limits are numbers of @p dimension declared in + /// @p declared, with each limit moved into the unit a value declared there + /// is shown in (`shown_number`): unchanged for a unit with a symbol, in + /// the coherent unit for one without. The error of the first limit the + /// move fails for, where it fails; the caller then writes the whole row + /// as `not_shown_text`, never one side of it in the wrong scale. + [[nodiscard]] inline std::expected shown_limit_row(LimitRow const& limitRow, + Unit const& declared, + Dimension dimension) + { + if (!spells_coherent_unit(declared, dimension)) + return limitRow; + auto const inShownUnit = [&](Limit const& side) -> std::expected { + std::optional const sideValue = side.value(); + if (!sideValue.has_value()) + return side; + std::expected const sideShown = shown_number(*sideValue, declared, dimension); + if (!sideShown) + return std::unexpected { sideShown.error() }; + return formula::limit(*sideShown); + }; + std::expected const lowerShown = inShownUnit(limitRow.lower); + if (!lowerShown) + return std::unexpected { lowerShown.error() }; + std::expected const upperShown = inShownUnit(limitRow.upper); + if (!upperShown) + return std::unexpected { upperShown.error() }; + return LimitRow { .lower = *lowerShown, .upper = *upperShown }; + } + + /// One row of an envelope declared in @p limitsIn as `render()` writes + /// it: its limits in the unit they are shown in (`shown_limit_row`), + /// then that unit (`shown_unit_spelling`), as `limit_row_text` words it -- + /// `from 1/4 to 1/2 kg` for a unit with no symbol -- or `(not shown: + /// ...)` for a row the move fails for, as a trace writes it. + [[nodiscard]] inline std::string shown_limit_row_text(LimitRow const& limitRow, + Unit const& limitsIn, + NumberStyle numberStyle) + { + std::expected const shownRow = + shown_limit_row(limitRow, limitsIn, limitsIn.dimension); + if (!shownRow) + return not_shown_text(shownRow.error()); + return limit_row_text(*shownRow, + shown_unit_spelling(limitsIn, limitsIn.dimension, verbatim_text), + shown_unit_of(limitsIn, limitsIn.dimension), + numberStyle); + } } // namespace detail /// Renders a conformity check in dialect @p D: `conform(, , /// ...)`, one field per element in the series' order, each the range it -/// permits (`detail::limit_row_text`), shaped as a lookup is +/// permits (`detail::shown_limit_row_text`), shaped as a lookup is /// (`detail::lookup_call`). The subject carries its series marker. /// /// **The verdict stays out**, for `Constraint`'s reason: it is what a checker @@ -2729,8 +2863,8 @@ template std::string rowFields; for (std::size_t at = 0; at < S::length; ++at) rowFields += detail::lookup_separator() - + detail::lookup_words_in_dialect(detail::limit_row_text( - conformityCheck.envelope[at], view(limitsIn.symbolText), limitsIn, limitStyle)); + + detail::lookup_words_in_dialect( + detail::shown_limit_row_text(conformityCheck.envelope[at], limitsIn, limitStyle)); return detail::lookup_call("conform", render(conformityCheck.subject, vocabulary), rowFields); } diff --git a/include/formula-cpp/trace_render.hpp b/include/formula-cpp/trace_render.hpp index 06ef555..eaccd8a 100644 --- a/include/formula-cpp/trace_render.hpp +++ b/include/formula-cpp/trace_render.hpp @@ -595,48 +595,13 @@ namespace detail } /// The text written after a value shown in `shown_unit_of(@p declared, - /// @p dimension)`: the coherent unit's spelling, the declared unit's - /// escaped symbol, or nothing for a dimensionless value in a unit with - /// no symbol, which is at scale 1. + /// @p dimension)`, as a trace line writes it: `shown_unit_spelling` + /// (`render.hpp`), with each symbol and name escaped as author text. A + /// bound a table declared is shown in that unit by `shown_bound_text` + /// (`render.hpp`), which `render()` writes its tables with too. [[nodiscard]] inline std::string shown_unit_text(Unit const& declared, Dimension dimension) { - return spells_coherent_unit(declared, dimension) ? coherent_unit_text(dimension) : unit_symbol_text(declared); - } - - /// A bound declared in @p declaredIn as a numerator/denominator pair -- a - /// band's, a curve's row or a permitted value -- spelled exact - /// (`declared_number_text`) in the unit a value declared in @p declaredIn - /// is shown in (`shown_unit_of`), without that unit's text: the caller - /// writes `shown_unit_text(declaredIn, declaredIn.dimension)` after the - /// bounds it lists. A bound of a unit with no symbol is moved into the - /// coherent unit, as the value it is compared with is, so that no number - /// on the line is in a scale the line does not name. - /// - /// Only that move can fail, and the bound then reads `(not shown: ...)` - /// rather than as a number in the wrong scale. A 64-bit pair times a - /// well-formed unit's 64-bit magnitude always fits a `Rational`, so the - /// move fails only for a pair that names no rational, a zero denominator; - /// for a malformed unit, one whose magnitude is zero (`DomainError`) or - /// whose magnitude or offset has a zero denominator (`DivisionByZero`); - /// and for a unit with an offset, whose sum can overflow: a bound of - /// 1/(2^63 - 1) in a unit of magnitude 1/(2^63 - 25) and offset - /// 1/(2^63 - 165) does. A bound of a unit with a symbol is never - /// converted, and never fails. - [[nodiscard]] inline std::string shown_bound_text(std::int64_t declaredNumerator, - std::int64_t declaredDenominator, - Unit const& declaredIn, - NumberStyle numberStyle) - { - if (!spells_coherent_unit(declaredIn, declaredIn.dimension)) - return declared_number_text(declaredNumerator, declaredDenominator, declaredIn, numberStyle); - std::expected const declared = Rational::make(declaredNumerator, declaredDenominator); - if (!declared) - return not_shown_text(declared.error()); - Unit const coherentUnit = coherent(declaredIn.dimension); - std::expected const inCoherent = checked_convert(*declared, declaredIn, coherentUnit); - if (!inCoherent) - return not_shown_text(inCoherent.error()); - return styled_number_text(*inCoherent, numberStyle.exact_only(), coherentUnit); + return shown_unit_spelling(declared, dimension, escaped_author_text); } /// Whether two bounds declared as numerator/denominator pairs are one @@ -2152,35 +2117,21 @@ namespace detail /// @p limitRow, whose limits are numbers in @p recorded's unit, as the /// range it permits (`limit_row_text`), in the unit @p recorded's value - /// is shown in (`shown_unit_of`), so that a value and the row it was - /// judged against are never shown in two scales. A limit the shown unit - /// cannot hold is reported, `(not shown: ...)`, never restated. + /// is shown in (`shown_limit_row`, `render.hpp`, which `render()` writes + /// an envelope with too), so that a value and the row it was judged + /// against are never shown in two scales. A limit the shown unit cannot + /// hold is reported, `(not shown: ...)`, never restated. [[nodiscard]] inline std::string conformity_row_text(ShownStep const& recorded, LimitRow const& limitRow, NumberStyle numberStyle) { - if (!spells_coherent_unit(recorded.unit, recorded.dimension)) - return limit_row_text(limitRow, unit_symbol_text(recorded.unit), recorded.unit, numberStyle); - Unit const shownUnit = shown_unit_of(recorded.unit, recorded.dimension); - auto const inShownUnit = [&](Limit const& side) -> std::expected { - std::optional const sideValue = side.value(); - if (!sideValue.has_value()) - return side; - std::expected const sideInShownUnit = - checked_convert(*sideValue, recorded.unit, shownUnit); - if (!sideInShownUnit) - return std::unexpected { sideInShownUnit.error() }; - return formula::limit(*sideInShownUnit); - }; - std::expected const lowerShown = inShownUnit(limitRow.lower); - if (!lowerShown) - return not_shown_text(lowerShown.error()); - std::expected const upperShown = inShownUnit(limitRow.upper); - if (!upperShown) - return not_shown_text(upperShown.error()); - return limit_row_text(LimitRow { .lower = *lowerShown, .upper = *upperShown }, + std::expected const shownRow = + shown_limit_row(limitRow, recorded.unit, recorded.dimension); + if (!shownRow) + return not_shown_text(shownRow.error()); + return limit_row_text(*shownRow, shown_unit_text(recorded.unit, recorded.dimension), - shownUnit, + shown_unit_of(recorded.unit, recorded.dimension), numberStyle); } diff --git a/test/render_tests.cpp b/test/render_tests.cpp index 46d1cd0..91b2bf6 100644 --- a/test/render_tests.cpp +++ b/test/render_tests.cpp @@ -2526,3 +2526,79 @@ TEST_CASE("render: a calculation's typed numbers follow RenderOptions, never rou formula::DecimalPadding::Padded) }; CHECK(formula::render(withFee, formula::DefaultVocabulary {}, exactPadded) == "total = subtotal + 5 EUR"); } + +namespace +{ +/// Invented bounds and rows in the unnamed gram, 250, 500 and 750 of it: +/// written in that scale, they would be numbers a thousand times those of the +/// kilograms written after them. +inline constexpr BandTable<2> UnlabelledGramBands { band(250, 1, 500, 1), band(500, 1, 750, 1) }; +inline constexpr BreakpointTable<2> UnlabelledGramRows { breakpoint(250), breakpoint(500) }; +constexpr formula::Envelope<2> unlabelledGramEnvelope { + formula::LimitRow { formula::limit(rat(250)), formula::limit(rat(500)) }, + formula::LimitRow { formula::limit(rat(750)), formula::unbounded }, +}; +/// A unit with no symbol whose offset makes moving 1/(2^63 - 1) of it into the +/// coherent unit overflow: times a magnitude of 1/(2^63 - 25), plus an offset +/// of 1/(2^63 - 165). +inline constexpr formula::Unit WideOffsetGram { .dimension = formula::dim::Mass, + .magnitudeNumerator = 1, + .magnitudeDenominator = INT64_MAX - 24, + .offsetNumerator = 1, + .offsetDenominator = INT64_MAX - 164 }; +} // namespace + +TEST_CASE("render: every number a formula declares in a unit with no symbol is in the coherent unit", + "[render][shown-unit]") +{ + // A per-element constant's values, as a constant's: 3 and 5 of the + // unnamed gram. + constexpr auto unlabelledValues = formula::series_constant(rat(3), rat(5)); + CHECK(formula::render(unlabelledValues) == "values(3/1000 kg, 1/200 kg)"); + CHECK(formula::render(unlabelledValues) + == "\\operatorname{values}(3/1000\\,\\mathrm{kg},\\allowbreak 1/200\\,\\mathrm{kg})"); + // A value the coherent unit cannot hold says so, with no unit after it, + // as a constant does. + CHECK(formula::render(formula::series_constant(formula::Rational { 1, INT64_MAX })) + == "values((not shown: overflow in exact arithmetic))"); + + // A lookup's bands, and the rows it gives. + constexpr auto unlabelledBanded = banded_lookup( + var, { rat(10), rat(20) }); + CHECK(formula::render(unlabelledBanded) == "lookup(w, 1/4 to under 1/2 kg gives 10 %, 1/2 to under 3/4 kg gives 20 %)"); + CHECK(formula::render(unlabelledBanded) + == "\\operatorname{lookup}(w,\\allowbreak \\mathrm{1/4\\ to\\ under\\ 1/2\\ kg\\ gives\\ 10\\ \\%}," + "\\allowbreak \\mathrm{1/2\\ to\\ under\\ 3/4\\ kg\\ gives\\ 20\\ \\%})"); + CHECK(formula::render(exact_lookup(MouldShape::Cylinder, { rat(250), rat(500), rat(750) })) + == "lookup(key Cylinder, key Cube gives 1/4 kg, key Cylinder gives 1/2 kg, key Prism gives 3/4 kg)"); + + // An interpolating lookup's rows, and the values it states at them. + CHECK(formula::render(interpolating_lookup(var, + { rat(3), rat(5) })) + == "interpolate(w, at 1/4 kg gives 3/1000 kg, at 1/2 kg gives 1/200 kg)"); + + // A snap's permitted values, a declared domain's points and a binning's + // classes. + CHECK(formula::render(formula::snapped( + var)) + == "snap(w, to 1/4, 1/2 kg)"); + CHECK(formula::render(formula::domain) == "domain(1/4, 1/2 kg)"); + CHECK(formula::render(formula::binned(formula::observations)) + == "bin(w(i), 1/4 to under 1/2 kg, 1/2 to under 3/4 kg)"); + + // An envelope's limits. + constexpr auto unlabelledLimits = formula::conformity( + formula::series, unlabelledGramEnvelope, formula::Verdict { "reject the specimen" }); + CHECK(formula::render(unlabelledLimits) == "conform(w(i), from 1/4 to 1/2 kg, at least 3/4 kg)"); + + // A unit with a symbol is never converted: the same tables in grams. + CHECK(formula::render(formula::series_constant(rat(3), rat(5))) == "values(3 g, 5 g)"); + CHECK(formula::render(formula::series_constant(rat(3), rat(5))) + == "\\operatorname{values}(3\\,\\mathrm{g},\\allowbreak 5\\,\\mathrm{g})"); + CHECK(formula::render(banded_lookup(var, + { rat(3), rat(5) })) + == "lookup(w, 250 to under 500 g gives 3 g, 500 to under 750 g gives 5 g)"); + CHECK(formula::render(formula::conformity( + formula::series, unlabelledGramEnvelope, formula::Verdict { "reject the specimen" })) + == "conform(w(i), from 250 to 500 g, at least 750 g)"); +} From f05adcce423c17a0cef5645c91961b54272f9e2a Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Mon, 5 Oct 2026 01:14:52 +0200 Subject: [PATCH 18/35] fix(render): raise a coherent unit's powers in a LaTeX table row A table's row is one run of upright words in LaTeX, escaped as a whole, so a coherent unit with a power, such as kg^-1, was set as escaped characters. A coherent unit is now set outside the row's words, after a thin space, as a constant's unit is: `\mathrm{1000\ to\ under\ 2000}\,\mathrm{kg}^{-1}`. A row in a unit with a symbol keeps its unit among the words, byte for byte as before. An envelope's row has one spelling, `limit_row_text`, for render() and for a trace, with the author-text spelling as a parameter. A derivation's header moves its value into the coherent unit through `shown_number`, the one rule every other declared number follows. Tests pin the LaTeX of a table keyed in a per-gram unit with no symbol, and render()'s text for a point, a permitted value and a limit that the coherent unit cannot hold. Signed-off-by: Christian Parpart --- include/formula-cpp/render.hpp | 359 ++++++++++++++++----------- include/formula-cpp/trace_render.hpp | 25 +- test/render_tests.cpp | 48 +++- 3 files changed, 274 insertions(+), 158 deletions(-) diff --git a/include/formula-cpp/render.hpp b/include/formula-cpp/render.hpp index 8bee92c..a0685aa 100644 --- a/include/formula-cpp/render.hpp +++ b/include/formula-cpp/render.hpp @@ -215,12 +215,12 @@ namespace detail /// unit with no symbol cannot say what scale its number is on, so the /// number is moved into the one scale its spelling names. The one rule /// for every place a number is written with its unit: in a trace, a - /// step's value, a squared deviation, a conformity row, a derivation's - /// header, and a bound a table, a curve or a permitted set declared - /// (`shown_bound_text`); in `render()`, every number a formula declares - /// -- a constant, a per-element constant's values, a table's bounds and - /// rows, a permitted value, a limit (`shown_number`). So every number is - /// in the unit written after it. + /// step's value, a squared deviation and a derivation's header; and in a + /// trace and in `render()` alike, every number a formula declares -- a + /// constant, a per-element constant's values, a bound or a row a table, a + /// curve or a permitted set declared, a limit (`shown_number`, + /// `shown_bound_text`, `shown_limit_row`). So every number is in the unit + /// written after it. /// A dimensionless unit with no symbol is always at scale 1 here: /// one with a scale is refused where it is written /// (`RequireNamedScaledScalar`, `unit.hpp`), so its bare number is the @@ -853,25 +853,6 @@ namespace detail return number_with_unit(lowText + " to under " + highText, unitSymbol); } - /// A half-open band as text: `103 to under 197 mm`. **The one spelling of a - /// half-open interval in this library** -- see this file's comment for the - /// ruling and for the published defect that bought it. - /// - /// @p keyUnit is the unit the bounds are declared in, and they are shown - /// as `shown_bound_text` shows them: in the coherent unit for a unit with - /// no symbol. @p keyUnitText is the unit they are shown in, as the caller - /// writes it (`shown_unit_spelling`). - [[nodiscard]] inline std::string band_text(Band const& shownBand, - std::string_view keyUnitText, - Unit const& keyUnit, - NumberStyle numberStyle) - { - return half_open_text( - shown_bound_text(shownBand.lowNumerator, shownBand.lowDenominator, keyUnit, numberStyle), - shown_bound_text(shownBand.highNumerator, shownBand.highDenominator, keyUnit, numberStyle), - keyUnitText); - } - /// Author-supplied words -- a key's name -- made literal in Markdown, so /// that whatever characters they hold are shown rather than obeyed; Plain /// changes nothing. @@ -1007,14 +988,6 @@ namespace detail return "key " + std::to_string(static_cast(key)); } - /// One row of a rendered lookup table: what selects the row, then what the - /// row gives. `103 to under 197 mm gives 863/1000`, `key Cylinder gives - /// 1127/1000`, `at 241 mm gives 1043/1000`. - [[nodiscard]] inline std::string lookup_row_text(std::string const& selector, std::string const& correction) - { - return selector + " gives " + correction; - } - /// A run of words a lookup contributes, as the dialect writes it: a row, /// the empty table's own statement, or an exact lookup's key. /// @@ -1034,7 +1007,9 @@ namespace detail /// /// Everything a lookup renders goes through here **except the operand of /// a banded or an interpolating lookup**, which is a sub-expression and - /// belongs in math mode -- it has already rendered itself in the dialect. + /// belongs in math mode -- it has already rendered itself in the dialect + /// -- and, in LaTeX, a coherent unit, which `TableWords` sets outside the + /// words so that its powers are raised. /// /// **The words themselves are the same in all three dialects**, before /// LaTeX's escape and wrapper, which is what lets the cross-dialect test @@ -1049,6 +1024,193 @@ namespace detail return words; } + /// A run of words a table or a list states -- a row, a set of permitted + /// values, an envelope's row -- with the unit after its numbers, as + /// dialect @p D writes it: the words as `lookup_words_in_dialect` sets + /// them, and each unit the one its numbers are shown in + /// (`shown_unit_spelling`), every symbol and name written by the spelling + /// it was made with: as it is in `render()`, escaped in a trace. + /// + /// In LaTeX a coherent unit (`spells_coherent_unit`) is set outside the + /// words' `\mathrm{...}`, after a thin space, as a constant's unit is + /// (`LatexUnitNotation`): `\mathrm{1/4\ to\ under\ 1/2}\,\mathrm{kg}^{-1}`, + /// where inside the words its power would be escaped into characters. A + /// symbol stays among the words, as it always has: + /// `\mathrm{103\ to\ under\ 197\ mm}`. + template + class TableWords + { + public: + /// Words whose units' symbols and names @p spellAuthorText writes. + explicit TableWords(AuthorTextSpelling spellAuthorText = verbatim_text): _spellAuthorText { spellAuthorText } {} + + /// @p addedWords, appended as they are. + TableWords& add_words(std::string const& addedWords) + { + _pending += addedWords; + return *this; + } + + /// The unit a number of @p dimension declared in @p declared is shown + /// in, appended after the number just added: ` kg`, or nothing for a + /// dimensionless unit with no symbol. + TableWords& add_unit_of(Unit const& declared, Dimension dimension) + { + if constexpr (D == Dialect::LaTeX) + { + if (spells_coherent_unit(declared, dimension)) + { + set_pending(); + _set += std::string { LatexUnitNotation.between } + + shown_unit_spelling(declared, dimension, _spellAuthorText, LatexUnitNotation); + return *this; + } + } + _pending = number_with_unit(_pending, shown_unit_spelling(declared, dimension, _spellAuthorText)); + return *this; + } + + /// @p declaredNumber, declared in @p declared, and its unit, as + /// `shown_value_text` writes them; or `(not shown: ...)`, with no unit + /// after it. + TableWords& add_value(Rational declaredNumber, Unit const& declared, NumberStyle numberStyle) + { + std::expected const numberText = + shown_number_text(declaredNumber, declared, numberStyle); + if (!numberText.has_value()) + return add_words(not_shown_text(numberText.error())); + return add_words(*numberText).add_unit_of(declared, declared.dimension); + } + + /// The words and units, as @p D writes them. + [[nodiscard]] std::string text() const + { + if (_pending.empty() && !_set.empty()) + return _set; + return _set + lookup_words_in_dialect(_pending); + } + + private: + /// Sets the words not yet set, ahead of a unit set outside them. + void set_pending() + { + if (!_pending.empty()) + _set += lookup_words_in_dialect(_pending); + _pending.clear(); + } + + AuthorTextSpelling _spellAuthorText; + std::string _set; + std::string _pending; + }; + + /// A half-open band as words: `103 to under 197 mm`. **The one spelling of + /// a half-open interval in this library** -- see this file's comment for + /// the ruling and for the published defect that bought it. + /// + /// The bounds are declared in @p keyUnit, and shown as `shown_bound_text` + /// shows them, followed by the unit they are shown in: in the coherent + /// unit for a unit with no symbol. + template + [[nodiscard]] TableWords band_text(Band const& shownBand, Unit const& keyUnit, NumberStyle numberStyle) + { + TableWords bandWords; + bandWords + .add_words(half_open_text( + shown_bound_text(shownBand.lowNumerator, shownBand.lowDenominator, keyUnit, numberStyle), + shown_bound_text(shownBand.highNumerator, shownBand.highDenominator, keyUnit, numberStyle), + {})) + .add_unit_of(keyUnit, keyUnit.dimension); + return bandWords; + } + + /// One row of a rendered lookup table: what selects the row, then what the + /// row gives, @p correction declared in @p resultUnit (`TableWords::add_value`). + /// `103 to under 197 mm gives 863/1000`, `key Cylinder gives 1127/1000`, + /// `at 241 mm gives 1043/1000`. + template + [[nodiscard]] std::string lookup_row_text(TableWords selector, + Rational correction, + Unit const& resultUnit, + NumberStyle numberStyle) + { + return selector.add_words(" gives ").add_value(correction, resultUnit, numberStyle).text(); + } + + /// @p limitRow, whose limits are numbers of @p dimension declared in + /// @p declared, with each limit moved into the unit a value declared there + /// is shown in (`shown_number`): unchanged for a unit with a symbol, in + /// the coherent unit for one without. The error of the first limit the + /// move fails for, where it fails; the caller then writes the whole row + /// as `not_shown_text`, never one side of it in the wrong scale. + [[nodiscard]] inline std::expected shown_limit_row(LimitRow const& limitRow, + Unit const& declared, + Dimension dimension) + { + if (!spells_coherent_unit(declared, dimension)) + return limitRow; + auto const inShownUnit = [&](Limit const& side) -> std::expected { + std::optional const sideValue = side.value(); + if (!sideValue.has_value()) + return side; + std::expected const sideShown = shown_number(*sideValue, declared, dimension); + if (!sideShown) + return std::unexpected { sideShown.error() }; + return formula::limit(*sideShown); + }; + std::expected const lowerShown = inShownUnit(limitRow.lower); + if (!lowerShown) + return std::unexpected { lowerShown.error() }; + std::expected const upperShown = inShownUnit(limitRow.upper); + if (!upperShown) + return std::unexpected { upperShown.error() }; + return LimitRow { .lower = *lowerShown, .upper = *upperShown }; + } + + /// One row of an envelope as the range it permits, the unit after the + /// last number: `from 30 to 40 %`, `at least 60 %`, `at most 5 mm`, or + /// `any value` for a row unbounded on both sides. **The one spelling of + /// an envelope's row**, for `render()` and a trace alike. + /// + /// The limits are numbers of @p dimension declared in @p declared, shown + /// in the unit a value declared there is shown in (`shown_limit_row`): + /// `from 1/4 to 1/2 kg` for a unit with no symbol. A row a limit of which + /// cannot be moved there reads `(not shown: ...)`, never one side of it in + /// the wrong scale. The unit's symbols and names are written by + /// @p spellAuthorText: as they are in `render()`, escaped in a trace. + /// Spelled in `numberStyle.exact_only()`: a limit is one side of the + /// comparison a check states, and is never shown rounded. + template + [[nodiscard]] std::string limit_row_text(LimitRow const& limitRow, + Unit const& declared, + Dimension dimension, + AuthorTextSpelling spellAuthorText, + NumberStyle numberStyle) + { + TableWords rowWords { spellAuthorText }; + std::expected const shownRow = shown_limit_row(limitRow, declared, dimension); + if (!shownRow) + return rowWords.add_words(not_shown_text(shownRow.error())).text(); + Unit const shownIn = shown_unit_of(declared, dimension); + NumberStyle const limitStyle = numberStyle.exact_only(); + std::optional const lowerValue = shownRow->lower.value(); + std::optional const upperValue = shownRow->upper.value(); + if (lowerValue.has_value() && upperValue.has_value()) + rowWords + .add_words("from " + styled_number_text(*lowerValue, limitStyle, shownIn) + " to " + + styled_number_text(*upperValue, limitStyle, shownIn)) + .add_unit_of(declared, dimension); + else if (lowerValue.has_value()) + rowWords.add_words("at least " + styled_number_text(*lowerValue, limitStyle, shownIn)) + .add_unit_of(declared, dimension); + else if (upperValue.has_value()) + rowWords.add_words("at most " + styled_number_text(*upperValue, limitStyle, shownIn)) + .add_unit_of(declared, dimension); + else + rowWords.add_words("any value"); + return rowWords.text(); + } + /// The separator between a rendered lookup's fields. /// /// **LaTeX adds `\allowbreak`, and that is a correctness fix rather than @@ -1839,13 +2001,13 @@ template () - + detail::lookup_words_in_dialect(detail::lookup_row_text( - detail::band_text(Bands[bandIndex], keyUnitText, keyUnit, tableStyle), - detail::shown_value_text(node.corrections[bandIndex], resultUnit, tableStyle))); + + detail::lookup_row_text(detail::band_text(Bands[bandIndex], keyUnit, tableStyle), + node.corrections[bandIndex], + resultUnit, + tableStyle); return detail::lookup_call("lookup", render(node.operand, vocabulary), rowText); } @@ -1876,9 +2038,10 @@ template std::string rowText; for (std::size_t keyIndex = 0; keyIndex < Keys.size(); ++keyIndex) rowText += detail::lookup_separator() - + detail::lookup_words_in_dialect( - detail::lookup_row_text(detail::key_text(Keys[keyIndex]), - detail::shown_value_text(node.corrections[keyIndex], resultUnit, tableStyle))); + + detail::lookup_row_text(detail::TableWords {}.add_words(detail::key_text(Keys[keyIndex])), + node.corrections[keyIndex], + resultUnit, + tableStyle); return detail::lookup_call( "lookup", detail::lookup_words_in_dialect(detail::key_text(node.key)), rowText); @@ -1902,17 +2065,18 @@ template () - + detail::lookup_words_in_dialect(detail::lookup_row_text( - "at " - + detail::number_with_unit( - detail::shown_bound_text( - Points[pointIndex].numerator, Points[pointIndex].denominator, keyUnit, tableStyle), - keyUnitText), - detail::shown_value_text(node.corrections[pointIndex], resultUnit, tableStyle))); + + detail::lookup_row_text( + detail::TableWords {} + .add_words("at " + + detail::shown_bound_text( + Points[pointIndex].numerator, Points[pointIndex].denominator, keyUnit, tableStyle)) + .add_unit_of(keyUnit, keyUnit.dimension), + node.corrections[pointIndex], + resultUnit, + tableStyle); return detail::lookup_call("interpolate", render(node.operand, vocabulary), rowText); } @@ -1937,8 +2101,7 @@ template () - + detail::lookup_words_in_dialect(detail::number_with_unit( - "to " + listed, detail::shown_unit_spelling(keyUnit, keyUnit.dimension, detail::verbatim_text))); + + detail::TableWords {}.add_words("to " + listed).add_unit_of(keyUnit, keyUnit.dimension).text(); return detail::lookup_call("snap", render(node.operand, vocabulary), permittedField); } @@ -1959,8 +2122,8 @@ template listed += detail::shown_bound_text( Points[pointIndex].numerator, Points[pointIndex].denominator, declaredIn, tableStyle); } - std::string const pointsText = detail::lookup_words_in_dialect(detail::number_with_unit( - listed, detail::shown_unit_spelling(declaredIn, declaredIn.dimension, detail::verbatim_text))); + std::string const pointsText = + detail::TableWords {}.add_words(listed).add_unit_of(declaredIn, declaredIn.dimension).text(); if constexpr (D == Dialect::LaTeX) return "\\operatorname{domain}(" + pointsText + ")"; else @@ -1992,12 +2155,9 @@ template () - + detail::lookup_words_in_dialect( - detail::band_text(Classes[classIndex], keyUnitText, keyUnit, tableStyle)); + classText += detail::lookup_separator() + detail::band_text(Classes[classIndex], keyUnit, tableStyle).text(); return detail::lookup_call("bin", render_node(node.source, vocabulary), classText); } @@ -2768,88 +2928,9 @@ template (boundFormula.expression, vocabulary); } -namespace detail -{ - /// One row of an envelope as the range it permits, the unit after the - /// last number: `from 30 to 40 %`, `at least 60 %`, `at most 5 mm`, or - /// `any value` for a row unbounded on both sides. - /// - /// @p unitSymbol is @p limitsIn's symbol as the caller writes it -- the - /// trace escapes it, `render()` does not -- and @p limitsIn is the unit - /// the limits are numbers in. Spelled in `numberStyle.exact_only()`: a - /// limit is one side of the comparison a check states, and is never shown - /// rounded. - [[nodiscard]] inline std::string limit_row_text(LimitRow limitRow, - std::string_view unitSymbol, - Unit const& limitsIn, - NumberStyle numberStyle) - { - NumberStyle const limitStyle = numberStyle.exact_only(); - std::optional const lowerValue = limitRow.lower.value(); - std::optional const upperValue = limitRow.upper.value(); - if (lowerValue.has_value() && upperValue.has_value()) - return "from " + styled_number_text(*lowerValue, limitStyle, limitsIn) + " to " - + number_with_unit(styled_number_text(*upperValue, limitStyle, limitsIn), unitSymbol); - if (lowerValue.has_value()) - return "at least " + number_with_unit(styled_number_text(*lowerValue, limitStyle, limitsIn), unitSymbol); - if (upperValue.has_value()) - return "at most " + number_with_unit(styled_number_text(*upperValue, limitStyle, limitsIn), unitSymbol); - return "any value"; - } - - /// @p limitRow, whose limits are numbers of @p dimension declared in - /// @p declared, with each limit moved into the unit a value declared there - /// is shown in (`shown_number`): unchanged for a unit with a symbol, in - /// the coherent unit for one without. The error of the first limit the - /// move fails for, where it fails; the caller then writes the whole row - /// as `not_shown_text`, never one side of it in the wrong scale. - [[nodiscard]] inline std::expected shown_limit_row(LimitRow const& limitRow, - Unit const& declared, - Dimension dimension) - { - if (!spells_coherent_unit(declared, dimension)) - return limitRow; - auto const inShownUnit = [&](Limit const& side) -> std::expected { - std::optional const sideValue = side.value(); - if (!sideValue.has_value()) - return side; - std::expected const sideShown = shown_number(*sideValue, declared, dimension); - if (!sideShown) - return std::unexpected { sideShown.error() }; - return formula::limit(*sideShown); - }; - std::expected const lowerShown = inShownUnit(limitRow.lower); - if (!lowerShown) - return std::unexpected { lowerShown.error() }; - std::expected const upperShown = inShownUnit(limitRow.upper); - if (!upperShown) - return std::unexpected { upperShown.error() }; - return LimitRow { .lower = *lowerShown, .upper = *upperShown }; - } - - /// One row of an envelope declared in @p limitsIn as `render()` writes - /// it: its limits in the unit they are shown in (`shown_limit_row`), - /// then that unit (`shown_unit_spelling`), as `limit_row_text` words it -- - /// `from 1/4 to 1/2 kg` for a unit with no symbol -- or `(not shown: - /// ...)` for a row the move fails for, as a trace writes it. - [[nodiscard]] inline std::string shown_limit_row_text(LimitRow const& limitRow, - Unit const& limitsIn, - NumberStyle numberStyle) - { - std::expected const shownRow = - shown_limit_row(limitRow, limitsIn, limitsIn.dimension); - if (!shownRow) - return not_shown_text(shownRow.error()); - return limit_row_text(*shownRow, - shown_unit_spelling(limitsIn, limitsIn.dimension, verbatim_text), - shown_unit_of(limitsIn, limitsIn.dimension), - numberStyle); - } -} // namespace detail - /// Renders a conformity check in dialect @p D: `conform(, , /// ...)`, one field per element in the series' order, each the range it -/// permits (`detail::shown_limit_row_text`), shaped as a lookup is +/// permits (`detail::limit_row_text`), shaped as a lookup is /// (`detail::lookup_call`). The subject carries its series marker. /// /// **The verdict stays out**, for `Constraint`'s reason: it is what a checker @@ -2863,8 +2944,8 @@ template std::string rowFields; for (std::size_t at = 0; at < S::length; ++at) rowFields += detail::lookup_separator() - + detail::lookup_words_in_dialect( - detail::shown_limit_row_text(conformityCheck.envelope[at], limitsIn, limitStyle)); + + detail::limit_row_text( + conformityCheck.envelope[at], limitsIn, limitsIn.dimension, detail::verbatim_text, limitStyle); return detail::lookup_call("conform", render(conformityCheck.subject, vocabulary), rowFields); } diff --git a/include/formula-cpp/trace_render.hpp b/include/formula-cpp/trace_render.hpp index eaccd8a..7727ebf 100644 --- a/include/formula-cpp/trace_render.hpp +++ b/include/formula-cpp/trace_render.hpp @@ -2116,23 +2116,17 @@ namespace detail } /// @p limitRow, whose limits are numbers in @p recorded's unit, as the - /// range it permits (`limit_row_text`), in the unit @p recorded's value - /// is shown in (`shown_limit_row`, `render.hpp`, which `render()` writes - /// an envelope with too), so that a value and the row it was judged - /// against are never shown in two scales. A limit the shown unit cannot - /// hold is reported, `(not shown: ...)`, never restated. + /// range it permits, in the unit @p recorded's value is shown in, its + /// symbol escaped: `limit_row_text` (`render.hpp`), the spelling + /// `render()` writes an envelope's row in too, so that a value and the + /// row it was judged against are never shown in two scales. A limit the + /// shown unit cannot hold is reported, `(not shown: ...)`, never + /// restated. [[nodiscard]] inline std::string conformity_row_text(ShownStep const& recorded, LimitRow const& limitRow, NumberStyle numberStyle) { - std::expected const shownRow = - shown_limit_row(limitRow, recorded.unit, recorded.dimension); - if (!shownRow) - return not_shown_text(shownRow.error()); - return limit_row_text(*shownRow, - shown_unit_text(recorded.unit, recorded.dimension), - shown_unit_of(recorded.unit, recorded.dimension), - numberStyle); + return limit_row_text(limitRow, recorded.unit, recorded.dimension, escaped_author_text, numberStyle); } /// A conformity step's line, without its number: `conform(#1)` and every @@ -3401,14 +3395,13 @@ namespace detail // unit, which can overflow for a value its own unit holds well, a // great many kilowatt-hours counted in joules. One with no symbol // moves into the coherent unit and says so, as a trace line's value - // does (`shown_unit_of`); that move is the one that can fail: for a + // does (`shown_number`); that move is the one that can fail: for a // value whose coherent form overflows, and for a malformed unit, one // whose magnitude is zero (`DomainError`) or whose magnitude or offset // has a zero denominator (`DivisionByZero`). Unit const shownUnit = shown_unit_of(shown.unit, shown.unit.dimension); std::expected const inShownUnit = - spells_coherent_unit(shown.unit, shown.unit.dimension) ? checked_convert(*shown.value, shown.unit, shownUnit) - : std::expected { *shown.value }; + shown_number(*shown.value, shown.unit, shown.unit.dimension); if (!inShownUnit) return not_shown_text(inShownUnit.error()); std::expected const spelled = diff --git a/test/render_tests.cpp b/test/render_tests.cpp index 91b2bf6..f155724 100644 --- a/test/render_tests.cpp +++ b/test/render_tests.cpp @@ -2546,6 +2546,13 @@ inline constexpr formula::Unit WideOffsetGram { .dimension = formula::dim::Mass, .magnitudeDenominator = INT64_MAX - 24, .offsetNumerator = 1, .offsetDenominator = INT64_MAX - 164 }; +/// A point and a limit of 1/(2^63 - 1) of that unit, which no coherent unit can hold. +inline constexpr BreakpointTable<1> WideOffsetPoints { breakpoint(1, INT64_MAX) }; +constexpr formula::Envelope<1> wideOffsetEnvelope { formula::LimitRow { + formula::limit(formula::Rational { 1, INT64_MAX }), formula::unbounded } }; +/// One invented band in a per-gram unit with no symbol, whose coherent unit +/// has a power: 1 to under 2 per gram is 1000 to under 2000 per kilogram. +inline constexpr BandTable<1> UnlabelledPerGramBands { band(1, 1, 2, 1) }; } // namespace TEST_CASE("render: every number a formula declares in a unit with no symbol is in the coherent unit", @@ -2566,11 +2573,27 @@ TEST_CASE("render: every number a formula declares in a unit with no symbol is i constexpr auto unlabelledBanded = banded_lookup( var, { rat(10), rat(20) }); CHECK(formula::render(unlabelledBanded) == "lookup(w, 1/4 to under 1/2 kg gives 10 %, 1/2 to under 3/4 kg gives 20 %)"); + // In LaTeX the coherent unit is set outside the row's words, as a + // constant's is, so that a power is raised rather than escaped. CHECK(formula::render(unlabelledBanded) - == "\\operatorname{lookup}(w,\\allowbreak \\mathrm{1/4\\ to\\ under\\ 1/2\\ kg\\ gives\\ 10\\ \\%}," - "\\allowbreak \\mathrm{1/2\\ to\\ under\\ 3/4\\ kg\\ gives\\ 20\\ \\%})"); - CHECK(formula::render(exact_lookup(MouldShape::Cylinder, { rat(250), rat(500), rat(750) })) + == "\\operatorname{lookup}(w,\\allowbreak \\mathrm{1/4\\ to\\ under\\ 1/2}\\,\\mathrm{kg}" + "\\mathrm{\\ gives\\ 10\\ \\%},\\allowbreak \\mathrm{1/2\\ to\\ under\\ 3/4}\\,\\mathrm{kg}" + "\\mathrm{\\ gives\\ 20\\ \\%})"); + constexpr auto perGramBanded = + banded_lookup(var, { rat(10) }); + CHECK(formula::render(perGramBanded) == "lookup(q, 1000 to under 2000 kg^-1 gives 10 %)"); + CHECK(formula::render(perGramBanded) + == "\\operatorname{lookup}(q,\\allowbreak \\mathrm{1000\\ to\\ under\\ 2000}\\,\\mathrm{kg}^{-1}" + "\\mathrm{\\ gives\\ 10\\ \\%})"); + constexpr auto unlabelledKeyed = + exact_lookup(MouldShape::Cylinder, { rat(250), rat(500), rat(750) }); + CHECK(formula::render(unlabelledKeyed) == "lookup(key Cylinder, key Cube gives 1/4 kg, key Cylinder gives 1/2 kg, key Prism gives 3/4 kg)"); + CHECK(formula::render(unlabelledKeyed) + == "\\operatorname{lookup}(\\mathrm{key\\ Cylinder},\\allowbreak " + "\\mathrm{key\\ Cube\\ gives\\ 1/4}\\,\\mathrm{kg},\\allowbreak " + "\\mathrm{key\\ Cylinder\\ gives\\ 1/2}\\,\\mathrm{kg},\\allowbreak " + "\\mathrm{key\\ Prism\\ gives\\ 3/4}\\,\\mathrm{kg})"); // An interpolating lookup's rows, and the values it states at them. CHECK(formula::render(interpolating_lookup(var, @@ -2590,6 +2613,21 @@ TEST_CASE("render: every number a formula declares in a unit with no symbol is i constexpr auto unlabelledLimits = formula::conformity( formula::series, unlabelledGramEnvelope, formula::Verdict { "reject the specimen" }); CHECK(formula::render(unlabelledLimits) == "conform(w(i), from 1/4 to 1/2 kg, at least 3/4 kg)"); + CHECK(formula::render(unlabelledLimits) + == "\\operatorname{conform}({w}_{i},\\allowbreak \\mathrm{from\\ 1/4\\ to\\ 1/2}\\,\\mathrm{kg}," + "\\allowbreak \\mathrm{at\\ least\\ 3/4}\\,\\mathrm{kg})"); + + // A point or a limit the coherent unit cannot hold says so, as a trace + // does: a point in its list, with the unit after the list, and a limit + // for its whole row. + CHECK(formula::render(formula::domain) + == "domain((not shown: overflow in exact arithmetic) kg)"); + CHECK(formula::render(formula::snapped( + var)) + == "snap(w, to (not shown: overflow in exact arithmetic) kg)"); + CHECK(formula::render(formula::conformity( + formula::series, wideOffsetEnvelope, formula::Verdict { "reject the specimen" })) + == "conform(w(i), (not shown: overflow in exact arithmetic))"); // A unit with a symbol is never converted: the same tables in grams. CHECK(formula::render(formula::series_constant(rat(3), rat(5))) == "values(3 g, 5 g)"); @@ -2598,6 +2636,10 @@ TEST_CASE("render: every number a formula declares in a unit with no symbol is i CHECK(formula::render(banded_lookup(var, { rat(3), rat(5) })) == "lookup(w, 250 to under 500 g gives 3 g, 500 to under 750 g gives 5 g)"); + CHECK(formula::render(banded_lookup(var, + { rat(3), rat(5) })) + == "\\operatorname{lookup}(w,\\allowbreak \\mathrm{250\\ to\\ under\\ 500\\ g\\ gives\\ 3\\ g}," + "\\allowbreak \\mathrm{500\\ to\\ under\\ 750\\ g\\ gives\\ 5\\ g})"); CHECK(formula::render(formula::conformity( formula::series, unlabelledGramEnvelope, formula::Verdict { "reject the specimen" })) == "conform(w(i), from 250 to 500 g, at least 750 g)"); From c59c8aaac7f401045692f38c1aa84eba584fc699 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Mon, 5 Oct 2026 01:18:05 +0200 Subject: [PATCH 19/35] docs(render): say a LaTeX lookup row may be several atoms A row that holds a coherent unit is set as words, a thin space and the unit's own factors and powers, not as one atom. The separator's comment said each row is one atom; it now says a row may be several, none of which is a break point in math, so its conclusion stands. Signed-off-by: Christian Parpart --- include/formula-cpp/render.hpp | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/include/formula-cpp/render.hpp b/include/formula-cpp/render.hpp index a0685aa..991190d 100644 --- a/include/formula-cpp/render.hpp +++ b/include/formula-cpp/render.hpp @@ -1214,10 +1214,13 @@ namespace detail /// The separator between a rendered lookup's fields. /// /// **LaTeX adds `\allowbreak`, and that is a correctness fix rather than - /// typographic polish.** Each row is one atomic `\mathrm{...}`, and TeX - /// gives a math comma no break penalty at all -- so without this there is - /// **no legal break point anywhere in a rendered lookup, at any row - /// count**. A table does not wrap; it runs off the line, and a wide enough + /// typographic polish.** Each row is one atomic `\mathrm{...}`, or, when + /// it holds a coherent unit, a run of atoms joined by thin spaces, + /// slashes and powers (`\mathrm{...}\,\mathrm{kg}^{-1}`), none of which + /// is a break point in math either; and TeX gives a math comma no break + /// penalty at all -- so without this there is **no legal break point + /// anywhere in a rendered lookup, at any row count**. A table does not + /// wrap; it runs off the line, and a wide enough /// one runs off the paper. A reader of a truncated formula is told nothing /// is missing, which is the same class of defect as the Markdown link /// syntax that dropped an operand from a published page -- see this file's From 460bb31f853e6952260f142aee07e61f1ef59876 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Mon, 5 Oct 2026 01:24:58 +0200 Subject: [PATCH 20/35] fix(trace): read a precision limit's first pass in its level's unit Pass 1 restates the level's value but took its unit from the types alone: the limit's quantity, else the level expression's, else the coherent unit. A level constant in grams read 40 g on its own line and 1/25 kg on the pass-1 line. Pass 1 now borrows the unit of the step it restates, under the rule pass 2 and a conditional use; the unit the types give stays the fallback. Signed-off-by: Christian Parpart --- CHANGELOG.md | 3 +++ docs/tracing.md | 4 +++- include/formula-cpp/trace.hpp | 14 +++++++++++--- test/trace_shown_unit_tests.cpp | 34 +++++++++++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82f90a1..5756438 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,9 @@ change is recorded here. in such a unit renders in the coherent unit too, as its trace writes it: a per-element constant's values (`values(3/1000 kg, 1/200 kg)`), a lookup's bands, rows and the values it gives, a binning's classes, a snap's permitted values, a domain's points and an envelope's limits. +- A precision limit's first pass reads in the unit of the level step it restates, as its second pass already did: + a level constant declared in grams reads `40 g` on both lines, where the first pass read `1/25 kg`. The unit the + limit's quantities give is still used when the level's step has none to lend. ## [0.3.0] - 2026-10-01 diff --git a/docs/tracing.md b/docs/tracing.md index 27e7fcc..686e3b3 100644 --- a/docs/tracing.md +++ b/docs/tracing.md @@ -395,7 +395,9 @@ A computed step borrows its unit off the steps it read in these cases: their two precisions. - A negation and an absolute value read in their operand's unit, and a conditional in its chosen branch's: `if #1 > #2 then #3 = 60 MPa`. A - precision limit reads in its second pass's. + precision limit reads in its second pass's, and its first pass in the unit + of the level it restates: a level constant in grams reads in grams on both + lines. - A value that is a point on its operand's scale -- a mean, a pass's mean, a rejected determination -- reads in that operand's unit when it has a symbol, offset or not: a mean of Celsius readings is a Celsius reading. diff --git a/include/formula-cpp/trace.hpp b/include/formula-cpp/trace.hpp index 51da808..c8b24c9 100644 --- a/include/formula-cpp/trace.hpp +++ b/include/formula-cpp/trace.hpp @@ -3828,9 +3828,11 @@ class RecordingSink /// Records the pass-1 step, claiming the level expression's step as its /// operand, and binds the level for the limit expression that follows. /// - /// Its value is in @p levelUnit, the unit of the quantity the limit's - /// placeholders name, so that the level reads as the results do; its - /// dimension is the level expression's. + /// It reads in the unit of the step it restates when that unit has a + /// symbol and holds exactly its value (`detail::restated_unit_or`), as + /// pass 2 does; otherwise in @p levelUnit, the unit of the quantity the + /// limit's placeholders name, so that the level reads as the results do. + /// Its dimension is the level expression's. void precision_level_produced(PrecisionKind precisionKind, Unit levelUnit, Evaluated const& produced) { // Told without `precision_level_entered`, or after a second sink @@ -3857,6 +3859,12 @@ class RecordingSink levelStep.error = produced.error(); else if (produced->has_value()) levelStep.value = **produced; + // Pass 1 restates the level expression's value, so it reads in the + // unit of the step it restates, as pass 2 and a conditional do: a + // level constant in grams reads in grams on both lines. The unit the + // types give stays the answer when nothing can be borrowed. + levelStep.unit = detail::restated_unit_or(_trace->steps, levelStep.operands, levelStep.dimension, + levelStep.value, levelUnit); std::size_t const levelIndex = _trace->steps.size(); std::size_t const recordIndex = _trace->precisionRecords.size(); diff --git a/test/trace_shown_unit_tests.cpp b/test/trace_shown_unit_tests.cpp index 914cc5a..a180922 100644 --- a/test/trace_shown_unit_tests.cpp +++ b/test/trace_shown_unit_tests.cpp @@ -540,6 +540,35 @@ TEST_CASE("a conditional reads in its chosen branch's unit, offset or not", "[tr "4. if #1 > #2 then #3 = 25 \xc2\xb0" "C\n"); } +TEST_CASE("a precision limit's first pass reads in the unit of the level it restates", "[trace-render][shown-unit][precision]") +{ + // The level is a constant in grams and the limit names no quantity, so + // nothing in the types says grams: pass 1 reads off the step it restates, + // 40 g, never 1/25 kg. + CHECK(trace_text(formula::precision_limit( + formula::constant(Rational { 40 }), formula::constant(Rational { 1 })), + determinations) + .starts_with("1. 40 g\n" + "2. level (pass 1 of 2) = #1 = 40 g\n")); + // A level constant in a unit with no symbol cannot lend its unit + // (`restated_unit_or` borrows only a unit with a symbol): pass 1 stays in + // the unit the types give, the coherent kilogram, as before. + CHECK(trace_text(formula::precision_limit( + formula::constant(Rational { 40000 }), formula::constant(Rational { 1 })), + determinations) + .find("2. level (pass 1 of 2) = #1 = 40 kg\n") + != std::string::npos); + // A level constant in degrees Celsius is a point on an offset scale, which + // `borrowable_for_a_point` lets a restating step show: pass 1 reads in + // degrees Celsius, as the constant's own line does, never as a kelvin + // difference. + CHECK(trace_text(formula::precision_limit( + formula::constant(Rational { 20 }), formula::constant(Rational { 1 })), + determinations) + .find("2. level (pass 1 of 2) = #1 = 20 \xc2\xb0" "C\n") + != std::string::npos); +} + TEST_CASE("a Celsius reading scaled by a pure number, and its absolute value, read in kelvin", "[trace-render][shown-unit]") { @@ -843,6 +872,11 @@ TEST_CASE("every value of a rejection, a bill, the statistics, a precision limit formula::constant(Rational { 1, 7 })) * var, pair)); + // A precision limit over a level constant in grams. + check_each_value_is_in_the_unit_written_after_it(recorded_trace( + formula::precision_limit(formula::constant(Rational { 40 }), + formula::constant(Rational { 1 })), + pair)); // An opaque call, its outputs, and a sum over one of them. check_each_value_is_in_the_unit_written_after_it( From adc5e74fa6c1ed912c91ce6a9f76ae701d26403b Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Mon, 5 Oct 2026 01:24:59 +0200 Subject: [PATCH 21/35] refactor(trace): say why a snap's exact hit compares raw pairs A snap decides it sat on a permitted value by comparing two raw pairs, where a lookup's rows compare values. That is exact: an exact hit records one row twice, and the permitted set is strictly ascending by value. A comment now says so. A missed curve lookup also spells its low bound only on the path that writes it. Signed-off-by: Christian Parpart --- include/formula-cpp/trace_render.hpp | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/include/formula-cpp/trace_render.hpp b/include/formula-cpp/trace_render.hpp index 7727ebf..9246918 100644 --- a/include/formula-cpp/trace_render.hpp +++ b/include/formula-cpp/trace_render.hpp @@ -787,14 +787,16 @@ namespace detail // and "runs 15/2 to 15/2 mm" would describe it as a range it is not. // `at ` is the spelling `render()` gives a breakpoint, for the // same reason: a row is a point. - std::string const lowText = shown_bound_text( - recorded.coveredRange->lowNumerator, recorded.coveredRange->lowDenominator, keyUnit, numberStyle); if (same_declared_bound(recorded.coveredRange->lowNumerator, recorded.coveredRange->lowDenominator, recorded.coveredRange->highNumerator, recorded.coveredRange->highDenominator)) + { + std::string const onlyRowText = shown_bound_text( + recorded.coveredRange->lowNumerator, recorded.coveredRange->lowDenominator, keyUnit, numberStyle); return "outside the curve, whose only row is at " - + number_with_unit(lowText, shown_unit_text(keyUnit, keyUnit.dimension)); + + number_with_unit(onlyRowText, shown_unit_text(keyUnit, keyUnit.dimension)); + } return "outside the curve, which runs " + closed_range_text(*recorded.coveredRange, keyUnit, numberStyle); } @@ -2053,6 +2055,14 @@ namespace detail shown_bound_text(neighbours.low.numerator, neighbours.low.denominator, keyUnit, numberStyle), keySymbol); std::string const highText = number_with_unit( shown_bound_text(neighbours.high.numerator, neighbours.high.denominator, keyUnit, numberStyle), keySymbol); + // Raw pairs, not values, and exact all the same: an exact hit + // records the one row it hit twice, from a single index + // (`locate_and_snap`, `snap.hpp`), and the permitted set is + // strictly ascending by value (`RequireValidBreakpointTable`), so + // two different rows never hold one value. A lookup's segment and + // a missed lookup's range compare by value + // (`same_declared_bound`) because a table's rows can be typed as + // different pairs of one number. if (neighbours.low == neighbours.high) return " [on " + lowText + "]"; if (recorded.tieBroken) From 393d917dc8f96870d4345d5a01e30d74851d2198 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Mon, 5 Oct 2026 01:28:48 +0200 Subject: [PATCH 22/35] test(trace): pin a Celsius precision level's own line and its first pass The check said the first pass reads in degrees Celsius as the constant's own line does, but pinned only the first pass. It now pins both lines, as the gram check does. The snap comment no longer says a lookup's rows compare by value because they can be typed as different pairs: they too are strictly ascending, and comparing by value simply does not depend on how a row was typed. Signed-off-by: Christian Parpart --- include/formula-cpp/trace_render.hpp | 4 ++-- test/trace_shown_unit_tests.cpp | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/include/formula-cpp/trace_render.hpp b/include/formula-cpp/trace_render.hpp index 9246918..3a19880 100644 --- a/include/formula-cpp/trace_render.hpp +++ b/include/formula-cpp/trace_render.hpp @@ -2061,8 +2061,8 @@ namespace detail // strictly ascending by value (`RequireValidBreakpointTable`), so // two different rows never hold one value. A lookup's segment and // a missed lookup's range compare by value - // (`same_declared_bound`) because a table's rows can be typed as - // different pairs of one number. + // (`same_declared_bound`), which costs nothing there and does not + // depend on how a row was typed. if (neighbours.low == neighbours.high) return " [on " + lowText + "]"; if (recorded.tieBroken) diff --git a/test/trace_shown_unit_tests.cpp b/test/trace_shown_unit_tests.cpp index a180922..5dfe4b5 100644 --- a/test/trace_shown_unit_tests.cpp +++ b/test/trace_shown_unit_tests.cpp @@ -565,8 +565,8 @@ TEST_CASE("a precision limit's first pass reads in the unit of the level it rest CHECK(trace_text(formula::precision_limit( formula::constant(Rational { 20 }), formula::constant(Rational { 1 })), determinations) - .find("2. level (pass 1 of 2) = #1 = 20 \xc2\xb0" "C\n") - != std::string::npos); + .starts_with("1. 20 \xc2\xb0" "C\n" + "2. level (pass 1 of 2) = #1 = 20 \xc2\xb0" "C\n")); } TEST_CASE("a Celsius reading scaled by a pure number, and its absolute value, read in kelvin", From cd86bdc536c36da2865b6a62ea4991dfc7c001e4 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Mon, 5 Oct 2026 02:04:30 +0200 Subject: [PATCH 23/35] fix(trace): refuse a series handed to explain or checked_explain in the library's words A series handed to explain or checked_explain failed with the compiler's "no matching function": each had an overload for a single-value expression and one for a bound formula, and a series is neither. Each now has a series overload that refuses it as evaluate refuses one, pointing at explain_series, the verb that gives a series' derivation. trace_of and the bound forms refuse a series as before. Signed-off-by: Christian Parpart --- CHANGELOG.md | 2 ++ docs/series.md | 8 +++++++ docs/tracing.md | 5 ++++ include/formula-cpp/evaluate.hpp | 18 +++++++++++++++ include/formula-cpp/trace.hpp | 22 ++++++++++++++++++ test/CMakeLists.txt | 10 ++++++++ .../checked_explain_series_as_single.cpp | 23 +++++++++++++++++++ test/negative/explain_series_as_single.cpp | 23 +++++++++++++++++++ 8 files changed, 111 insertions(+) create mode 100644 test/negative/checked_explain_series_as_single.cpp create mode 100644 test/negative/explain_series_as_single.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index d6da6c2..6c7ec87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,8 @@ change is recorded here. - The numeric headroom page's least-squares table gives the most bits the exact curve fit's wide integers used, as the overflow census measures it, in place of figures no test checked; the opaque-operation guide's widths of an exact fit's outputs are pinned by a test. +- `explain` and `checked_explain` handed a series refuse it in the library's words, pointing at `explain_series`, + instead of failing with "no matching function". ## [0.3.0] - 2026-10-01 diff --git a/docs/series.md b/docs/series.md index 88e28ba..2e83060 100644 --- a/docs/series.md +++ b/docs/series.md @@ -52,6 +52,14 @@ refused in the library's words: static assertion failed: formula: this expression is a series, not a single value; evaluate it with checked_evaluate_series, or reduce it to one value first (sum, interpolate_at) ``` +Handed to `explain` or `checked_explain`, which trace a single value, it is +refused the same way, pointing at `explain_series`, the verb that gives a +series' outcome together with its derivation: + +``` +static assertion failed: formula: this expression is a series, not a single value; explain it with explain_series, or reduce it to one value first (sum, interpolate_at) +``` + Anywhere else, the compiler reports only that no function or operator matches. That covers the operand of `snapped`, `rounded`, `pow` or `documented`, the point `interpolate_at` reads at, and one side of a diff --git a/docs/tracing.md b/docs/tracing.md index 686e3b3..03c05e0 100644 --- a/docs/tracing.md +++ b/docs/tracing.md @@ -227,6 +227,11 @@ was typed in rather than derived leaves `trace` empty, as it does for `double` is traced by calling its `checked_evaluate_si` with your own `RecordingSink`. +A series handed to `explain` or `checked_explain` does not compile: both +trace a single value, and they say so in the library's words, pointing at +`explain_series`. Reduce the series to one value first (`sum`, +`interpolate_at`) to trace that value instead. + ## Just the trace Code that only shows how a number was reached has no use for the outcome, and diff --git a/include/formula-cpp/evaluate.hpp b/include/formula-cpp/evaluate.hpp index 9eda9ca..4e6c616 100644 --- a/include/formula-cpp/evaluate.hpp +++ b/include/formula-cpp/evaluate.hpp @@ -200,6 +200,24 @@ namespace detail static constexpr bool value = true; }; + /// Fails to compile when a series (`series.hpp`) is handed to a verb that + /// traces one value -- `explain` or `checked_explain` (`trace.hpp`). The + /// tracing counterpart of `RequireSingleValueExpression`: a caller who + /// asked for a derivation is pointed at `explain_series`, the series verb + /// that gives one. Named so the expression prints. + template + struct RequireSingleValueTraced + { + // A series already refused (`refused`, `series.hpp`) is not asked + // again: its own refusal is the one message for the mistake. + static_assert( + !SeriesNode || requires { requires detail::refused_already(); }, + "formula: this expression is a series, not a single value; explain it with " + "explain_series, or reduce it to one value first (sum, interpolate_at)"); + + static constexpr bool value = true; + }; + /// Wraps a bare `Rep` as a present value. template [[nodiscard]] constexpr Evaluated present(Rep value) noexcept diff --git a/include/formula-cpp/trace.hpp b/include/formula-cpp/trace.hpp index c8b24c9..014eb22 100644 --- a/include/formula-cpp/trace.hpp +++ b/include/formula-cpp/trace.hpp @@ -4611,6 +4611,17 @@ template +[[nodiscard]] Explained explain(Expression const&, Env const&, V const& = V {}) +{ + static_assert(detail::RequireSingleValueTraced::value); + return Explained {}; +} + /// `explain(boundFormula.expression, environmentGiven, vocabulary)`, /// `Q` taken from the `Yields` (`yields.hpp`). `Result` is `Q`'s place for a /// caller who names it anyway; any other quantity is refused. @@ -4751,6 +4762,17 @@ checked_explain(Expression const& expression, Env const& environment, V const& v return Explained { *checked, std::move(recorded) }; } +/// A series handed to `checked_explain`: refused as `explain` refuses it, +/// pointing at `explain_series`. The body is the refusal and nothing else; +/// what it returns is never seen. +template +[[nodiscard]] std::expected, CheckedExplainFailure> +checked_explain(Expression const&, Env const&, V const& = V {}) +{ + static_assert(detail::RequireSingleValueTraced::value); + return Explained {}; +} + /// `checked_explain(boundFormula.expression, environmentGiven, /// vocabulary)`, `Q` taken from the `Yields` (`yields.hpp`). `Result` is `Q`'s /// place for a caller who names it anyway; any other quantity is refused. diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 6234f6b..a806266 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -551,6 +551,16 @@ formula_add_negative_test(trace_of_si_series_as_single formula_add_negative_test(yields_series_explain "this expression is a series, not a single value; evaluate it with checked_evaluate_series" EXPECT_COUNT 3 REJECT "no matching") +# A bare series handed to explain or checked_explain, which trace a single +# value: refused in the library's words, pointing at explain_series, the +# verb that gives a series' derivation, rather than as an overload nobody +# matched. trace_of and the bound forms above keep their own message. +formula_add_negative_test(explain_series_as_single + "this expression is a series, not a single value; explain it with explain_series" EXPECT_COUNT 1 + REJECT "no matching") +formula_add_negative_test(checked_explain_series_as_single + "this expression is a series, not a single value; explain it with explain_series" EXPECT_COUNT 1 + REJECT "no matching") formula_add_negative_test(yields_rejection_evaluate "formula: this is a rejection of outliers, not a single value; evaluate it with checked_evaluate_rejection" EXPECT_COUNT 6 REJECT "no matching") diff --git a/test/negative/checked_explain_series_as_single.cpp b/test/negative/checked_explain_series_as_single.cpp new file mode 100644 index 0000000..8ca96f6 --- /dev/null +++ b/test/negative/checked_explain_series_as_single.cpp @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: this expression is a series, not a single value; explain it with explain_series +// REJECT: no matching +// +// A series handed to checked_explain, which traces a single value. Refused in +// this library's words, pointing at explain_series, the verb that gives a +// series' derivation -- rather than as an overload nobody matched. +#include +#include + +struct Retained: formula::Quantity +{ +}; + +inline constexpr auto inputs = + formula::environment(formula::measured_series(formula::Measured { formula::Rational { 130 } }, + formula::Measured { formula::Rational { 210 } }, + formula::Measured { formula::Rational { 95 } })); + +int main() +{ + return formula::checked_explain(formula::series, inputs).has_value() ? 0 : 1; +} diff --git a/test/negative/explain_series_as_single.cpp b/test/negative/explain_series_as_single.cpp new file mode 100644 index 0000000..b01cf4d --- /dev/null +++ b/test/negative/explain_series_as_single.cpp @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: this expression is a series, not a single value; explain it with explain_series +// REJECT: no matching +// +// A series handed to explain, which traces a single value. Refused in this +// library's words, pointing at explain_series, the verb that gives a +// series' derivation -- rather than as an overload nobody matched. +#include +#include + +struct Retained: formula::Quantity +{ +}; + +inline constexpr auto inputs = + formula::environment(formula::measured_series(formula::Measured { formula::Rational { 130 } }, + formula::Measured { formula::Rational { 210 } }, + formula::Measured { formula::Rational { 95 } })); + +int main() +{ + return formula::explain(formula::series, inputs).trace.empty() ? 1 : 0; +} From 0048e490355ccd5e62b38285f077bbf890adb1d1 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Mon, 5 Oct 2026 02:04:30 +0200 Subject: [PATCH 24/35] docs: link the tracing guide once, and describe the checked 128-bit add as built The README's tracing paragraph linked the tracing guide twice; it now links it once, at the end. The 128-bit design document said the checked add and subtract use the compiler's overflow builtins. They compute on the words' bit patterns with the portable routines on every compiler, and detect overflow from the signs; only the multiply uses the native checked builtin. Signed-off-by: Christian Parpart --- README.md | 12 ++++++------ .../specs/2026-10-03-int128-rational-design.md | 8 ++++++-- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index b737c76..b87fce1 100644 --- a/README.md +++ b/README.md @@ -244,12 +244,12 @@ environment overrides the result instead of letting the formula derive it, `explained.trace` comes back empty — nothing ran, so nothing was recorded — and `explained.outcome.is_overridden()` says so instead: an overridden number shows *that a person entered it*, a different fact from how it was reached and -arguably a more important one. See [the tracing guide](docs/tracing.md) for -the detail. Tracing costs nothing when nobody asks for it: a sink is passed by -value, and the untraced path — `evaluate()`, `checked_evaluate()` — defaults -to one that does nothing, adding no instruction the evaluator would not -already emit once the call inlines, measured on all four compilers this -library targets. See [the tracing guide](docs/tracing.md). +arguably a more important one. Tracing costs nothing when nobody asks for it: +a sink is passed by value, and the untraced path — `evaluate()`, +`checked_evaluate()` — defaults to one that does nothing, adding no +instruction the evaluator would not already emit once the call inlines, +measured on all four compilers this library targets. See +[the tracing guide](docs/tracing.md). ### A published table that a value falls outside of gives no number at all diff --git a/docs/superpowers/specs/2026-10-03-int128-rational-design.md b/docs/superpowers/specs/2026-10-03-int128-rational-design.md index 5d11214..b29cea6 100644 --- a/docs/superpowers/specs/2026-10-03-int128-rational-design.md +++ b/docs/superpowers/specs/2026-10-03-int128-rational-design.md @@ -84,8 +84,12 @@ class for no gain: a conversion in and out of the compiler's own integer optimis - **Plain operators behave like a built-in signed integer:** overflow, division by zero and an out-of-range shift are preconditions. - **Checked forms live in `detail/checked_int.hpp`,** beside the 64-bit ones, as `Int128` overloads of - `add_checked_or_none`, `sub_checked_or_none` and `mul_checked_or_none`. Natively they use `__builtin_add_overflow` - and its kin, which are `constexpr` on GCC and Clang. In software they use partial products. + `add_checked_or_none`, `sub_checked_or_none` and `mul_checked_or_none`. The sum and the difference are formed on + the two words' bit patterns with the portable routines, on every compiler, and an overflow is detected from the + signs: calling `Int128`'s own `+` or `-` first would break their precondition that the exact result fits. Only + the product uses the compiler's checked builtin, `__builtin_mul_overflow` on the unsigned magnitudes, where the + compiler has `__int128`, and partial products in software elsewhere; it is then checked against the signed + range. - **`detail::UInt128`**, an unsigned 128-bit type, carries magnitudes. A numerator equal to the minimum, -2^127, keeps working, as `std::uint64_t` magnitudes let -2^63 work today. It also carries `gcd` (binary, using `std::countr_zero` on the words) and the integer square root. From 455eca4b5a497ed831e9dfb4514354edb5d2c2e0 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Mon, 5 Oct 2026 02:07:54 +0200 Subject: [PATCH 25/35] docs: say which compilers multiply 128-bit integers with the native builtin The 128-bit design document said the checked multiply uses __builtin_mul_overflow wherever the compiler has __int128. The native path is taken on GCC and Clang outside MSVC's ABI only: clang-cl has __int128 but multiplies in software, as cl does. Signed-off-by: Christian Parpart --- docs/superpowers/specs/2026-10-03-int128-rational-design.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-10-03-int128-rational-design.md b/docs/superpowers/specs/2026-10-03-int128-rational-design.md index b29cea6..acaa9c1 100644 --- a/docs/superpowers/specs/2026-10-03-int128-rational-design.md +++ b/docs/superpowers/specs/2026-10-03-int128-rational-design.md @@ -87,9 +87,9 @@ class for no gain: a conversion in and out of the compiler's own integer optimis `add_checked_or_none`, `sub_checked_or_none` and `mul_checked_or_none`. The sum and the difference are formed on the two words' bit patterns with the portable routines, on every compiler, and an overflow is detected from the signs: calling `Int128`'s own `+` or `-` first would break their precondition that the exact result fits. Only - the product uses the compiler's checked builtin, `__builtin_mul_overflow` on the unsigned magnitudes, where the - compiler has `__int128`, and partial products in software elsewhere; it is then checked against the signed - range. + the product uses the compiler's checked builtin, `__builtin_mul_overflow` on the unsigned magnitudes, on GCC and + Clang (outside MSVC's ABI, so not clang-cl, which has `__int128` but multiplies in software), and partial products + in software elsewhere; it is then checked against the signed range. - **`detail::UInt128`**, an unsigned 128-bit type, carries magnitudes. A numerator equal to the minimum, -2^127, keeps working, as `std::uint64_t` magnitudes let -2^63 work today. It also carries `gcd` (binary, using `std::countr_zero` on the words) and the integer square root. From 568619f5e73ce9a76c864413259a8c6227053b7a Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Mon, 5 Oct 2026 02:24:44 +0200 Subject: [PATCH 26/35] docs: state what each comment relies on instead of naming development history Comments, test names and section banners referred to stages of the project's development and to one-off experiments by labels a reader cannot look up. Each now says what it relies on: a measurement keeps its compilers and versions or points at the test that pins it, a banner names its feature, and a reference that added nothing is gone. Two census test cases are renamed after what they cover. Signed-off-by: Christian Parpart --- cmake/CheckInstalledHeaders.cmake | 4 +- docs/quantities.md | 6 +- examples/CMakeLists.txt | 12 +- examples/constraints.cpp | 2 +- include/formula-cpp/band.hpp | 14 +-- include/formula-cpp/citation.hpp | 4 +- include/formula-cpp/constraint.hpp | 6 +- include/formula-cpp/error.hpp | 4 +- include/formula-cpp/escape.hpp | 2 +- include/formula-cpp/lookup.hpp | 91 +++++++------- include/formula-cpp/measured.hpp | 4 +- include/formula-cpp/method.hpp | 13 +- include/formula-cpp/opaque.hpp | 6 +- include/formula-cpp/overlay.hpp | 4 +- include/formula-cpp/precision.hpp | 20 +-- include/formula-cpp/quantity.hpp | 6 +- include/formula-cpp/rational.hpp | 8 +- include/formula-cpp/record.hpp | 3 +- include/formula-cpp/render.hpp | 51 ++++---- include/formula-cpp/retry.hpp | 2 +- include/formula-cpp/rounding.hpp | 7 +- include/formula-cpp/sink.hpp | 12 +- include/formula-cpp/statistics.hpp | 23 ++-- include/formula-cpp/trace.hpp | 18 +-- include/formula-cpp/trace_render.hpp | 10 +- test/CMakeLists.txt | 12 +- test/band_tests.cpp | 19 ++- test/conformity_tests.cpp | 4 +- test/curve_tests.cpp | 3 +- test/dimension_cross_tu.hpp | 20 +-- test/document_tests.cpp | 14 +-- test/least_squares_tests.cpp | 4 +- test/lineage_tests.cpp | 2 +- test/lookup_tests.cpp | 11 +- test/measured_tests.cpp | 4 +- test/method_tests.cpp | 12 +- .../exact_lookup_duplicate_key_two_rows.cpp | 2 +- test/negative/exact_lookup_int_key_table.cpp | 2 +- .../exact_lookup_rep_not_rational.cpp | 2 +- ...erpolating_lookup_malformed_breakpoint.cpp | 14 +-- test/negative/lookup_rep_not_rational.cpp | 6 +- .../negative/method_tag_names_spelt_alike.cpp | 6 +- test/negative/method_variants_disagree.cpp | 6 +- test/negative/opaque_throwing_compute.cpp | 2 +- .../overlay_constant_inside_opaque_series.cpp | 2 +- ...ries_elementwise_wrong_dimension_first.cpp | 4 +- test/opaque_tests.cpp | 6 +- test/overflow_census_tests.cpp | 14 +-- test/overlay_tests.cpp | 17 ++- test/record_join_tests.cpp | 10 +- test/record_statistics_tests.cpp | 8 +- test/render_tests.cpp | 114 +++++++++--------- test/retry_tests.cpp | 4 +- test/series_tests.cpp | 4 +- test/sink_tests.cpp | 16 +-- test/statistics_tests.cpp | 2 +- test/trace_render_tests.cpp | 48 ++++---- test/trace_tests.cpp | 8 +- test/unit_cross_tu.hpp | 4 +- test/unit_tests.cpp | 6 +- test/vocabulary_tests.cpp | 10 +- tools/gallery/main.cpp | 2 +- 62 files changed, 373 insertions(+), 383 deletions(-) diff --git a/cmake/CheckInstalledHeaders.cmake b/cmake/CheckInstalledHeaders.cmake index 6f00129..bcd5c48 100644 --- a/cmake/CheckInstalledHeaders.cmake +++ b/cmake/CheckInstalledHeaders.cmake @@ -4,8 +4,8 @@ # The FILE_SET is a hand-written list, deliberately: installing a header is a # decision about the published surface, and a glob makes that decision silently # on someone's behalf. The cost of writing it by hand is that it can be -# forgotten -- and it was. Phase 7 added sink.hpp, trace.hpp and -# trace_render.hpp and listed none of them, so `find_package(formula-cpp)` +# forgotten -- and it was: sink.hpp, trace.hpp and trace_render.hpp were once +# added and listed nowhere, so `find_package(formula-cpp)` # produced a package that did not compile at all: evaluate.hpp includes # sink.hpp, and sink.hpp was not there. # diff --git a/docs/quantities.md b/docs/quantities.md index 9d671bf..12592d2 100644 --- a/docs/quantities.md +++ b/docs/quantities.md @@ -183,9 +183,9 @@ diagnostic readable. **There is no fifth parameter for the dimension.** A `Unit` already carries its dimension (`unit.dimension`), so a separate dimension parameter would state it a second time and let the two disagree. That is not a hypothetical -risk: a spike compiled the five-parameter spelling with `dim::Mass` paired -against `unit::Litre`, and all three compilers accepted the contradiction in -silence. `Quantity::dimension` is derived from the unit instead, so there is +risk: the five-parameter spelling, with `dim::Mass` paired against +`unit::Litre`, compiled without a diagnostic on every compiler it was tried +on. `Quantity::dimension` is derived from the unit instead, so there is no second place for it to disagree with, and no spelling that lets a caller write the contradiction at all. diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 59cca1d..db6ecf1 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -106,15 +106,15 @@ formula_add_example(rounding_and_conditionals rounding_and_conditionals.cpp "all # yes" says the program's own booleans agreed with themselves, but says # nothing about what those booleans actually checked. That is not enough # here: docs/constraints.md quotes this program's output verbatim, including -# the very outcome words and trace shape this phase exists to get right, and -# nothing but an implementer's own diligence stood between a withdrawn -# spelling and a stale guide during phase 8. So this regex additionally pins, +# the very outcome words and trace shape the constraints guide exists to show, +# and nothing but an implementer's own diligence once stood between a +# withdrawn spelling and a stale guide. So this regex additionally pins, # in the order the program prints them: all four ConstraintOutcomeKind words # (satisfied, violated, not checked, invalid), the verdict label surviving # into output (`reject the specimen`), the arithmetic-error text surviving # into output (`division by zero`), and two literal `require ... [...]` trace # lines -- the satisfied case and, especially, the not-checked case that is -# this whole phase's central argument -- plus the degenerate one-operand +# the guide's central argument -- plus the degenerate one-operand # shape a left-side arithmetic error produces. A spelling change to any of # these now fails the build instead of silently leaving the guide wrong. # @@ -133,8 +133,8 @@ formula_add_example(constraints constraints.cpp [==[satisfied.*violated.*reject # # - `0 to under 127 mm gives 913/10 %` -- the ONE spelling of a half-open # interval in this library. It exists because `[0, 127)` is Markdown link -# syntax, which silently dropped an operand from a published page in phase -# 8; a guard test already forbids `](` and a bare `[` in any Markdown +# syntax, which once silently dropped an operand from a published page; a +# guard test already forbids `](` and a bare `[` in any Markdown # rendering, and this regex additionally pins that the replacement wording # itself does not drift. # - `at 127 mm gives 913/10 %` -- the interpolating table's point spelling, which diff --git a/examples/constraints.cpp b/examples/constraints.cpp index 0874918..343a35c 100644 --- a/examples/constraints.cpp +++ b/examples/constraints.cpp @@ -105,7 +105,7 @@ int main() std::println("no strength measured: {}", notChecked.kind()); std::println("divides by zero: {} ({})", invalid.kind(), *invalid.error()); - // The safety property this whole phase exists for, stated as code rather + // The safety property constraints exist for, stated as code rather // than only as a printed word: an unresolved check is neither satisfied // nor violated -- it is its own, honest, third thing. bool const notCheckedIsHonest = notChecked.is_not_checked() && !notChecked.is_satisfied() && !notChecked.is_violated(); diff --git a/include/formula-cpp/band.hpp b/include/formula-cpp/band.hpp index 21b2e42..60e51fc 100644 --- a/include/formula-cpp/band.hpp +++ b/include/formula-cpp/band.hpp @@ -23,7 +23,7 @@ /// the same reason `Bounds`, a unit's validity range, is: `Rational` keeps its /// members private, so it is not a *structural* type and cannot be a /// non-type template parameter (dimension.hpp's comment on `Exponent` says so -/// first, and a spike compiled the rejection on all four compilers). An +/// first, and the rejection was measured on all four compilers). An /// aggregate of plain `std::int64_t` fields is structural, and so is /// `std::array` of them -- which is what makes it possible to /// validate a table's bands at compile time, with `static_assert`, rather @@ -172,17 +172,17 @@ template } /// A table of bands, declared in ascending order. An alias template, not a -/// wrapping struct: a spike compiled `template ` directly, -/// with alias-template deduction, on all four compilers, so a second type -/// would add nothing but a name to unwrap. Consumers (phase 10 tasks 2-4) -/// name a table either by giving `N` explicitly or by letting it deduce from -/// a braced initialiser. +/// wrapping struct: `template ` compiles directly, with +/// alias-template deduction, as measured on all four compilers, so a second +/// type would add nothing but a name to unwrap. Consumers name a table +/// either by giving `N` explicitly or by letting it deduce from a braced +/// initialiser. template using BandTable = std::array; /// One of the two predicates well-formedness validation is built on (the /// other is `band_is_well_formed` just below), used both by the -/// `static_assert` wiring and by any runtime loader (phase 10 tasks 2-4) -- +/// `static_assert` wiring and by any runtime loader -- /// so the two checks cannot drift the way this project's checks have four /// times before. /// diff --git a/include/formula-cpp/citation.hpp b/include/formula-cpp/citation.hpp index b8e28b6..457f8a4 100644 --- a/include/formula-cpp/citation.hpp +++ b/include/formula-cpp/citation.hpp @@ -87,8 +87,8 @@ template } /// Evaluating a documented expression evaluates what it documents. The wrapper -/// is invisible to arithmetic; only the documentation walk and, from phase 7, -/// the trace sink will notice it. +/// is invisible to arithmetic; only the documentation walk and the trace sink +/// notice it. template [[nodiscard]] constexpr Evaluated checked_evaluate_si(DocumentedNode const& node, Env const& environment, diff --git a/include/formula-cpp/constraint.hpp b/include/formula-cpp/constraint.hpp index 89fd1f7..e21afb6 100644 --- a/include/formula-cpp/constraint.hpp +++ b/include/formula-cpp/constraint.hpp @@ -243,13 +243,13 @@ template /// because `formula::constraint(...)` is a free function at namespace scope /// and a parameter of the same name would shadow it. `-Wshadow` does not /// catch a parameter shadowing a function, so nothing would fail to build, -/// but it is the same kind of name collision that shipped a `StepKind::Pi` -/// enumerator shadowing `formula::Pi` in phase 7 and broke GCC alone. +/// but it is the same kind of name collision that once shipped a +/// `StepKind::Pi` enumerator shadowing `formula::Pi` and broke GCC alone. /// /// **Recorded in the trace as its own step**, the way spec sections 9 and /// 9.1 require. A constraint is not a `Node`, so it cannot go through /// `sink.entered`/`sink.produced` -- both constrained on `Node` -- the same -/// problem phase 8 solved for a `WhenNode`'s branch with an optional +/// problem a `WhenNode`'s branch has, solved with an optional /// `sink.branch_taken(...)` hook. The two calls below follow that /// established shape: a sink that defines `constraint_entered`/ /// `constraint_produced` -- `RecordingSink` (`trace.hpp`) is the one that diff --git a/include/formula-cpp/error.hpp b/include/formula-cpp/error.hpp index 4a4d526..2b7b998 100644 --- a/include/formula-cpp/error.hpp +++ b/include/formula-cpp/error.hpp @@ -43,8 +43,8 @@ enum class ArithmeticError : std::uint8_t }; /// A lowercase noun phrase with no trailing punctuation, so callers can embed it -/// in a longer sentence. Spec phase 5 renders this into the `invalid` arm of the -/// evaluation result. +/// in a longer sentence. An evaluation result's `invalid` arm is written with +/// it. [[nodiscard]] constexpr std::string_view describe(ArithmeticError cause) noexcept { switch (cause) diff --git a/include/formula-cpp/escape.hpp b/include/formula-cpp/escape.hpp index d5b6dee..3586e3f 100644 --- a/include/formula-cpp/escape.hpp +++ b/include/formula-cpp/escape.hpp @@ -13,7 +13,7 @@ /// number and the rule does not correct for that, because it was never meant /// to be evaluated in any other unit. This library cannot make a rule like /// that consistent, and silently dropping the unit to accommodate it would -/// defeat the entire dimensional layer phases 3 and 4 exist for. +/// defeat what the entire dimensional layer exists for. /// /// So the hole is explicit, narrow, and impossible to take quietly: /// diff --git a/include/formula-cpp/lookup.hpp b/include/formula-cpp/lookup.hpp index 9931fe7..0390b52 100644 --- a/include/formula-cpp/lookup.hpp +++ b/include/formula-cpp/lookup.hpp @@ -25,7 +25,7 @@ /// A method's own algebra sometimes needs a coefficient no formula computes -- /// a size-correction factor for a specimen's diameter, say -- that the /// method's procedure instead publishes as a table of intervals. `§16.1` -/// bounds what this phase may contain: the table's *structure* (its bands) +/// bounds what a lookup may contain: the table's *structure* (its bands) /// is part of the method and belongs in a formula's type; the table's /// *contents* (the correction each band selects) are master data that may be /// registered per customer, per region, per contract, and must be allowed to @@ -33,11 +33,11 @@ /// at once. /// /// **The join, stated explicitly, because two things separately verified do -/// not verify their join.** A prior spike verified, separately, that +/// not verify their join.** It was measured, separately, that /// `std::array` works as a non-type template parameter on all four /// compilers, and that a node can hold runtime state behind a compile-time /// shape (`ConstantNode` holds a runtime `Rational number` while `unit` and -/// `dimension` live in its type). It explicitly did not build a node +/// `dimension` live in its type). Neither measurement built a node /// combining both. `BandedLookupNode` is that combination: /// /// - **In the type** (compile-time shape, part of what a formula *is*): @@ -67,20 +67,19 @@ /// falls in no band -- or an `ExactLookupNode` whose key names no row of its /// table -- has found nothing: not zero, not the nearest band, not the /// first row. There is no default-value parameter and no fallback of any -/// kind: adding one would be phase 9's forbidden `bool satisfied()` in a new -/// costume, an API that must answer *something* for the unresolved case, -/// where every answer is a lie. -/// -/// **How the miss is actually reported, and where this departs from the -/// phase's own design-decisions note.** That note proposed reusing -/// `Outcome`'s `Invalid` alternative and its `InvalidReason`. Reading the -/// tree as it stands today (not as the note anticipated it) rules that out -/// on two independent grounds, and the tree wins: -/// -/// 1. `checked_evaluate_si` -- the machinery this node rides, per this -/// phase's own settled decision that a lookup is a `Node` needing none -/// of `Constraint`'s separate entry points -- returns `Evaluated`, -/// an alias for `std::expected, ArithmeticError>`. +/// kind: adding one would be the refused `bool satisfied()` of constraints +/// (`constraint.hpp`) in a new costume, an API that must answer *something* +/// for the unresolved case, where every answer is a lie. +/// +/// **How the miss is actually reported, and why not through `Outcome`.** +/// Reusing `Outcome`'s `Invalid` alternative and its `InvalidReason` was +/// considered. The tree as it stands rules that out on two independent +/// grounds: +/// +/// 1. `checked_evaluate_si` -- the machinery this node rides, because a +/// lookup is a `Node` needing none of `Constraint`'s separate entry +/// points -- returns `Evaluated`, an alias for +/// `std::expected, ArithmeticError>`. /// There is no path from there to `Outcome::invalid(...)`: /// `checked_evaluate` only ever builds `Outcome::empty()` or /// `::%value(...)` itself, and an `ArithmeticError` returned by any node @@ -89,13 +88,13 @@ /// `checked_evaluate_si` would mean widening `Evaluated` for every /// existing node kind to serve this one new caller -- a change far /// beyond this header, and a much larger one than "add a node". -/// 2. A prior spike proved `InvalidReason::label` is a non-owning +/// 2. `InvalidReason::label` is, as measured, a non-owning /// `std::string_view` that dangles the moment it is built from anything -/// but a string literal -- it printed the pointer inside the +/// but a string literal -- the measurement printed the pointer inside the /// reason-building function and the one the caller received, identical, /// with the string already destroyed. "Value 42 falls in no band" is -/// exactly the generated-at-the-point-of-failure sentence that proof -/// condemns. +/// exactly the generated-at-the-point-of-failure sentence that +/// measurement condemns. /// /// So the miss is reported the way `Evaluated`'s existing error channel /// already reports every other kind of failure: `std::unexpected { @@ -166,7 +165,7 @@ /// enumeration the method's author declares, and a table is /// `KeyTable` -- `std::array` -- as a non-type template /// parameter. The alternative considered, and rejected, was -/// `detail::FixedString` (phase 4), which is equally usable as an NTTP. The +/// `detail::FixedString`, which is equally usable as an NTTP. The /// deciding question is the one a method author will actually hit: **what /// happens when a key is absent.** /// @@ -436,15 +435,16 @@ /// `checked_add` onto the lower row -- so /// `y0 + (x - x0)(y1 - y0)/(x1 - x0)` is computed with no rounding anywhere, /// and a result no finite decimal can hold (14/15, say) comes back as exactly -/// 14/15. Nothing here reaches for phase 8's rounding, and nothing here loses -/// precision silently: the *only* way the answer is not the exact rational the -/// two rows imply is that some intermediate lies outside `Rational`'s -/// representable range, and that is reported as `ArithmeticError::Overflow` -/// through the same channel a miss uses, never approximated away -- asserted -/// on a table whose exact answer genuinely does not fit, rather than only -/// reasoned about. `x1 - x0` cannot be zero -- strictly ascending breakpoints -/// are enforced at compile time -- so the division is guarded by the table's -/// own validation rather than by a runtime test. +/// 14/15. Nothing here reaches for rounding (`rounding.hpp`), and nothing here +/// loses precision silently: the *only* way the answer is not the exact +/// rational the two rows imply is that some intermediate lies outside +/// `Rational`'s representable range, and that is reported as +/// `ArithmeticError::Overflow` through the same channel a miss uses, never +/// approximated away -- asserted on a table whose exact answer genuinely does +/// not fit, rather than only reasoned about. `x1 - x0` cannot be zero -- +/// strictly ascending breakpoints are enforced at compile time -- so the +/// division is guarded by the table's own validation rather than by a runtime +/// test. /// /// **`Rep` is closed to `Rational`, and here the arithmetic reason is the true /// one.** The banded node's guard gives an arithmetic reason (band selection @@ -556,14 +556,13 @@ namespace detail /// /// A linear scan, not a binary search, even though `band_table_is_well_ /// formed`'s own proof (`band.hpp`) shows a well-formed table's bands are - /// strictly ascending, which would make a binary search valid. Phase 10 - /// is the first thing in this codebase to need an interval search at - /// all; a method's own published table is rows, not big data, and an - /// obviously-correct O(N) scan is worth more here than O(log N) -- - /// especially for the half-open, exactly-on-a-boundary case this type - /// exists to get right. `Bands` is compile-time state (see the file - /// comment); this is the one place its `int64` pairs are turned into - /// `Rational` for an exact comparison. + /// strictly ascending, which would make a binary search valid. A method's + /// own published table is rows, not big data, and an obviously-correct + /// O(N) scan is worth more here than O(log N) -- especially for the + /// half-open, exactly-on-a-boundary case this type exists to get right. + /// `Bands` is compile-time state (see the file comment); this is the one + /// place its `int64` pairs are turned into `Rational` for an exact + /// comparison. template [[nodiscard]] constexpr std::optional find_band(Rational value) noexcept { @@ -905,8 +904,8 @@ template ` with both `Key` -/// and `N` deduced from the template argument, on cl, clang-cl, clang++ and +/// (`band.hpp`): `template ` compiles with both `Key` and `N` +/// deduced from the template argument, measured on cl, clang-cl, clang++ and /// g++, so a wrapping struct would add a name to unwrap and nothing else. /// /// `Key` is a scoped enumeration -- enforced by `RequireScopedEnumKey` in @@ -946,8 +945,8 @@ namespace detail /// Declared here, ahead of the predicates, because **every** public entry /// point that takes a key enforces it, not only the node. A validator that /// accepted an `std::array` no node would ever take is two - /// surfaces disagreeing about the same question -- the defect this phase - /// keeps finding -- and a runtime loader of tables reaching for + /// surfaces disagreeing about the same question -- a defect this codebase + /// has met more than once -- and a runtime loader of tables reaching for /// `key_table_is_well_formed` is exactly where it would bite. template struct RequireScopedEnumKey @@ -1398,8 +1397,8 @@ template /// A table of breakpoints, declared in strictly ascending order. An alias /// template over `std::array`, for the reason `BandTable` (`band.hpp`) and -/// `KeyTable` above are: a spike compiled `template ` with -/// both the element type and `N` deduced on all four compilers, so a wrapping +/// `KeyTable` above are: `template ` compiles with both the +/// element type and `N` deduced, measured on all four compilers, so a wrapping /// struct would add a name to unwrap and nothing else. template using BreakpointTable = std::array; @@ -1731,7 +1730,7 @@ namespace detail /// say which two rows an answer came from would otherwise have to find /// them again with a second scan somewhere else. Two scans of one table /// against one rule is the pair-of-surfaces-that-must-agree defect this - /// phase keeps refusing; returning what is already known costs one + /// library keeps refusing; returning what is already known costs one /// `std::pair` and cannot drift from itself. `interpolate` just below /// drops the location for the evaluation path, which has no use for it. /// diff --git a/include/formula-cpp/measured.hpp b/include/formula-cpp/measured.hpp index 9953e16..c8d8669 100644 --- a/include/formula-cpp/measured.hpp +++ b/include/formula-cpp/measured.hpp @@ -57,8 +57,8 @@ class Measured // which is the common case and the one a person actually reads. // // Nothing performs SFINAE on `Measured` today, so the cost is currently - // zero. Phase 5's expression layer may well want to, and if it does, this - // is the line to revisit -- the concept it would need already exists. + // zero. If a later layer needs to, this is the line to revisit -- the + // concept it would need already exists. static_assert(RequireDescribed::value); public: diff --git a/include/formula-cpp/method.hpp b/include/formula-cpp/method.hpp index 71c0ab3..fca3839 100644 --- a/include/formula-cpp/method.hpp +++ b/include/formula-cpp/method.hpp @@ -164,9 +164,9 @@ namespace detail /// aggregate, so a `VariantCase<...>` can be declared with no factory call. /// /// Deliberately not a `Node`. A variant does not stand where a number stands; -/// it names one of the formulas a method chooses between. Phase 10 settled -/// that test for lookups -- a lookup *is* a node because it produces a -/// quantity -- and it comes out the other way here. +/// it names one of the formulas a method chooses between. The same test makes +/// a lookup a node, because a lookup produces a quantity, and it comes out the +/// other way here. template struct VariantCase { @@ -913,7 +913,8 @@ namespace detail /// deliberately: this is a public aggregate with a public member, so a /// `Variants<...>` can be declared directly with no factory call anywhere, /// and a check placed only in the factory would let that route through. The -/// same mistake was found, and fixed, in the lookup tables of phase 10. +/// lookup tables were once open to the same mistake, and are checked the same +/// way. template struct Variants { @@ -1701,8 +1702,8 @@ namespace detail { /// Refuses a tag no variant declares. There is no fallback variant and no /// "first match wins": a specimen matching no variant has no result, the - /// same ruling phase 9 made for `bool satisfied()` and phase 10 made for a - /// lookup miss. An author who wants a catch-all writes one. + /// same ruling as for a constraint's `bool satisfied()` and a lookup miss. + /// An author who wants a catch-all writes one. template struct RequireVariantForTag { diff --git a/include/formula-cpp/opaque.hpp b/include/formula-cpp/opaque.hpp index 517e0ed..f018987 100644 --- a/include/formula-cpp/opaque.hpp +++ b/include/formula-cpp/opaque.hpp @@ -221,7 +221,7 @@ namespace detail /// only ASCII letters, digits and spaces, no space at either end and no /// two spaces together. Anything else would need escaping somewhere, and /// the site's MathJax shows a LaTeX text-mode escape backslash and all - /// (phase 15's spike, step 9). + /// (measured under MathJax 3.2.2 with the site's configuration). [[nodiscard]] consteval bool readable_name(std::string_view candidate) noexcept { if (candidate.empty() || candidate.front() == ' ' || candidate.back() == ' ') @@ -1483,8 +1483,8 @@ namespace detail /// /// The body is gated on the call being sound (`if constexpr`): without the /// gate, g++ 14.2 follows a refused `compute`'s one message with errors of -/// its own (phase 15's spike, step 7), the behaviour -/// `checked_evaluate_series` records for its refusal. +/// its own -- the behaviour `checked_evaluate_series` records for its +/// refusal, and what `opaque_throwing_compute`'s REJECTs pin. template [[nodiscard]] constexpr Evaluated checked_evaluate_si(OpaqueOutputNode, Origin> const& node, Env const& environment, diff --git a/include/formula-cpp/overlay.hpp b/include/formula-cpp/overlay.hpp index 050e210..02707e3 100644 --- a/include/formula-cpp/overlay.hpp +++ b/include/formula-cpp/overlay.hpp @@ -13,7 +13,7 @@ /// which yields a method. /// /// An overlay is applied at **compile time** and the method it yields is a new -/// type. A spike that built both shapes settled it: the compile-time overlay met every one of section +/// type. Both shapes were built and compared: the compile-time overlay met every one of section /// 16.7's demands and cost nothing at the call site, where a runtime overlay /// could not replace a formula without type erasure. The set of jurisdictions /// is closed and lives in the type; which one applies is a runtime choice made @@ -2128,7 +2128,7 @@ namespace detail /// own bounds, verdict and citation: a jurisdiction's tolerance reaches /// the limit (`with_constant`, §16.7), and a substitution for /// the sample's quantity is refused by the result check, as it is for any - /// series (phase 12's message). + /// series (`RequireConstantNotSeries`'s message). template struct ConstantRewrite> { diff --git a/include/formula-cpp/precision.hpp b/include/formula-cpp/precision.hpp index 5917df5..96321cf 100644 --- a/include/formula-cpp/precision.hpp +++ b/include/formula-cpp/precision.hpp @@ -54,7 +54,7 @@ /// **What is not modelled:** reproducibility across laboratories changes only /// the name (`R` rather than `r`) and the trace's words, never the arithmetic. /// The other laboratory's result is an ordinary input here; reading another -/// test's record is a later phase's. +/// test's record is the job of records (`record.hpp`). #include #include @@ -113,7 +113,7 @@ struct AbsoluteValueNode: NodeBase /// The absolute value of `operand`: `abs(var - var)`. /// -/// Named `abs` after a spike: beside `` and ``, under +/// Named `abs` after measuring it: beside `` and ``, under /// `using namespace std;`, found by ADL and as a consumer's local name, it drew /// no ambiguity and no warning on cl 19.51, clang-cl and clang++ 22.1.3, or /// g++ 13.3 and 14.2. @@ -338,7 +338,7 @@ namespace detail /// declared in namespace `formula` with no specialisation here is refused /// (`RequireLevelChildrenFor`), where a placeholder inside it would /// otherwise hide from every check below without a word -- as - /// `DerivedQuantityNode`, and then phase 12's `sum` and elementwise + /// `DerivedQuantityNode`, and then the series `sum` and elementwise /// nodes, once did. On a front end whose spelling of a type this cannot /// read, every kind reaching the primary is refused rather than passed as /// a consumer's. One divergence between those toolchains: a class @@ -566,9 +566,9 @@ namespace detail { }; - // Phase 12's series kinds. A series is not a `Node`, but a placeholder - // broadcast into one -- `series + precision_level` under a - // `sum` -- is inside the level all the same. + // The series kinds (`series.hpp`). A series is not a `Node`, but a + // placeholder broadcast into one -- `series + precision_level` + // under a `sum` -- is inside the level all the same. template struct LevelChildren>: LevelLeaf { @@ -637,9 +637,9 @@ namespace detail { }; - // Phase 14's snap and curves: a snap reads its operand, a curve its two - // series, a splice its two curves, an interpolation its curve and the - // point it is read at. A declared domain is a table of points. + // Snaps and curves (`snap.hpp`, `curve.hpp`): a snap reads its operand, a + // curve its two series, a splice its two curves, an interpolation its curve + // and the point it is read at. A declared domain is a table of points. template struct LevelChildren>: LevelParent { @@ -665,7 +665,7 @@ namespace detail { }; - // Phase 12's raw observations, and the classes they are binned into. + // Raw observations, and the classes they are binned into. template struct LevelChildren>: LevelLeaf { diff --git a/include/formula-cpp/quantity.hpp b/include/formula-cpp/quantity.hpp index 961a853..c968748 100644 --- a/include/formula-cpp/quantity.hpp +++ b/include/formula-cpp/quantity.hpp @@ -90,9 +90,9 @@ namespace formula /// /// **There is no dimension parameter.** A `Unit` already carries its dimension, /// so passing both would state it twice and let the two contradict each other. -/// A spike compiled that spelling with `dim::Mass` against `unit::Litre` and all -/// three compilers accepted it in silence. `dimension` below is derived, so the -/// contradiction cannot be written. +/// That spelling, with `dim::Mass` against `unit::Litre`, compiled without a +/// diagnostic on every compiler it was tried on. `dimension` below is derived, +/// so the contradiction cannot be written. template struct Quantity { diff --git a/include/formula-cpp/rational.hpp b/include/formula-cpp/rational.hpp index 1b4f4e6..beadd2b 100644 --- a/include/formula-cpp/rational.hpp +++ b/include/formula-cpp/rational.hpp @@ -10,8 +10,8 @@ /// as 0.45 m3 and rendered back is not reliably 450. /// /// This type carries no decimal-place tag. Declared precision belongs to the -/// unit and quantity layer; rounding is an explicit operation (rounding.hpp) and, -/// from spec phase 8 on, a node in the expression tree. +/// unit and quantity layer; rounding is an explicit operation (rounding.hpp) and +/// a node in the expression tree. #include #include @@ -705,8 +705,8 @@ namespace detail // root, -2, both exists and is representable -- it is only the magnitude // of the intermediate numerator that is not. Reworking the search onto an // unsigned magnitude to rescue this one input would add new numeric code - // at the end of a phase to save a single edge case, which risks a worse - // bug than the one it fixes. + // to save a single edge case, which risks a worse bug than the one it + // fixes. if (radicand.numerator() == std::numeric_limits::min()) return std::unexpected { ArithmeticError::Overflow }; diff --git a/include/formula-cpp/record.hpp b/include/formula-cpp/record.hpp index 0936ee9..a5196a8 100644 --- a/include/formula-cpp/record.hpp +++ b/include/formula-cpp/record.hpp @@ -1574,8 +1574,7 @@ namespace detail /// fixed a value inside the computation over that record. /// /// Declared here, after `overlay.hpp` is included, and found by `apply()` - /// all the same -- measured by phase 14's spike on cl, clang-cl, - /// clang++ and g++. + /// all the same -- measured on cl, clang-cl, clang++ and g++. template struct ConstantRewrite>: ConstantRewriteOperand to under `, never `[low, /// high)`.** A band really is `[103, 197)` (`band.hpp`), and that is exactly -/// the character sequence CommonMark reads as a link label -- the defect -/// phase 8 published, when `round[to 1 dp of mm](d)` reached a page with its -/// operand silently dropped. The guard test in `render_tests.cpp` asserts +/// the character sequence CommonMark reads as a link label -- a defect once +/// published, when `round[to 1 dp of mm](d)` reached a page with its operand +/// silently dropped. The guard test in `render_tests.cpp` asserts /// that no Markdown rendering contains `](` or a bare `[`, and a band written /// the mathematician's way would defeat it. `to under` is not a compromise /// spelling: it *says* the exclusion in words, where a reader has to know the /// bracket convention to see it, and it survives every Markdown flavour /// because it contains no punctuation at all. Whatever renders a band next -- /// a trace, a `document()` walk, a guide, a gallery -- spells it this way, -/// because two surfaces naming the same thing differently is the phase-8 -/// defect itself rather than a matter of taste. +/// because two surfaces naming the same thing differently is that defect +/// itself rather than a matter of taste. #include #include @@ -96,8 +96,9 @@ namespace detail { /// A comparison or a `when()` -- binds looser than every arithmetic /// operator, so either one needs a bracket wherever it sits as the - /// operand of `+`, `-`, `*`, `/`, unary negation, or a power. Added in - /// spec phase 8; every other rung keeps its original number. + /// operand of `+`, `-`, `*`, `/`, unary negation, or a power. Added + /// later than the others, at 0; every other rung keeps its original + /// number. Conditional = 0, Additive = 1, Multiplicative = 2, @@ -328,7 +329,7 @@ namespace detail /// /// **The one place the marker is spelled.** Every series node that names /// a quantity calls this, so the marker cannot drift between node kinds. - /// Chosen by the phase 12 spike: under MathJax 3.2.2 with the site's + /// Chosen by measurement: under MathJax 3.2.2 with the site's /// configuration and under tectonic 0.17.0 with `[OT1]{fontenc}`, /// `{x_m}_{i}`, `{R}_{i}` and `{f_{c}}_{i}` typeset, while `x_m_i` is a /// "Double subscript" error in both; python-markdown 3.10.3 keeps @@ -349,8 +350,8 @@ namespace detail /// @p quantitySymbol -- already the jurisdiction's, through `symbol_of` -- /// marked as a retry's value at attempt @p attemptIndex (`k`, `k-1` or /// `0`), in dialect @p D: `w(k-1)` in plain text, `` `w(k-1)` `` in - /// Markdown and `{w}_{k-1}` in LaTeX -- `series_marker`'s family, chosen - /// by phase 15's spike (step 9) for the same engines. + /// Markdown and `{w}_{k-1}` in LaTeX -- `series_marker`'s family, + /// measured under the same engines. /// /// **The one place the marker is spelled**, for the render, the document /// and the trace (`trace_render.hpp`) alike. @@ -1636,9 +1637,9 @@ template } /// A sample's variance renders as a call on its sample, -/// `sample_variance(m(i))`, and in LaTeX as `s^{2}({m}_{i})`, the spelling -/// a spike typeset clean. The variance is one value and carries no series -/// marker; its sample carries its own. +/// `sample_variance(m(i))`, and in LaTeX as `s^{2}({m}_{i})`, a spelling +/// measured to typeset clean under MathJax and tectonic. The variance is one +/// value and carries no series marker; its sample carries its own. template [[nodiscard]] std::string render_node(SampleVarianceNode const& node, V const& vocabulary) { @@ -1954,7 +1955,7 @@ template return render(node.replacement(), vocabulary); } -// ------------------------------------------------------- phase 10: lookups +// ----------------------------------------------------------------- lookups // // Three node kinds, one shape: `(, , , // ...)`. See `detail::lookup_call` for why that is the existing call shape @@ -2231,8 +2232,8 @@ template \right\rvert` in LaTeX. /// /// **Never a `|`, in any dialect.** A bare vertical bar inside a Markdown table -/// cell ends the cell, silently: a spike measured a row whose formula held an -/// absolute value in bars render as a one-cell row holding only the text +/// cell ends the cell, silently -- measured: a row whose formula held an +/// absolute value in bars renders as a one-cell row holding only the text /// before the first bar (python-markdown 3.10.3, pymdown-extensions 12.1). A /// formula is quoted in exactly such tables -- a symbol table, a gallery row, /// a `document()` page -- so the plain and Markdown spellings are a call, and @@ -2340,7 +2341,7 @@ template /// code span as a series marker writes it; and in LaTeX /// `\text{linear least squares}({t}_{i}, {L}_{i})_{\text{slope}}`. /// -/// The spellings are phase 15's spike's (step 9), measured under MathJax 3.2.2 -/// with the site's configuration, tectonic 0.17.0 with `[OT1]{fontenc}` and -/// python-markdown 3.10.3. The names go in as written: an operation's name and -/// output names hold only ASCII letters, digits and single spaces +/// The spellings were measured under MathJax 3.2.2 with the site's +/// configuration, tectonic 0.17.0 with `[OT1]{fontenc}` and python-markdown +/// 3.10.3. The names go in as written: an operation's name and output names +/// hold only ASCII letters, digits and single spaces /// (`RequireOpaqueNameReadable`), which every dialect shows as they are. The /// operation's name is its own text, like `numeric(...)`, and no vocabulary /// renames it; its inputs' symbols follow the vocabulary. @@ -2679,12 +2680,12 @@ namespace detail /// waiting for the join with derived and replaced variants, and one no /// test that lacks such a node would see. /// - /// **A consumer's nodes** keep the extension point every earlier phase + /// **A consumer's nodes** keep the extension point the library has always /// published, `template std::string render_node(TheirNode /// const&)`. Passing the vocabulary as a second argument would leave every - /// such overload unreachable -- measured by the phase-11 spike on clang++ - /// 20.1.8 ("no matching function for call to 'render_node'"), and again on - /// cl 19.51 by deleting the fallback below (C2672). So, in order: + /// such overload unreachable -- measured on clang++ 20.1.8 ("no matching + /// function for call to 'render_node'"), and again on cl 19.51 by + /// deleting the fallback below (C2672). So, in order: /// /// 1. a two-argument overload that is not this library's -- the consumer /// opted in -- is called with the vocabulary; diff --git a/include/formula-cpp/retry.hpp b/include/formula-cpp/retry.hpp index 42ad32b..6de0e57 100644 --- a/include/formula-cpp/retry.hpp +++ b/include/formula-cpp/retry.hpp @@ -141,7 +141,7 @@ inline constexpr bool formats_by_describe = true; /// repeat a step a few times; a larger count is almost always a typo, and 64 /// attempts of a five-node attempt with a four-node judgement fit one /// constant evaluation on cl 19.51, clang-cl and clang++ 22.1.3, g++ 13.3 and -/// g++ 14.2 (measured in phase 15's spike, step 5). +/// g++ 14.2, as measured. /// /// The cap bounds the count, not the numbers: an exact fixpoint as simple as /// `6.08 g + w(k-1) / 2` doubles its denominator every attempt and passes diff --git a/include/formula-cpp/rounding.hpp b/include/formula-cpp/rounding.hpp index 6d84fe4..62abbf9 100644 --- a/include/formula-cpp/rounding.hpp +++ b/include/formula-cpp/rounding.hpp @@ -7,9 +7,8 @@ /// Norm methods state where rounding happens and which way it goes, and /// intermediate and final rounding routinely differ within one method. A library /// that rounds only on output produces wrong numbers, so rounding here is an -/// operation over exact values that yields another exact value. From spec phase -/// 8 on it is also a node in the expression tree, and the result carries into -/// the trace. +/// operation over exact values that yields another exact value. It is also a +/// node in the expression tree, and the result carries into the trace. #include #include @@ -181,7 +180,7 @@ struct SignificantDigits /// /// This is the primitive the decimal-place and significant-digit forms are built /// on, and it is also what snapping a computed sieve size onto a standard sieve -/// series needs (spec phase 12). +/// series needs (`snap.hpp`). /// /// @pre `increment` is strictly positive; otherwise DomainError. [[nodiscard]] constexpr std::expected checked_round_to_multiple( diff --git a/include/formula-cpp/sink.hpp b/include/formula-cpp/sink.hpp index 8d0a334..a7d462c 100644 --- a/include/formula-cpp/sink.hpp +++ b/include/formula-cpp/sink.hpp @@ -10,7 +10,7 @@ /// compiler to materialise the address of an empty object that nothing reads, /// which clang emits as a real instruction at every call site. By value it /// disappears. Measured on cl 19.51, clang-cl 22.1.3, clang++ 22.1.3 and -/// g++ 13.3 -- see the phase-7 prep notes. +/// g++ 13.3. /// /// The consequence for anyone writing a stateful sink: keep it small and /// cheap to copy. A sink that owns its storage would copy that storage at @@ -204,11 +204,11 @@ namespace detail { /// Evaluates @p node with @p sink, through whichever overload exists. /// - /// Phase 5 published `checked_evaluate_si(node, environment)` as an - /// extension point: a consumer with their own node kind writes an overload - /// and the evaluator finds it by ADL. This phase adds a third parameter, - /// which would leave every such overload unreachable. The `requires` below - /// prefers a sink-aware overload where one exists and falls back to the + /// `checked_evaluate_si(node, environment)` was published as an extension + /// point: a consumer with their own node kind writes an overload and the + /// evaluator finds it by ADL. The sink is a third parameter, which would + /// leave every such overload unreachable. The `requires` below prefers a + /// sink-aware overload where one exists and falls back to the /// two-parameter one where it does not, so a consumer's existing node keeps /// evaluating correctly. It simply contributes no trace steps -- the honest /// outcome, since the library was never told how to trace it. diff --git a/include/formula-cpp/statistics.hpp b/include/formula-cpp/statistics.hpp index 77560ed..64c7e0d 100644 --- a/include/formula-cpp/statistics.hpp +++ b/include/formula-cpp/statistics.hpp @@ -7,8 +7,8 @@ /// `sum` is for a series. /// /// **A sample** (`SampleSource`) is any source of repeated determinations of -/// one quantity. That is a phase 12 series, `series` or any expression -/// over one: a series expression is a sample, so +/// one quantity. That is a series (`series.hpp`), `series` or any +/// expression over one: a series expression is a sample, so /// `sample_mean(series / series)` is the mean of the per-element /// ratios. It is also raw observations, `observations`, whose /// count is known only at run time: `Capacity` is a bound, not a count, and @@ -68,13 +68,13 @@ namespace detail inline constexpr bool is_observations_sample> = true; } // namespace detail -/// A source of repeated determinations of one quantity: a phase 12 series, -/// any expression over one, raw observations (`observations`), -/// or a rejection of outliers from any of these (`without_outliers`, -/// `rejection.hpp`). Its dimension is `S::dimension`, and how many -/// determinations it can hold is `detail::sample_capacity` -- `N` for a -/// series, `Capacity` for observations; how many it does hold is known when -/// it is evaluated. +/// A source of repeated determinations of one quantity: a series +/// (`series.hpp`), any expression over one, raw observations +/// (`observations`), or a rejection of outliers from any of +/// these (`without_outliers`, `rejection.hpp`). Its dimension is +/// `S::dimension`, and how many determinations it can hold is +/// `detail::sample_capacity` -- `N` for a series, `Capacity` for +/// observations; how many it does hold is known when it is evaluated. template concept SampleSource = SeriesNode || detail::is_observations_sample> || detail::is_sample_transformer>; @@ -111,9 +111,8 @@ namespace detail } // namespace detail /// The result of evaluating a sample: its values, absent as a whole when the -/// sample is, or the failure that stopped it -- phase 12's -/// `SeriesFailure`, whose `element` is empty when the failure belongs to no -/// determination. +/// sample is, or the failure that stopped it -- a series' `SeriesFailure`, +/// whose `element` is empty when the failure belongs to no determination. template using EvaluatedSample = std::expected>, SeriesFailure>; diff --git a/include/formula-cpp/trace.hpp b/include/formula-cpp/trace.hpp index 014eb22..f610e97 100644 --- a/include/formula-cpp/trace.hpp +++ b/include/formula-cpp/trace.hpp @@ -608,9 +608,9 @@ enum class OperandSide : std::uint8_t /// relaying it" -- and a renderer reading such a step alone would emit /// something true-sounding and useless ("lookup failed: argument outside the /// domain of the operation") for a case where nothing was outside any domain. -/// Phase 9 refused `bool satisfied()` for exactly this shape of defect: a -/// surface that must answer something for a case it cannot distinguish, where -/// the plausible answer is a lie. +/// Constraints refuse a `bool satisfied()` (`constraint.hpp`) for exactly +/// this shape of defect: a surface that must answer something for a case it +/// cannot distinguish, where the plausible answer is a lie. /// /// `ArithmeticError::Overflow` is ambiguous the same way and **is not the /// same case**: only the interpolating lookup computes anything, so only it @@ -1053,9 +1053,9 @@ struct Step /// The whole `ConstraintOutcome` rather than a kind plus a separate label /// and a separate error field of its own: `ConstraintOutcome` already /// carries exactly those three things behind one safe interface, and - /// splitting it back out here would be the identical duplication phase 8 - /// undid when it removed the member it had added to `WhenNode` to expose - /// a comparison already reachable another way. `check()` (`constraint.hpp`) + /// splitting it back out here would be the identical duplication once + /// undone by removing a member added to `WhenNode` to expose a comparison + /// already reachable another way. `check()` (`constraint.hpp`) /// hands this to `RecordingSink::constraint_produced` verbatim. /// /// Default-constructs to `ConstraintOutcomeKind::NotChecked` -- see @@ -1122,7 +1122,7 @@ struct Step /// `declared_number_text` before printing, so a row typed `14/4` reads /// `7/2` in a derivation -- deliberately, because `render()` prints `7/2` /// for that same row and a trace disagreeing with the formula it derives - /// is the defect this phase exists to refuse. An auditor reconciling + /// is a defect this library refuses. An auditor reconciling /// *values* against a published curve therefore matches; one reconciling /// the *literal spelling* an author typed needs this field, which is where /// the unreduced pair survives for a programmatic consumer to read. @@ -4472,8 +4472,8 @@ class RecordingSink /// curve's, raw observations', a constraint's, a conformity check's, a /// variant selection's, an acceptance check's, a precision level's first /// pass and a rejection's passes, rejections and verdict -- so that a - /// recording path added later, as phase 12's series paths and phase 13's - /// statistics paths were, has one rule to follow rather than one to + /// recording path added later, as the series paths and the statistics + /// paths were, has one rule to follow rather than one to /// forget. Outside every scope it sets nothing. /// /// **The rule for every recording path, present and future:** a path diff --git a/include/formula-cpp/trace_render.hpp b/include/formula-cpp/trace_render.hpp index 3a19880..c7259a4 100644 --- a/include/formula-cpp/trace_render.hpp +++ b/include/formula-cpp/trace_render.hpp @@ -812,9 +812,9 @@ namespace detail /// clause the line would read `lookup(#1) = argument outside the domain of /// the operation` for a case where nothing was outside any domain and the /// real failure happened two levels down -- a plausible answer to a - /// question the line cannot otherwise answer, which is the defect phase 9 - /// refused `bool satisfied()` over. `Step::lookupFailure` is what resolves - /// it, and `LookupFailure` (`trace.hpp`) records how. + /// question the line cannot otherwise answer, which is the defect a + /// constraint's `bool satisfied()` was refused over. `Step::lookupFailure` + /// is what resolves it, and `LookupFailure` (`trace.hpp`) records how. /// /// The same bracket `citation_suffix`, `rounding_mode_suffix` and /// `constraint_outcome_suffix` use, for the reason the last of those gives @@ -1527,8 +1527,8 @@ namespace detail /// is. /// /// `selected by tag` names **how** the choice was made, not only that it - /// was: a tag is the only discriminator a method has in this phase, and - /// saying so now is what will keep this line true once there is a second. + /// was: a tag is the only discriminator a method has, and saying so now is + /// what will keep this line true once there is a second. /// /// The same bracket `citation_suffix` and `lookup_suffix` use, for the /// reason `lookup_suffix` gives: it is where a reader already looks for diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index a806266..78edfc1 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -1103,8 +1103,8 @@ formula_add_negative_test(sample_mean_overlay_constant_on_sample "formula: this overlay fixes a quantity the method reads as a series or as raw observations; one constant cannot stand for many values" REJECT "overrides a quantity that no variant or constraint of the method uses" "cannot see inside") -# The same check sees through phase 12's series kinds: a placeholder added to -# every element of a series a nested level sums. +# The same check sees through the series kinds: a placeholder added to every +# element of a series a nested level sums. formula_add_negative_test(precision_level_in_nested_level_through_series "formula: this precision_limit's level expression reads precision_level" REJECT "precision_level is meaningful only inside") @@ -2161,7 +2161,7 @@ formula_add_negative_test(render_node_without_vocabulary # compile. formula_add_negative_test(vocabulary_symbol_not_static "a constant expression") -# Phase 14: records and the context. Each case's own comment says why its +# Records and the context. Each case's own comment says why its # mistake is the only thing wrong with it. formula_add_negative_test(record_context_duplicate_role "formula: this record_context binds the same role to more than one record" @@ -2339,8 +2339,8 @@ formula_add_negative_test(record_series_through_refused_record EXPECT_COUNT 1 REJECT "get_series") # The same refusal, rendered, documented and overlaid: every walk answers the -# refused scope, as phase 12's walks answer a refused series, so each stays -# one message. cl stops at the first failed static_assert, so only the +# refused scope, as the walks answer a refused series, so each stays one +# message. cl stops at the first failed static_assert, so only the # overlay's REJECT -- a library static_assert of its own -- can fire there; # g++, clang++ and clang-cl see all three. formula_add_negative_test(record_scope_of_series_rendered @@ -2395,7 +2395,7 @@ formula_add_negative_test(opaque_unknown_output "formula: this opaque operation has no output of that name; the operation and the name asked for appear in this diagnostic as the template arguments of RequireOpaqueOutputNamed" REJECT "this result quantity does not measure the dimension") # g++ 14.2 followed the noexcept refusal with seven errors of its own when the -# evaluator's body was not gated (phase 15's spike, step 7): the REJECTs. +# evaluator's body was not gated: the REJECTs. formula_add_negative_test(opaque_throwing_compute "formula: an opaque operation's compute must be noexcept; evaluation cannot throw, and an exception escaping it would end the program" REJECT "~expected" "flows off the end") diff --git a/test/band_tests.cpp b/test/band_tests.cpp index 08fc5fc..18f0aba 100644 --- a/test/band_tests.cpp +++ b/test/band_tests.cpp @@ -62,14 +62,13 @@ namespace band(173, 1, 277, 1), // overlap: band[2]'s low (173) < band[1]'s high (197) }; - // ---- an inverted band (its own low is not below its own high) -- fix - // round 1's finding. Constructed so every adjacent pair still shares its - // boundary exactly (`bands_are_adjacent` alone would pass every one of - // these), isolating that only `band_is_well_formed` catches this defect. - // One inversion per position -- first, middle, last -- for the same - // reason gap and overlap each got a middle case and an end case: a check - // exercised at only one position can silently be one that only works - // there. + // ---- an inverted band (its own low is not below its own high). + // Constructed so every adjacent pair still shares its boundary exactly + // (`bands_are_adjacent` alone would pass every one of these), isolating + // that only `band_is_well_formed` catches this defect. One inversion per + // position -- first, middle, last -- for the same reason gap and overlap + // each got a middle case and an end case: a check exercised at only one + // position can silently be one that only works there. inline constexpr BandTable<3> InvertedFirstBand { band(103, 1, 0, 1), // inverted: low (103) is not below high (0) @@ -252,8 +251,8 @@ TEST_CASE("a well-formed, adjacent table has its bands in ascending order -- imp // pinned instead by test/negative/band_gap.cpp, test/negative/band_overlap.cpp // and test/negative/band_inverted.cpp, which assert that each fails to // compile and that the failure names the offending band or pair. This test -// only proves the success path is reachable the way phase 10 tasks 2-4 will -// reach it. +// only proves the success path is reachable the way the lookup nodes reach +// it. TEST_CASE("RequireValidBandTable accepts a well-formed table", "[band]") { diff --git a/test/conformity_tests.cpp b/test/conformity_tests.cpp index 9df36ef..c3dd408 100644 --- a/test/conformity_tests.cpp +++ b/test/conformity_tests.cpp @@ -28,8 +28,8 @@ constexpr formula::Rational rat(std::int64_t numerator, std::int64_t denominator return formula::Rational { numerator, denominator }; } -// The shared fixture's quantities (see the phase 12 plan): an invented screen -// analysis, and the percentage passing each screen. +// The shared fixture's quantities: an invented screen analysis, and the +// percentage passing each screen. struct Retained: formula::Quantity { }; diff --git a/test/curve_tests.cpp b/test/curve_tests.cpp index 994d531..22d1e8e 100644 --- a/test/curve_tests.cpp +++ b/test/curve_tests.cpp @@ -36,8 +36,7 @@ struct Opening: formula::Quantity struct Passing: formula::Quantity { }; -// A share as a plain fraction, for the LaTeX renderings: `%` is emitted bare -// there, a tracked follow-up this phase does not fix. +// A share as a plain fraction, for the LaTeX renderings. struct Share: formula::Quantity { }; diff --git a/test/dimension_cross_tu.hpp b/test/dimension_cross_tu.hpp index ff0b1d0..44b032e 100644 --- a/test/dimension_cross_tu.hpp +++ b/test/dimension_cross_tu.hpp @@ -2,16 +2,16 @@ #pragma once /// Cross-translation-unit identity for a Dimension used as a non-type template -/// parameter. Phase 1 established that the equivalent trick with -/// `decltype([]{})` gives each TU its OWN type and fails at link time with a -/// message that never names the cause. This test exists so that a future change -/// to Dimension cannot reintroduce that failure silently: the functions below -/// are DEFINED in dimension_cross_tu_b.cpp and CALLED from dimension_tests.cpp -/// with an equal but differently spelled dimension. If the two spellings are not -/// the same type, this does not link. The two that carry named base dimensions -/// are also defined with a spelling different from their declaration here, so -/// that the declaration, the definition and the call each work out the named -/// bases' canonical order for themselves. +/// parameter. The equivalent trick with `decltype([]{})` gives each TU its OWN +/// type and fails at link time with a message that never names the cause. This +/// test exists so that a future change to Dimension cannot reintroduce that +/// failure silently: the functions below are DEFINED in +/// dimension_cross_tu_b.cpp and CALLED from dimension_tests.cpp with an equal +/// but differently spelled dimension. If the two spellings are not the same +/// type, this does not link. The two that carry named base dimensions are also +/// defined with a spelling different from their declaration here, so that the +/// declaration, the definition and the call each work out the named bases' +/// canonical order for themselves. #include diff --git a/test/document_tests.cpp b/test/document_tests.cpp index 5fd7060..d696ef6 100644 --- a/test/document_tests.cpp +++ b/test/document_tests.cpp @@ -235,7 +235,7 @@ TEST_CASE("document: a variable inside a RoundNode still appears in the symbol t // // **It does not establish that the collect() forward declarations are // load-bearing, and an earlier version of this comment claimed it did.** - // Measured while phase 10 added the lookup overloads: deleting every + // Measured when the lookup overloads were added: deleting every // forward declaration in document.hpp and rebuilding this suite succeeds // on cl 19.51, clang-cl 22 and g++ 14.2 -- this test included. ADL finds // the overload wherever it is declared, exactly as document.hpp's own @@ -484,7 +484,7 @@ TEST_CASE("document: a constraint predicate's right-hand side reaches the symbol CHECK(documentation.symbols[1].description == std::string_view { "second replicate reading" }); } -// ------------------------------------------------------- phase 10: lookups +// ----------------------------------------------------------------- lookups namespace { @@ -545,7 +545,7 @@ struct AbsentReading: formula::Quantity LayerBands { band(139, 100, 713, 100), // 139/100 to under 713/100 mm band(713, 100, 3466, 200), // 713/100 to under 1733/100 mm -- 3466/200 declared, so reduction shows @@ -664,8 +664,8 @@ void quantityAgrees(formula::Documentation const& documentation, std::size_t& sh /// the same tree -- `render()` fills `.formula`, `detail::collect` fills /// `.symbols` -- and `document()` never compares them with each other. A node /// kind taught to one walk and not to the other is therefore invisible to any -/// test that asserts each half on its own, which is the shape of the defect -/// phase 8 published: two surfaces, each internally consistent, disagreeing. +/// test that asserts each half on its own, which is the shape of a defect +/// once published: two surfaces, each internally consistent, disagreeing. /// /// So this asserts the relation instead of either half. For every quantity in /// @p Qs the rendered formula shows its symbol **if and only if** the symbol @@ -679,7 +679,7 @@ void quantityAgrees(formula::Documentation const& documentation, std::size_t& sh /// kill -- every attempt to construct one failed. Its whole claim is the one /// below: it is the only assertion in this file that checks either half of a /// `Documentation` against the other rather than against a literal its author -/// typed, which is the property phase 8's published defect violated, and it is +/// typed, which is the property that published defect violated, and it is /// what a lookup kind added later gets without anyone remembering to write it. /// /// The rows also come back in the order the formula reads, which is what @@ -854,7 +854,7 @@ TEST_CASE("document: the rendered formula and the symbol table agree on every lo formulaAndSymbolsAgree(profileLookup() * var); } -// ---- A series in the symbol table (phase 12) ---- +// ---- A series in the symbol table ---- namespace { diff --git a/test/least_squares_tests.cpp b/test/least_squares_tests.cpp index faae5d2..7f20d57 100644 --- a/test/least_squares_tests.cpp +++ b/test/least_squares_tests.cpp @@ -232,8 +232,8 @@ TEST_CASE("two points give the exact line through them", "[least-squares]") namespace { // Point k at ((k + 1)/(k + 2) s, (2k + 3)/(k + 3) mm): a different -// denominator on every point, the spike's shape that overflows from 27 -// points (step 3). Invented, and ascending, as a curve's points must be. +// denominator on every point, a shape that overflows at 27 points, as the +// test below pins. Invented, and ascending, as a curve's points must be. template [[nodiscard]] auto distinct_denominators() { diff --git a/test/lineage_tests.cpp b/test/lineage_tests.cpp index 0559199..926502e 100644 --- a/test/lineage_tests.cpp +++ b/test/lineage_tests.cpp @@ -353,7 +353,7 @@ struct formula::TagName TEST_CASE("a lineage attribute's name is escaped in the trace, as other author text is", "[lineage-trace]") { - // Phase 11's trace escape (`escaped_author_text`), applied to role and + // The trace's escape (`escaped_author_text`), applied to role and // attribute names through `tag_words`. A role's name is identifier-like, // so only an attribute's can hold these; unescaped, ";" would read as the // start of a new clause, and a "\" before it as escaping it. diff --git a/test/lookup_tests.cpp b/test/lookup_tests.cpp index 97ce4ed..e36e63d 100644 --- a/test/lookup_tests.cpp +++ b/test/lookup_tests.cpp @@ -383,7 +383,7 @@ TEST_CASE("a key that is not an enumerator at all is a miss, not an index", "[lo TEST_CASE("an exact miss and a banded miss are the same failure, reported the same way", "[lookup]") { - // The one test that pins this phase's stated drift risk directly. Two + // The one test that pins the lookups' drift risk directly. Two // different tables, two different reasons nothing was found, and exactly // one vocabulary for "found nothing" -- so a later change that gives // either kind its own spelling fails here rather than in a consumer. @@ -535,9 +535,8 @@ TEST_CASE("key_table_is_well_formed answers for a table that only arrives at run TEST_CASE("a two-row exact table selects each of its rows and misses everything else", "[lookup]") { // The compile-time side of the same boundary the predicate test covers - // just above: two rows is the smallest table with a pair, and the phase - // otherwise only ever uses 0, 1, 4 and 5. Both rows, so neither a - // first-only nor a last-only scan passes. + // just above: two rows is the smallest table with a pair. Both rows, so + // neither a first-only nor a last-only scan passes. constexpr KeyTable TwoShapes { SpecimenVariant::CubeSmall, SpecimenVariant::Prism }; constexpr auto first = formula::checked_evaluate( @@ -737,7 +736,7 @@ TEST_CASE("a value below the table's first row is a miss -- interpolation does n // -5 mm == -0.5 cm, below `CurvePoints[0]`. An implementation that ran the // first segment's slope backwards would answer about 75.2 % here, // confidently, for an input the table never defined -- which is the one - // thing this phase refuses everywhere. A miss, not a value. + // thing the lookups refuse everywhere. A miss, not a value. constexpr auto computed = formula::checked_evaluate(curve(), millimetresOfDiameter(-5)); STATIC_REQUIRE(!computed.has_value()); STATIC_REQUIRE(computed.error() == formula::ArithmeticError::DomainError); @@ -984,7 +983,7 @@ TEST_CASE("breakpoint_table_is_well_formed answers for a curve that only arrives TEST_CASE("all three lookup kinds report finding nothing the same way", "[lookup]") { - // The one test that pins this phase's stated drift risk across every table + // The one test that pins the lookups' drift risk across every table // kind at once. Three different tables, three different reasons nothing was // found, and exactly one vocabulary for "found nothing" -- so a later change // that gives any kind its own spelling fails here rather than in a consumer. diff --git a/test/measured_tests.cpp b/test/measured_tests.cpp index e993100..51ac646 100644 --- a/test/measured_tests.cpp +++ b/test/measured_tests.cpp @@ -190,7 +190,7 @@ static_assert(formula::combine(Measured {}, Measured(measured(450, 1)); REQUIRE(present.has_value()); REQUIRE(present->has_value()); @@ -285,7 +285,7 @@ TEST_CASE("rounding to declared precision leaves an absent value absent", "[meas // ---- an inner error is an ERROR, never silently reported as absence ---- // -// Three functions delegate to a phase-3 checked_ function and, until this +// Three functions delegate to an underlying checked_ function and, until this // section, only ever exercised its success path. Turning the inner failure // into `return Measured {}` (or, for bounds, `NotMeasured`) instead of // propagating the error leaves every test above this comment green -- that diff --git a/test/method_tests.cpp b/test/method_tests.cpp index fc438c5..9d877d6 100644 --- a/test/method_tests.cpp +++ b/test/method_tests.cpp @@ -163,11 +163,11 @@ TEST_CASE("the expression a variant was given is the expression it stores", "[me // It kills no mutation uniquely, and that is characteristic of the kind // rather than a defect in this one: dropping the expression in `variant()` // leaves its parameter unreferenced and `/W4 /WX` rejects the build before - // any test runs. What a reachability probe catches is ABSENCE -- phase 9 - // shipped an overload that worked, was tested, and no user could call; - // phase 10 shipped a `document()` walk that compiled for nothing. The - // spike compiled this pack and never ran it, so nothing until now had - // established that a stored variant still evaluates at all. + // any test runs. What a reachability probe catches is ABSENCE: an overload + // that works and is tested but that no user can call, or a `document()` + // walk that compiles for nothing. Compiling this pack proves nothing about + // running it, so this establishes that a stored variant still evaluates at + // all. // // `ConstantNode` because it is the node with runtime state reachable from // the operators THIS fixture uses: a `VarNode` holds no bytes, and a @@ -384,7 +384,7 @@ TEST_CASE("a sink is told whose constraints they are around the checks, or not a CHECK(half == "cc"); } -// ------------------------------------------- phase 13: a precision check +// ----------------------------------------------------- a precision check namespace { diff --git a/test/negative/exact_lookup_duplicate_key_two_rows.cpp b/test/negative/exact_lookup_duplicate_key_two_rows.cpp index 23d2db0..6a6236b 100644 --- a/test/negative/exact_lookup_duplicate_key_two_rows.cpp +++ b/test/negative/exact_lookup_duplicate_key_two_rows.cpp @@ -3,7 +3,7 @@ // REJECT: must be initialized by a constant expression // // TWO rows, both the same key: the smallest table that can contain a -// duplicate at all, and the case the rest of the phase never reaches. An +// duplicate at all, and the case the other lookup tests never reach. An // empty table has no pair, a one-row table has no pair, and every other // duplicate case here uses five rows -- so the boundary between "no pair to // check" and "one pair to check" is checked by nothing else. Should the pair diff --git a/test/negative/exact_lookup_int_key_table.cpp b/test/negative/exact_lookup_int_key_table.cpp index bfae393..095b96a 100644 --- a/test/negative/exact_lookup_int_key_table.cpp +++ b/test/negative/exact_lookup_int_key_table.cpp @@ -6,7 +6,7 @@ // types `ExactLookupNode` refuses: a validator that blessed an // `std::array` would be certifying a table no node could ever be // built from -- two surfaces answering the same question differently, which -// is the defect this phase keeps finding. Pinned separately from +// is a defect this library has met more than once. Pinned separately from // `exact_lookup_keys_match_int_key.cpp` so that removing either guard alone // is caught; each file exercises exactly one entry point. This must not // compile. diff --git a/test/negative/exact_lookup_rep_not_rational.cpp b/test/negative/exact_lookup_rep_not_rational.cpp index e9e3a52..eab1d48 100644 --- a/test/negative/exact_lookup_rep_not_rational.cpp +++ b/test/negative/exact_lookup_rep_not_rational.cpp @@ -3,7 +3,7 @@ // // `checked_evaluate_si` on an exact lookup. The guard's message is // author-facing text and therefore tested API, like every other -// `static_assert` in this phase -- and it is the ONLY place the reason for +// `static_assert` of the lookups -- and it is the ONLY place the reason for // this refusal reaches a user, since nobody reads a header's file comment // when a build fails. It must say what the file comment says: every lookup // table in this library answers in one representation, NOT that selecting a diff --git a/test/negative/interpolating_lookup_malformed_breakpoint.cpp b/test/negative/interpolating_lookup_malformed_breakpoint.cpp index d489c11..4dcac68 100644 --- a/test/negative/interpolating_lookup_malformed_breakpoint.cpp +++ b/test/negative/interpolating_lookup_malformed_breakpoint.cpp @@ -7,13 +7,13 @@ // getting wrong once to find. // // **The table has exactly ONE row, and that is the point of the file.** The -// first draft put the malformed key in the middle of three, applying this -// phase's position rule mechanically. It was the wrong table: a key that is not -// a number is not below the key after it either, so both rules fire on such a -// table, and deleting `RequireBreakpointWellFormed` entirely still left that -// file failing to compile -- through `RequireBreakpointsAscend`. The file would -// have gone on passing with the guard it exists to pin deleted, which is the -// defect `exact_lookup_unscoped_key.cpp` was found to have. +// first draft put the malformed key in the middle of three, applying the +// put-the-defect-in-the-middle rule mechanically. It was the wrong table: a key +// that is not a number is not below the key after it either, so both rules fire +// on such a table, and deleting `RequireBreakpointWellFormed` entirely still +// left that file failing to compile -- through `RequireBreakpointsAscend`. The +// file would have gone on passing with the guard it exists to pin deleted, +// which is the defect `exact_lookup_unscoped_key.cpp` was found to have. // // A one-row table has no adjacent pair, so the ordering sweep never runs and // this guard is the only thing standing between an author and a table with a diff --git a/test/negative/lookup_rep_not_rational.cpp b/test/negative/lookup_rep_not_rational.cpp index a586853..6530c6b 100644 --- a/test/negative/lookup_rep_not_rational.cpp +++ b/test/negative/lookup_rep_not_rational.cpp @@ -2,9 +2,9 @@ // EXPECT: formula: a banded lookup node can only be evaluated with Rep = Rational // // `checked_evaluate_si` on a banded lookup. The exact lookup's own -// `Rep` guard gained a negative case in the same round, and leaving this one -// untested would put the two halves of one rule in different states -- one -// pinned, one free to drift -- which is the failure this phase keeps finding. +// `Rep` guard has a negative case (`exact_lookup_rep_not_rational.cpp`), and +// leaving this one untested would put the two halves of one rule in different +// states -- one pinned, one free to drift -- a failure met more than once. // Unlike the exact lookup's, this message's reason is literally true: deciding // which band a value falls in IS arithmetic, and a value a few ULPs off an // intended boundary picks the wrong band silently. This must not compile. diff --git a/test/negative/method_tag_names_spelt_alike.cpp b/test/negative/method_tag_names_spelt_alike.cpp index 139afc7..73ebd34 100644 --- a/test/negative/method_tag_names_spelt_alike.cpp +++ b/test/negative/method_tag_names_spelt_alike.cpp @@ -3,9 +3,9 @@ // // `TagName` spelling `Cylinder` beside a real `Cylinder` variant. The // tags are distinct types, so the distinct-tag rule accepts them, but a trace -// line naming the variant that ran would read `Cylinder` for either (final -// review of phase 11, L3). Refused where the names are shown, the first time -// the method is evaluated, whichever tag is asked for -- here `Cube`. +// line naming the variant that ran would read `Cylinder` for either. Refused +// where the names are shown, the first time the method is evaluated, +// whichever tag is asked for -- here `Cube`. // // This must not compile. #include diff --git a/test/negative/method_variants_disagree.cpp b/test/negative/method_variants_disagree.cpp index 0f38f79..14bff88 100644 --- a/test/negative/method_variants_disagree.cpp +++ b/test/negative/method_variants_disagree.cpp @@ -5,9 +5,9 @@ // that the comparison this file exists to provoke is neither the first the // guard makes nor the last. // -// The rule inherited from phase 10 is "put the defect in the middle, because -// that defeats a first-only and a last-only sweep at once", and phase 10 read -// "middle" as a middle PAIR of four rows, because a band table is checked +// The rule for a band table is "put the defect in the middle, because that +// defeats a first-only and a last-only sweep at once", and there "middle" +// means a middle PAIR of four rows, because a band table is checked // pairwise between neighbours. This guard is not pairwise between neighbours: // it compares the first variant against each later one, so a pack of three // yields only the two comparisons (0,1) and (0,2), and there is no middle one diff --git a/test/negative/opaque_throwing_compute.cpp b/test/negative/opaque_throwing_compute.cpp index 02712dd..c8e4a0e 100644 --- a/test/negative/opaque_throwing_compute.cpp +++ b/test/negative/opaque_throwing_compute.cpp @@ -6,7 +6,7 @@ // A compute declared without noexcept: an exception escaping it would end the // program, since evaluation is noexcept throughout. Refused once, where the // call is built; the evaluator's body is gated on the call being sound, so -// g++ 14.2 adds none of its own errors (phase 15's spike, step 7) -- the +// g++ 14.2 adds none of its own errors -- the // REJECTs name the ones it added when the body was not gated. #include diff --git a/test/negative/overlay_constant_inside_opaque_series.cpp b/test/negative/overlay_constant_inside_opaque_series.cpp index 2a3a894..63fa3f1 100644 --- a/test/negative/overlay_constant_inside_opaque_series.cpp +++ b/test/negative/overlay_constant_inside_opaque_series.cpp @@ -6,7 +6,7 @@ // `with_constant` on a method whose only use of the length is the // series a fit reads. The rewrite sees through the opaque call to its inputs // (`ConstantRewrite` for `OpaqueOutputNode`, `overlay.hpp`), so the refusal is -// phase 12's for a series -- once -- and not also "cannot see inside", nor +// the one for a series -- once -- and not also "cannot see inside", nor // "nobody reads it", which would both be false. #include #include diff --git a/test/negative/series_elementwise_wrong_dimension_first.cpp b/test/negative/series_elementwise_wrong_dimension_first.cpp index d068947..2f3fdb0 100644 --- a/test/negative/series_elementwise_wrong_dimension_first.cpp +++ b/test/negative/series_elementwise_wrong_dimension_first.cpp @@ -8,8 +8,8 @@ // whose left dimension is a stand-in (the opening's), asks nothing. One // message, counted by hand on cl 19.51, g++-14 and clang++-20. The scalar // operators' own chain, var + var + var, still draws two on g++ -// and clang++ (measured): BinaryNode carries no such -// flag, which is inherited and left for a later phase. +// and clang++ (measured): BinaryNode carries no such flag, which is +// inherited and left as it is. #include struct Retained: formula::Quantity diff --git a/test/opaque_tests.cpp b/test/opaque_tests.cpp index 830687d..ae5421d 100644 --- a/test/opaque_tests.cpp +++ b/test/opaque_tests.cpp @@ -455,9 +455,9 @@ TEST_CASE("a result entered by a person replaces an opaque output, which is not TEST_CASE("an opaque call and its outputs survive a default-constructibility probe", "[opaque]") { // std::tuple asks whether its members are default-constructible; a `{}` - // initialiser on an expression member turned that question into a hard - // error on clang, g++-14 and libc++ in phase 11 (defect class 4). Here - // it must simply compile, and answer. + // initialiser on an expression member once turned that question into a + // hard error on clang, g++-14 and libc++. Here it must simply compile, and + // answer. using Output = decltype(formula::opaque_output<"span">(span_call)); STATIC_REQUIRE(std::is_default_constructible_v> == std::is_default_constructible_v); STATIC_REQUIRE(std::is_default_constructible_v> diff --git a/test/overflow_census_tests.cpp b/test/overflow_census_tests.cpp index 3b97bc1..f865886 100644 --- a/test/overflow_census_tests.cpp +++ b/test/overflow_census_tests.cpp @@ -197,7 +197,7 @@ template inline constexpr auto sixPercent = formula::deviation_from_mean(Rational { 6, 100 } * formula::pass_mean); inline constexpr auto sevenQuarters = formula::deviation_in_stddevs(formula::number(Rational { 7, 4 })); -// The phase 13 fixtures (rejection_tests.cpp's shared fixtures), in grams. +// The statistics fixtures (rejection_tests.cpp's shared fixtures), in grams. std::array const fixtureA { rat(402, 10), rat(398, 10), rat(405, 10), rat(44), rat(40), rat(433, 10) }; std::array const fixtureB { rat(402, 10), rat(398, 10), rat(405, 10), rat(452, 10), rat(40), rat(372, 10) }; std::array const fixtureC { rat(40), rat(40), rat(44), rat(40), rat(36) }; @@ -317,11 +317,11 @@ template return found; } -// ---- Least squares (phase 15), a spike's data shapes ------------------------------ +// ---- Least squares: three data shapes --------------------------------------------- // Point k of each shape, in coherent SI -- seconds and newtons -- so the fit -// sees exactly these numbers. Invented; the spike's offsets are replaced by -// primes, the rest of its generator kept. +// sees exactly these numbers. Invented: the two decimal shapes' offsets are +// primes. struct FitPoint { Rational x; @@ -716,7 +716,7 @@ TEST_CASE("the census reports 0 bits of headroom for the largest Int128, and Ove // ---- The census set ----------------------------------------------------------------- -TEST_CASE("census: phase 13's fixtures", "[census]") +TEST_CASE("census: statistics, outlier rejections and spreads over the six fixtures", "[census]") { emit("statistics", "| formula | numerator bits | denominator bits | intermediate bits | unsigned bits | headroom |"); emit("statistics", "|---|---|---|---|---|---|"); @@ -749,7 +749,7 @@ TEST_CASE("census: phase 13's fixtures", "[census]") print_row("fixture F: exact root at 0 dp", census_of([] { REQUIRE(spread_of<0>(fixtureF)); })); } -TEST_CASE("census: phase 12's cumulative sums and interpolation", "[census]") +TEST_CASE("census: cumulative sums and interpolation", "[census]") { std::array const screens { rat(130), rat(210), rat(95), rat(340), rat(28) }; print_row("passing from the cumulative retained, 5 screens", census_of([&] { @@ -958,7 +958,7 @@ TEST_CASE("census: a cylinder's cross-section and its strength, for d from 101 t TEST_CASE("census: least squares over 2 to 128 points", "[census]") { - // The spike's shapes, through the library's own fit: which sizes + // The three shapes, through the library's own fit: which sizes // overflow, and what the others leave. An overflowing fit is the library's // Overflow, never a line. // The least-squares tests' fixtures, through the node as a method states the fit: t = 1, diff --git a/test/overlay_tests.cpp b/test/overlay_tests.cpp index aeae6ab..bf61042 100644 --- a/test/overlay_tests.cpp +++ b/test/overlay_tests.cpp @@ -383,7 +383,7 @@ TEST_CASE("an overlay fixes a constant inside every node kind", "[overlay]") STATIC_REQUIRE(withRatioFixedAtFour(f::documented(r, nationalAnnex)) == Rational { 4 }); // The citation is the one piece of a rebuilt node no value can show, and - // provenance is what this phase exists for: the rewritten wrapper must + // the one that says where a value came from: the rewritten wrapper must // still cite what the original cited. STATIC_REQUIRE( std::get<0>(withRatioFixedAtFourMethod(f::documented(r, nationalAnnex)).variantSet.cases).expression.citation @@ -807,9 +807,8 @@ inline constexpr formula::Citation laterPruneAnnex { .reference = "Example Stand TEST_CASE("a pin says which jurisdiction made the variant mandatory", "[overlay][trace]") { - // Final review of phase 11, M3: a pinned method traced its selection - // exactly as the base method did, so nothing said a jurisdiction had made - // the variant mandatory. + // A pinned method traced its selection exactly as the base method did, so + // nothing said a jurisdiction had made the variant mandatory. constexpr auto pinned = formula::apply(formula::overlay(formula::pin_variant(pinAnnex)), threeVariants); CHECK(traceOfVariant(pinned, inputs) @@ -1396,11 +1395,11 @@ TEST_CASE("an overlay's constraint judges a category code, and its verdict names TEST_CASE("an operation given an empty citation says so in every clause", "[overlay][trace]") { - // Final re-review of phase 11, M4: every operation takes a citation - // argument, but an empty one compiles -- `{}`, or an operation's aggregate - // built directly with no citation at all. A clause reading `pinned by - // jurisdiction overlay` and nothing more would then look cited to a reader - // who does not know it could have said more, so each says it was not. + // Every operation takes a citation argument, but an empty one compiles -- + // `{}`, or an operation's aggregate built directly with no citation at + // all. A clause reading `pinned by jurisdiction overlay` and nothing more + // would then look cited to a reader who does not know it could have said + // more, so each says it was not. constexpr auto pinnedEmpty = formula::apply(formula::overlay(formula::pin_variant({})), threeVariants); CHECK(traceOfVariant(pinnedEmpty, inputs) .ends_with(" [variant Cylinder (2nd of 3), selected by tag; pinned by jurisdiction overlay (no citation " diff --git a/test/record_join_tests.cpp b/test/record_join_tests.cpp index fc31335..aac409c 100644 --- a/test/record_join_tests.cpp +++ b/test/record_join_tests.cpp @@ -360,8 +360,8 @@ inline constexpr auto retainedRatio = TEST_CASE("a series formula through a context reads as through this record's environment", "[record-join]") { - // The inheritance probe, over phase 12's accessors: the - // context is this record's environment, `get_series` included. + // The inheritance probe, over the series accessors: the context is this + // record's environment, `get_series` included. constexpr auto viaContext = formula::checked_evaluate_si(formula::sum(formula::series), screensContext); constexpr auto viaEnvironment = @@ -410,7 +410,7 @@ struct Count: formula::Quantity { }; -// Phase 12's invented classes and sizes: three significant digits, none a +// Invented classes and sizes: three significant digits, none a // preferred number or a sieve size. inline constexpr formula::BandTable<3> sizeClasses { formula::band(0, 1, 127, 1), formula::band(127, 1, 197, 1), formula::band(197, 1, 331, 1) }; @@ -424,8 +424,8 @@ inline constexpr auto particlesContext = formula::record_context( TEST_CASE("raw observations read from another record are stamped with its origin", "[record-join]") { - // The observations path phase 12 added records its own step; read inside - // a scope, that step carries the record and its line names it. + // The observations path records its own step; read inside a scope, that + // step carries the record and its line names it. constexpr auto countedThere = formula::from_record( formula::sum(formula::binned(formula::observations))); constexpr auto total = formula::checked_evaluate_si(countedThere, particlesContext); diff --git a/test/record_statistics_tests.cpp b/test/record_statistics_tests.cpp index a90d6e4..578f9da 100644 --- a/test/record_statistics_tests.cpp +++ b/test/record_statistics_tests.cpp @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // -// Phase 13's statistics, outlier rejections and precision limits, read from +// Statistics, outlier rejections and precision limits, read from // another record. Each records steps of its own, on paths of its own -- a // rejection's passes and verdict through `push_rejection_step`, a precision // limit's level through `precision_level_produced` -- and every step read @@ -46,7 +46,7 @@ constexpr formula::Measured grams(formula::Rational value) return formula::Measured { value }; } -// The reference holds phase 13's fixture A, 40.2, 39.8, 40.5, 44.0, 40.0 and +// The reference holds the statistics fixture A, 40.2, 39.8, 40.5, 44.0, 40.0 and // 43.3 g, and fixture P's pair, 40 g and 40.905 g. This record holds other // values of each, so a step read from the wrong record gives another number. inline constexpr auto here = formula::environment( @@ -100,7 +100,7 @@ TEST_CASE("a statistic read from another record is stamped with that record", "[ TEST_CASE("an outlier rejection read from another record is stamped with that record, pass by pass", "[record-statistics]") { - // Phase 13's fixture A under a 6 % deviation from each pass's mean: pass + // The statistics fixture A under a 6 % deviation from each pass's mean: pass // 1 rejects 44.0 g, pass 2 rejects 43.3 g, pass 3 settles at 321/8 g. // Every pass, rejection and verdict is a step `push_rejection_step` // records, and each is the reference's. @@ -124,7 +124,7 @@ TEST_CASE("an outlier rejection read from another record is stamped with that re TEST_CASE("a precision limit read from another record is stamped with that record, its level included", "[record-statistics]") { - // Phase 13's fixture P: the level is the pair's mean, 40.4525 g, and r = + // The statistics fixture P: the level is the pair's mean, 40.4525 g, and r = // 0.1 g + level / 50 = 0.90905 g. The level's first pass is a step // `precision_level_produced` records, and it is the reference's. constexpr auto limitThere = formula::from_record(formula::precision_limit( diff --git a/test/render_tests.cpp b/test/render_tests.cpp index f155724..3ccff15 100644 --- a/test/render_tests.cpp +++ b/test/render_tests.cpp @@ -448,17 +448,16 @@ TEST_CASE("render: a dimensionless constant as the base of a power needs no brac CHECK(formula::render(formula::pow<2>(formula::constant(rat(5)))) == "5^2"); } -// ------------------------------------------------------- phase 8: rounding +// ---------------------------------------------------------------- rounding TEST_CASE("render: a decimal-places rounding node renders as round(..., to N dp of unit)", "[render][rounding]") { // The granularity is a comma-separated second argument, operand first -- // see the comment on RoundNode's render_node for why: a trailing suffix - // with nothing between it and the operand (round(... to 1 dp of mm), - // fixed in review round 1) let it misattach to a WhenNode operand's else - // branch, and a `[...]` prefix right against the operand's own - // parentheses (round[to 1 dp of mm](...), the round-1 fix itself) read as - // a CommonMark link in Markdown, fixed in review round 3. + // with nothing between it and the operand (round(... to 1 dp of mm)) once + // let it misattach to a WhenNode operand's else branch, and a `[...]` + // prefix right against the operand's own parentheses (round[to 1 dp of + // mm](...), the first fix itself) read as a CommonMark link in Markdown. constexpr auto rounded = formula::rounded( var); @@ -589,7 +588,7 @@ TEST_CASE("render: a significant-digits rounding node inside a power and inside == "\\operatorname{round}_{2\\mathrm{sf},\\,\\mathrm{mm}}(d) \\cdot 2"); } -// --------------------------------------------------- phase 8: predicates +// ------------------------------------------------------------ predicates TEST_CASE("render: a predicate renders as lhs comparison rhs", "[render][predicate]") { @@ -647,7 +646,7 @@ TEST_CASE("render: a conditional nested as a predicate's operand keeps its brack CHECK(formula::render(guarded) == "(if f > 473/10 MPa then f else f * 2) > 137/10 MPa"); } -// --------------------------------------------------- phase 8: conditionals +// ------------------------------------------------------------ conditionals TEST_CASE("render: a conditional renders as if/then/else, and as a LaTeX cases block", "[render][conditional]") { @@ -659,8 +658,8 @@ TEST_CASE("render: a conditional renders as if/then/else, and as a LaTeX cases b CHECK(formula::render(chosen) == "\\begin{cases} f \\cdot 2 & \\text{if } f > 473/10\\,\\mathrm{MPa} \\\\ f \\cdot 4 & \\text{otherwise} " "\\end{cases}"); - // RoundingMode is not the only thing this phase deliberately keeps out of - // the rendered text -- WhenNode has no state to omit, but note that its + // RoundingMode is not the only thing the renderer deliberately keeps out + // of the rendered text -- WhenNode has no state to omit, but note that its // predicate's operands are plain quantities and constants here on // purpose: the brackets a nested conditional needs are covered below, // not in this standalone case. @@ -668,7 +667,7 @@ TEST_CASE("render: a conditional renders as if/then/else, and as a LaTeX cases b TEST_CASE("render: a conditional inside a power keeps its bracket", "[render][conditional]") { - // This is the case phase 6's two rendering bugs generalise to: a node + // This is the case two earlier rendering bugs generalise to: a node // whose *text* binds looser than arithmetic must bracket as the base of // a power, exactly as a negative or unit-bearing constant does. constexpr auto overThreshold = var > formula::constant(rat(473, 10)); @@ -697,17 +696,16 @@ TEST_CASE("render: a conditional inside a product keeps its bracket", "[render][ "\\cdot 2"); } -// ------------------------------------------------- phase 8: numeric_value_of +// ---------------------------------------------------------- numeric_value_of TEST_CASE("render: a numeric-value escape hatch renders as numeric(..., in unit)", "[render][escape]") { // The unit is a comma-separated second argument, operand first -- see the // comment on NumericValueNode's render_node for why: a trailing suffix - // with nothing between it and the operand (numeric(... in MPa), fixed in - // review round 1) let it misattach to a WhenNode operand's else branch, - // and a `[...]` prefix right against the operand's own parentheses - // (numeric[in MPa](...), the round-1 fix itself) read as a CommonMark - // link in Markdown, fixed in review round 3. + // with nothing between it and the operand (numeric(... in MPa)) once let + // it misattach to a WhenNode operand's else branch, and a `[...]` prefix + // right against the operand's own parentheses (numeric[in MPa](...), the + // first fix itself) read as a CommonMark link in Markdown. constexpr auto numeric = formula::numeric_value_of(var); @@ -736,15 +734,14 @@ TEST_CASE("render: a numeric-value escape hatch inside a power and inside a prod CHECK(formula::render(numeric * rat(2)) == "\\{f/\\mathrm{MPa}\\} \\cdot 2"); } -// ----------------------------- phase 8 fix round 1: nesting a new kind -// inside another new kind, in every dialect. This is the exact axis review -// round 1 found untested -- and where the trailing-suffix bug (findings 1-2 -// of that review) was hiding. +// ---------------------------------------- nesting one node kind inside +// another, in every dialect. This is the exact axis a review once found +// untested -- and where the trailing-suffix bug was hiding. TEST_CASE("render: a rounding node wrapping a conditional keeps the granularity from misattaching to a branch", "[render][rounding][conditional]") { - // Before review round 1's fix, this rendered in Plain as "round(if f > 473/10 + // Before the fix, this rendered in Plain as "round(if f > 473/10 // MPa then d * 2 else d * 3 to 1 dp of mm)" -- a reader parses "d * 3 to // 1 dp of mm" as one phrase, rounding the else branch alone. The // granularity is now a comma-separated second argument, so nothing can @@ -769,8 +766,8 @@ TEST_CASE("render: a rounding node wrapping a conditional keeps the granularity TEST_CASE("render: a numeric-value escape hatch wrapping a conditional keeps the unit from misattaching to a branch", "[render][escape][conditional]") { - // The exact shape of must-fix finding 2 in review round 1: before the - // fix, this rendered in Plain as "numeric(if f > 473/10 MPa then f * 2 else f + // The exact shape of the trailing-suffix bug: before the fix, this + // rendered in Plain as "numeric(if f > 473/10 MPa then f * 2 else f // * 4 in MPa)", reading as if "in MPa" (and therefore the whole escape // hatch) applied to the else branch alone. constexpr auto overThreshold = var > formula::constant(rat(473, 10)); @@ -865,7 +862,7 @@ TEST_CASE("render: a conditional nested inside another conditional's branches is "\\end{cases} & \\text{otherwise} \\end{cases}"); } -// --------------------------------------------------- phase 9: constraints +// ------------------------------------------------------------ constraints TEST_CASE("render: a constraint renders as its rule, never its verdict", "[render][constraint]") { @@ -923,7 +920,7 @@ TEST_CASE("render: a constraint's predicate brackets a nested conditional exactl CHECK(formula::render(rule) == "require (if f > 473/10 MPa then f else f * 2) > 137/10 MPa"); } -// ------------------------------------------------------- phase 10: lookups +// ----------------------------------------------------------------- lookups namespace { @@ -1276,7 +1273,7 @@ TEST_CASE("render: a table with no rows says so, and a table with one row render // All three empty tables are valid and all three always miss (`band.hpp`, // `lookup.hpp`). `lookup(d)` would show a reader a complete-looking call // with the whole table silently absent, which is the same class of lie as - // the operand a published page dropped in phase 8. + // the operand a published page once dropped. CHECK(formula::render(banded_lookup(var, {})) == "lookup(d, no rows)"); CHECK(formula::render(exact_lookup(MouldShape::Beam, {})) @@ -1458,7 +1455,7 @@ TEST_CASE("render: every character either dialect escapes in a key's name is esc TEST_CASE("render: a documented lookup renders as the bare lookup, like every other wrapped node", "[render][lookup]") { // A citation is documentation, not arithmetic -- `document()` surfaces it. - // Worth one case per phase that adds node kinds, because `DocumentedNode` + // Worth one case per family of node kinds, because `DocumentedNode` // is the wrapper `documented()` puts round a table's identity, and a table // is the part of a method that carries a source. constexpr auto cited = formula::documented(bandedLookup(), { .title = "Invented Method 7, table 2" }); @@ -1528,9 +1525,9 @@ TEST_CASE("render: the three dialects name a lookup's rows the same way, for all { // THE cross-surface test. Every other case in this section asserts one // dialect's output against a literal, and a set of such cases cannot catch - // two dialects drifting apart -- that is the whole lesson of phase 8, - // where two renderers each had passing tests and each was internally - // consistent, and a human reading a published page found the disagreement. + // two dialects drifting apart -- that is the whole lesson of the time two + // renderers each had passing tests and each was internally consistent, + // and a human reading a published page found the disagreement. // // So this compares the dialects **against each other**, and locates what // it compares by POSITION -- the field index inside the rendered call -- @@ -1570,13 +1567,12 @@ TEST_CASE("render: the three dialects name a lookup's rows the same way, for all dialectsAgree(curveLookup(), 4); } -// --------------------------------------------- phase 8 fix round 3: guard -// against the whole class of bug review round 3 found, not just this one -// instance. `"](" `is CommonMark's inline-link syntax -- a Markdown renderer -// displays only the link's label, silently dropping whatever the destination -// held, so string equality between two Markdown-dialect strings is blind to -// this: two strings can be equal to each other and still both be wrong in -// the same way. Only checking the actual character sequence a Markdown +// ------------------------- guard against the whole class of bug, not just +// one instance of it. `"](" `is CommonMark's inline-link syntax -- a Markdown +// renderer displays only the link's label, silently dropping whatever the +// destination held, so string equality between two Markdown-dialect strings is +// blind to this: two strings can be equal to each other and still both be wrong +// in the same way. Only checking the actual character sequence a Markdown // parser treats specially catches it, which is what this test does instead. namespace @@ -1653,23 +1649,23 @@ TEST_CASE("render: Markdown output never contains text a CommonMark parser reint // A bare "[" alone is not risky by itself, but nothing this library // writes has any legitimate reason to contain one either -- so the // stronger check costs nothing and catches a "[...]" reference-style - // link too, not only the inline "[...](...)" shape review round 3 - // found. A backslash-escaped `\[` is inert, and is exactly how a key's + // link too, not only the inline "[...](...)" shape that once reached a + // page. A backslash-escaped `\[` is inert, and is exactly how a key's // author-supplied name carries one (`detail::literal_words_in_dialect`). CHECK(unescapedPositions(text, '[').empty()); - // Phase 13: a bare `|`. Inside a Markdown table cell it ends the - // cell, silently -- a spike measured a row whose formula held an - // absolute value in bars render as one cell holding only the text - // before the first bar (python-markdown 3.10.3, pymdown-extensions - // 12.1). No plain or Markdown spelling in this library writes one: - // an absolute value is `abs(...)` there, and bars are LaTeX's alone. + // A bare `|`. Inside a Markdown table cell it ends the cell, silently + // -- measured: a row whose formula held an absolute value in bars + // renders as one cell holding only the text before the first bar + // (python-markdown 3.10.3, pymdown-extensions 12.1). No plain or + // Markdown spelling in this library writes one: an absolute value is + // `abs(...)` there, and bars are LaTeX's alone. CHECK(unescapedPositions(text, '|').empty()); - // Phase 10 round 2: an asterisk. A bare `*` CANNOT be forbidden the - // way `[` is, because one node kind emits it legitimately -- - // `render_node(BinaryNode)` spells multiplication ` * ` in Plain and - // Markdown alike, and always will. + // An asterisk. A bare `*` CANNOT be forbidden the way `[` is, because + // one node kind emits it legitimately -- `render_node(BinaryNode)` + // spells multiplication ` * ` in Plain and Markdown alike, and always + // will. // // But every asterisk this library emits has a space on BOTH sides, // and that is exactly what makes it safe: CommonMark's flanking rules @@ -1766,7 +1762,7 @@ TEST_CASE("render: Markdown output never contains text a CommonMark parser reint formula::rounded_elementwise( formula::series))); // ElementwiseRoundNode - // Phase 10's three lookup kinds. A band is naturally written `[103, 197)`, + // The three lookup kinds. A band is naturally written `[103, 197)`, // which is the exact character sequence this guard forbids -- so these // three lines are the reason `render.hpp` rules that a half-open interval // is spelled `103 to under 197` instead, and the thing that fails if anyone @@ -1782,7 +1778,7 @@ TEST_CASE("render: Markdown output never contains text a CommonMark parser reint isInertInMarkdown(formula::render( exact_lookup(MouldMarking::Stamped, { rat(1127, 1000) }))); - // Phase 14: a read from another record, alone and compound, and one whose + // A read from another record, alone and compound, and one whose // role's published name is underscored. A role's name is identifier-like // (`RequireIdentifierLikeRoleName`), so Markdown's link syntax, asterisks // and backticks cannot reach it; this guard does not check underscores, @@ -1794,11 +1790,11 @@ TEST_CASE("render: Markdown output never contains text a CommonMark parser reint // And a formula nesting several of the above, since a guard that only // ever sees one node kind in isolation could still miss an interaction - // between two -- which is exactly how review round 3's defect hid from - // both the mutation testing and the "read it as a person would" pass in - // fix round 1: neither ever combined a rounding/escape node with a - // conditional operand under Dialect::Markdown and looked at the raw - // character sequence rather than the string as a whole. + // between two -- which is exactly how the `](` defect once hid from both + // mutation testing and a "read it as a person would" pass: neither ever + // combined a rounding/escape node with a conditional operand under + // Dialect::Markdown and looked at the raw character sequence rather than + // the string as a whole. constexpr auto deep = formula::numeric_value_of(formula::when( overThreshold, var * rat(2), var * rat(4))); @@ -1934,7 +1930,7 @@ TEST_CASE("render: a lookup key's name is set in math mode, where the site's Mat CHECK(formula::detail::latex_math_words("key fit_2") == "key\\ fit\\_2"); } -// ---- A series variable, marked as a series in the formula itself (phase 12) ---- +// ---- A series variable, marked as a series in the formula itself --------------- namespace { @@ -1965,7 +1961,7 @@ TEST_CASE("a series variable is marked as a series in the formula itself, in eve // The marker wraps the jurisdiction's symbol, never the declared one. CHECK(formula::render(formula::series) == "m_r(i)"); CHECK(formula::render(formula::series) == "{m_r}_{i}"); - // A symbol with a braced subscript still groups (typeset clean in a spike). + // A symbol with a braced subscript still groups (measured to typeset clean). constexpr auto braced = formula::vocabulary(formula::renames("f_{c}")); CHECK(formula::render(formula::series, braced) == "{f_{c}}_{i}"); // The known limit, pinned so it is a decision and not an accident: a diff --git a/test/retry_tests.cpp b/test/retry_tests.cpp index 90a5084..a59035d 100644 --- a/test/retry_tests.cpp +++ b/test/retry_tests.cpp @@ -366,8 +366,8 @@ constexpr bool answers_every_environment_member() { // Environment's public members, listed by hand at the branch point // (d09657e): provides, is_entered, is_entered_series, get, get_series, - // get_observations and source_of. If phase 14 or any later change adds a - // member nodes call, add it here and forward it. + // get_observations and source_of. If a change adds a member nodes call, + // add it here and forward it. return requires(AE const& wrapped) { { AE::template provides } -> std::convertible_to; { AE::template is_entered } -> std::convertible_to; diff --git a/test/series_tests.cpp b/test/series_tests.cpp index dfa1d78..af7027d 100644 --- a/test/series_tests.cpp +++ b/test/series_tests.cpp @@ -13,8 +13,8 @@ namespace { -// The shared fixture's quantities (see the phase 12 plan): an invented screen -// analysis, retained masses in grams. +// The shared fixture's quantities: an invented screen analysis, retained +// masses in grams. struct Retained: formula::Quantity { }; diff --git a/test/sink_tests.cpp b/test/sink_tests.cpp index 751c72e..0bf2c7c 100644 --- a/test/sink_tests.cpp +++ b/test/sink_tests.cpp @@ -113,8 +113,8 @@ TEST_CASE("NullSink changes neither the answer nor whether one is produced", "[s namespace { -/// A consumer's own node, written against the extension point as phase 5 -/// published it: two parameters, no knowledge of sinks. +/// A consumer's own node, written against the extension point as first +/// published: two parameters, no knowledge of sinks. struct LegacyNode: formula::NodeBase { static constexpr formula::Dimension dimension = formula::dim::Mass; @@ -187,10 +187,10 @@ TEST_CASE("trace_of_si traces a two-parameter extension-point node at the root, // Constant evaluation // // `evaluate` and `checked_evaluate` must remain usable in a constant -// expression. Threading a sink through every overload in phase 7 could have -// cost that -- a single non-constexpr step anywhere in the walk would -- and -// nothing in the suite would have noticed, because every other test calls -// them at runtime. +// expression. Threading a sink through every overload could have cost that +// -- a single non-constexpr step anywhere in the walk would -- and nothing in +// the suite would have noticed, because every other test calls them at +// runtime. // // These are `static_assert`s rather than `CHECK`s deliberately: the claim is // that the computation happens during translation, and a runtime assertion @@ -222,8 +222,8 @@ static_assert(evaluatedAtCompileTime.is_value(), "formula: evaluate must work in static_assert(evaluatedAtCompileTime.measurement().value() == formula::Rational { 2 }); // The sink-carrying walk itself, with a sink named explicitly rather than -// defaulted, so this covers the parameter phase 7 added and not just the -// defaulted call. +// defaulted, so this covers the sink parameter and not just the defaulted +// call. constexpr auto tracedAtCompileTime = [] { formula::NullSink sink {}; return formula::checked_evaluate_si(densityFormula, constantEnvironment, sink); diff --git a/test/statistics_tests.cpp b/test/statistics_tests.cpp index b310295..582008e 100644 --- a/test/statistics_tests.cpp +++ b/test/statistics_tests.cpp @@ -285,7 +285,7 @@ TEST_CASE("each statistic documents its sample on its own", "[statistics][docume TEST_CASE("an empty sample cannot be written, and the statistics refuse one all the same", "[statistics]") { - // series is refused where it is written (phase 12), and a + // series is refused where it is written (`series.hpp`), and a // rejection keeps at least one determination (KeepAtLeast, m >= 1), so // no sample a formula can name is empty. The guards stay, for the next // sample source: a mean over none is a division by zero and a range a diff --git a/test/trace_render_tests.cpp b/test/trace_render_tests.cpp index 77bbaeb..c534771 100644 --- a/test/trace_render_tests.cpp +++ b/test/trace_render_tests.cpp @@ -308,7 +308,7 @@ TEST_CASE("an explicit limit of zero is allowed, and says what it hid", "[trace- CHECK(text == "... 1 further step not shown\n"); } -// --------------------------------------------------------------- phase 8 +// ----------------- rounding, logarithms, numeric values and conditionals TEST_CASE("a derivation renders a Round step as round(..., to N dp of unit)", "[trace-render]") { @@ -678,11 +678,11 @@ TEST_CASE("a derivation spells a comparison the way render() does", "[trace-rend // constraint_expression (trace_render.hpp) and render_node(Constraint // ...) (render.hpp) are two independent functions that each spell // "require " from scratch, and nothing but this - // assertion ties them together. Phase 8 shipped exactly this shape of - // defect -- render() and the trace renderer disagreeing about a - // rounding spelling -- for several commits, each internally consistent - // and fully tested, caught only by a whole-branch review because no - // test compared the two surfaces to each other. + // assertion ties them together. This shape of defect once shipped -- + // render() and the trace renderer disagreeing about a rounding spelling + // -- for several commits, each internally consistent and fully tested, + // caught only by a whole-branch review because no test compared the two + // surfaces to each other. // // Extracts just the keyword and the comparison token from each surface // -- both "require f >= 473/10 MPa" (render) and "require #1 >= #2 [...]" @@ -866,7 +866,7 @@ TEST_CASE("a derivation renders a Constraint step with two operands when the pre "5. require #1 > #4 [division by zero]\n"); } -// ------------------------------------------------------- phase 10: lookups +// ----------------------------------------------------------------- lookups namespace { @@ -1170,10 +1170,10 @@ TEST_CASE("a derivation names the band a banded lookup's value fell in", "[trace TEST_CASE("a derivation renders a banded miss as a miss, never as a value", "[trace-render][lookup]") { - // Phase 9's `[not checked]` against `[else]` is the precedent: a reader - // must never confuse "no row matched" with "the matched row held zero". - // The bands are named too, because "outside the domain" is not something - // a reader can check without knowing what the domain was. + // A constraint's `[not checked]` against `[else]` is the precedent: a + // reader must never confuse "no row matched" with "the matched row held + // zero". The bands are named too, because "outside the domain" is not + // something a reader can check without knowing what the domain was. CHECK(derivationOf(sizeLookup(), diameterOf(95)) == "1. d = 95 mm\n" "2. lookup(#1) = argument outside the domain of the operation" @@ -1294,8 +1294,8 @@ TEST_CASE("a derivation renders an interpolating miss as outside the curve, not // The other end of the same axis: 2 cm is in the FIRST segment. A suite // that only ever probed the second lets "report the last pair" through in - // silence, exactly as round 1's fixtures let "report the last band" - // through by only ever selecting the middle one. + // silence, exactly as fixtures that only ever selected the middle band + // once let "report the last band" through. CHECK(derivationOf(curveLookup(), diameterOf(20)) == "1. d = 20 mm\n" "2. interpolate(#1) = 11221/480 % [between 139/100 and 331/100 cm]\n"); @@ -1348,12 +1348,12 @@ TEST_CASE("a derivation spells a band's excluded top and a curve's included one TEST_CASE("a derivation spells a lookup the way render() does", "[trace-render][lookup]") { - // `render.hpp` and `trace_render.hpp` compose a band, a key and a head - // name from scratch, independently of each other, and nothing but this - // assertion ties them together. Phase 8 shipped exactly this shape of - // defect for several commits -- two surfaces each internally consistent - // and fully tested, disagreeing with each other -- caught only by a - // whole-branch review because no test compared them. + // `render.hpp` and `trace_render.hpp` compose a band, a key and a head name + // from scratch, independently of each other, and nothing but this assertion + // ties them together. This shape of defect once shipped for several commits + // -- two surfaces each internally consistent and fully tested, disagreeing + // with each other -- caught only by a whole-branch review because no test + // compared them. // // Each surface is compared to the other and never to a literal here, so a // failure shows both actual spellings side by side rather than naming @@ -1625,7 +1625,7 @@ TEST_CASE("a derivation renders a miss against a table that covers nothing at al // --------------------------------------------------------------------------- // Which variant a method selected // -// Spec section 9.1 makes this the phase's acceptance criterion: the trace +// Spec section 9.1 makes this an acceptance criterion: the trace // records which variant fired and on what discriminator. The fixture below is // written so that the name of a variant NOT taken cannot appear in a // derivation by any other route -- no quantity symbol, unit, constant or @@ -1858,7 +1858,7 @@ namespace { // Author text the compile-time rules let through -- a semicolon and a // backslash are refused nowhere -- in a variant's tag and a lookup key's name. -// Final re-review of phase 11, L3: each escaped today, and no test said so. +// Each is escaped, and these tests say so. struct EscapedTag { }; @@ -2013,13 +2013,13 @@ TEST_CASE("a rounding or a numeric value in a unit with no symbol adds no unit c .ends_with("2. numeric(#1, in MPa) = 30 (the fit is stated in MPa)\n")); } -// ---- A series on the trace (phase 12) ---- +// ---- A series on the trace ---- namespace { namespace series_trace { - // The shared fixture of the phase 12 plan: an invented screen analysis. + // The shared series fixture: an invented screen analysis. // Every element differs, and the middle one was not measured. struct Retained: formula::Quantity { @@ -2618,7 +2618,7 @@ TEST_CASE("a per-element rounding records each element's granularity and its mod TEST_CASE("a series and a curve escape their symbols and units, as a scalar step does", "[trace-render][escape][series]") { - // Phase 11's escaping reaches the series lines through `step_line`, the + // The trace's escaping reaches the series lines through `step_line`, the // one entry point: the declared symbol that spells a jurisdiction's // clause, and the author's unit that closes the value's clause, are // escaped on every element, every pair and an interpolation's value. diff --git a/test/trace_tests.cpp b/test/trace_tests.cpp index f58df6a..ea0085e 100644 --- a/test/trace_tests.cpp +++ b/test/trace_tests.cpp @@ -49,7 +49,7 @@ struct Strength: formula::Quantity { formula::Rational { volume } }); } -// The predicate every phase-8 conditional test below shares: strength over an +// The predicate the conditional tests below share: strength over an // invented 473/10 MPa. Kept at namespace scope so the mutation test (further // down) can name its exact type. constexpr auto overThreshold = var > formula::constant(formula::Rational { 473, 10 }); @@ -364,7 +364,7 @@ TEST_CASE("explain returns an empty trace when the result is a manual override", CHECK(explained.trace.steps.size() == 0); } -// --------------------------------------------------------------- phase 8 +// ----------------- rounding, logarithms, numeric values and conditionals TEST_CASE("a Round step records its own declared unit and granularity, and the pre-rounding value stays " "visible on its operand's own step", @@ -745,7 +745,7 @@ TEST_CASE("a trace records one Constraint step per constraint checked via check_ } } -// ------------------------------------------------------- phase 10: lookups +// ----------------------------------------------------------------- lookups namespace { @@ -1805,7 +1805,7 @@ TEST_CASE("a branch told with no when() entered is dropped, never read off an em CHECK(trace.marks.empty()); } -// ---- A series on the trace (phase 12) ---- +// ---- A series on the trace ---- namespace { diff --git a/test/unit_cross_tu.hpp b/test/unit_cross_tu.hpp index e58f598..5dd52e8 100644 --- a/test/unit_cross_tu.hpp +++ b/test/unit_cross_tu.hpp @@ -9,8 +9,8 @@ /// does not link. /// /// Unit nests Symbol (a 16-byte char array) and Bounds inside the NTTP, a -/// strictly richer mangling than Dimension's, and phase 4's Quantity is -/// the consumer that will depend on this holding. +/// strictly richer mangling than Dimension's, and `Quantity<…, Unit>` is the +/// consumer that depends on this holding. #include diff --git a/test/unit_tests.cpp b/test/unit_tests.cpp index 861cc73..123d633 100644 --- a/test/unit_tests.cpp +++ b/test/unit_tests.cpp @@ -89,7 +89,7 @@ static_assert(unit::Celsius.offsetNumerator == 27315 && unit::Celsius.offsetDeno static_assert(unit::Fahrenheit.offsetNumerator == 45967 && unit::Fahrenheit.offsetDenominator == 180); static_assert(unit::Fahrenheit.magnitudeNumerator == 5 && unit::Fahrenheit.magnitudeDenominator == 9); -// ---- a Unit is a template argument, which is what phase 4 needs ---- +// ---- a Unit is a template argument, which is what Quantity needs ---- template struct Measured @@ -997,8 +997,8 @@ TEST_CASE("a unit template argument has the same identity in every translation u // field-by-field rather than named from unit::Litre -- so this linking at // all is the assertion, mirroring dimension_tests.cpp's cross-TU case for // Dimension. Unit nests Symbol and Bounds inside the NTTP, a strictly - // richer mangling than Dimension's, and phase 4's Quantity is the - // consumer that will depend on it. + // richer mangling than Dimension's, and `Quantity<…, Unit>` is the + // consumer that depends on it. constexpr Unit LitreRebuilt { .dimension = dim::Volume, .magnitudeNumerator = 1, .magnitudeDenominator = 1000, diff --git a/test/vocabulary_tests.cpp b/test/vocabulary_tests.cpp index e7fec7b..8902ead 100644 --- a/test/vocabulary_tests.cpp +++ b/test/vocabulary_tests.cpp @@ -436,7 +436,7 @@ TEST_CASE("explain records in the vocabulary it is given", "[vocabulary][trace]" namespace { /// A consumer's own node, rendered through the one-argument extension point -/// every earlier phase published: it knows nothing of vocabularies. +/// the library has always published: it knows nothing of vocabularies. struct Gauge: formula::NodeBase { // Never read: this node is only rendered, never evaluated. @@ -698,7 +698,7 @@ inline constexpr formula::BreakpointTable<3> everySnapSet { formula::breakpoint( * var * var * formula::pi * formula::constant(rat(2)) * formula::exact_lookup(EveryFinish::Rough, { rat(1087, 1000), rat(1249, 1000) }) * formula::snapped(var) - // Phase 13's kinds, added rather than multiplied in: the product + // The statistics kinds, added rather than multiplied in: the product // above leaves too few bits for another factor. + formula::rounded_sqrt( r * var) @@ -1324,7 +1324,7 @@ TEST_CASE("a constraint over the overlaid quantities traces and documents in the CHECK(formula::document(limit, everyVocabulary).formula == "\\text{require } E \\geq R"); } -// ---- A series in two jurisdictions' words (phase 12) ---- +// ---- A series in two jurisdictions' words --------------- namespace { @@ -1393,9 +1393,9 @@ TEST_CASE("elementwise arithmetic is written in the page's vocabulary on every s CHECK(text.find("m_s") == std::string::npos); } -// ---- The join: a series inside a method, under an overlay (phase 12) ---- +// ---- The join: a series inside a method, under an overlay --------------- // -// Phase 11's lesson: two separately verified things do not verify their join. +// Two separately verified things do not verify their join. // `sum` is the first series-holding `Node`, so it is where a series first sits // inside a method's variant; here it is evaluated, rendered, documented and // traced through a method an overlay rewrote, in a jurisdiction's words. diff --git a/tools/gallery/main.cpp b/tools/gallery/main.cpp index a17f0b6..e393734 100644 --- a/tools/gallery/main.cpp +++ b/tools/gallery/main.cpp @@ -520,7 +520,7 @@ void write_symbol_table(std::ofstream& out, std::vector co /// symbol table asks for `Dialect::Markdown` and the display form asks for /// `Dialect::LaTeX`, each the dialect it is actually for, rather than /// assuming today's `collect()` ignores `D` for the symbol table -- an -/// assumption a later phase could quietly invalidate. +/// assumption a later change could quietly invalidate. template void write_formula(std::ofstream& out, N const& node) { From 4c43e37fb29d180fd48e442dd86fc6462c180d33 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Mon, 5 Oct 2026 02:28:07 +0200 Subject: [PATCH 27/35] docs: drop numbered defect-class labels from comments Thirteen comments cited a "defect class" by number, a classification recorded nowhere a reader can look up. Each now says what it relies on: a missing `{}` initialiser points at `Corrections` (lookup.hpp), which gives the measurement, and a comment that already stated the property simply drops the label. A negative test that leaned on "the other defect classes here" names the three files it means. Signed-off-by: Christian Parpart --- include/formula-cpp/conformity.hpp | 4 ++-- include/formula-cpp/curve.hpp | 10 ++++++---- include/formula-cpp/expression.hpp | 2 +- include/formula-cpp/opaque.hpp | 6 +++--- include/formula-cpp/retry.hpp | 8 ++++---- include/formula-cpp/series.hpp | 11 +++++------ test/least_squares_tests.cpp | 2 +- test/negative/band_zero_width.cpp | 4 ++-- 8 files changed, 24 insertions(+), 23 deletions(-) diff --git a/include/formula-cpp/conformity.hpp b/include/formula-cpp/conformity.hpp index c730bb6..ca303ed 100644 --- a/include/formula-cpp/conformity.hpp +++ b/include/formula-cpp/conformity.hpp @@ -190,8 +190,8 @@ namespace detail /// `std::array` of `N` rows, or, when the count is only known at run time, /// with `envelope_from`, which checks it. /// -/// No `{}` default member initialiser, deliberately (defect class 4): an -/// envelope must state its contents. +/// No `{}` default member initialiser, deliberately: an envelope must state +/// its contents. /// /// **`{}` is refused in this library's words**, naming both counts as every /// other wrong count is, by the one rule `Elements` (`series.hpp`) follows diff --git a/include/formula-cpp/curve.hpp b/include/formula-cpp/curve.hpp index b2a59af..a34d8e9 100644 --- a/include/formula-cpp/curve.hpp +++ b/include/formula-cpp/curve.hpp @@ -161,8 +161,8 @@ namespace detail /// The domain series @p D paired with the value series @p V, element by /// element: value i is the curve's value at point i. /// -/// No `{}` initialiser on either series, deliberately (defect class 4): see -/// `Corrections` (`lookup.hpp`). +/// No `{}` initialiser on either series, deliberately: see `Corrections` +/// (`lookup.hpp`). template struct CurveNode: CurveNodeBase { @@ -330,7 +330,8 @@ namespace detail /// Two curves spliced into one: the sorted union of their points, values /// running as @p M says. /// -/// No `{}` initialiser on either curve, deliberately (defect class 4). +/// No `{}` initialiser on either curve, deliberately: see `Corrections` +/// (`lookup.hpp`). template struct SpliceNode: CurveNodeBase { @@ -439,7 +440,8 @@ namespace detail /// The value of the curve @p C at the point @p At evaluates to: one value, and /// so a `Node`. /// -/// No `{}` initialiser on either member, deliberately (defect class 4). +/// No `{}` initialiser on either member, deliberately: see `Corrections` +/// (`lookup.hpp`). template struct InterpolateAlongNode: NodeBase { diff --git a/include/formula-cpp/expression.hpp b/include/formula-cpp/expression.hpp index e532224..2efc2a8 100644 --- a/include/formula-cpp/expression.hpp +++ b/include/formula-cpp/expression.hpp @@ -145,7 +145,7 @@ namespace detail /// leaf, or a node kind that cannot be refused). A node over a refused /// operand asks no question of its own -- the operand's length and /// dimension are stand-ins taken after the refusal, and asking about them - /// would report the one mistake a second time (defect class 2). + /// would report the one mistake a second time. /// /// Declared here, beside `Node`, because every check that reads a node's /// dimension asks it: a series refused already (`series.hpp`), a curve diff --git a/include/formula-cpp/opaque.hpp b/include/formula-cpp/opaque.hpp index f018987..10eaaec 100644 --- a/include/formula-cpp/opaque.hpp +++ b/include/formula-cpp/opaque.hpp @@ -797,8 +797,7 @@ struct OpaqueCall using operation = Op; /// The inputs, in the order the operation declares them. No `{}` - /// initialiser, deliberately (defect class 4): see `Corrections` - /// (`lookup.hpp`). + /// initialiser, deliberately: see `Corrections` (`lookup.hpp`). std::tuple inputs; /// Why the method uses the operation here: required by `opaque()`, and @@ -951,7 +950,8 @@ namespace detail /// @p Origin is `detail::`, and says whether `opaque_output` found the name /// (see `detail::UnnamedOpaqueOutput`); leave it to its default. /// -/// No `{}` initialiser on `call`, deliberately (defect class 4). +/// No `{}` initialiser on `call`, deliberately: see `Corrections` +/// (`lookup.hpp`). template struct OpaqueOutputNode: NodeBase { diff --git a/include/formula-cpp/retry.hpp b/include/formula-cpp/retry.hpp index 6de0e57..98e9da3 100644 --- a/include/formula-cpp/retry.hpp +++ b/include/formula-cpp/retry.hpp @@ -150,8 +150,8 @@ inline constexpr bool formats_by_describe = true; inline constexpr std::size_t retryAttemptCap = 64; /// Attempt 0's value: what `previous_attempt` reads at the first attempt. -/// No `{}` initialiser on the expression, deliberately (defect class 4): see -/// `Corrections` (`lookup.hpp`). +/// No `{}` initialiser on the expression, deliberately: see `Corrections` +/// (`lookup.hpp`). template struct StartingValue { @@ -1064,7 +1064,7 @@ namespace detail /// class body, so one built as an aggregate is checked too. Not a `Node`. /// /// No `{}` initialiser on the start, the attempt or the acceptance, -/// deliberately (defect class 4): see `Corrections` (`lookup.hpp`). +/// deliberately: see `Corrections` (`lookup.hpp`). template struct Retry { @@ -1220,7 +1220,7 @@ namespace detail } // namespace detail /// How a retry ended, and in what. Built only by `checked_evaluate_retry`: no /// public constructor and no setters, so how it ended and where it was -/// accepted are the library's to state (defect class 3). +/// accepted are the library's to state. template class RetryOutcome { diff --git a/include/formula-cpp/series.hpp b/include/formula-cpp/series.hpp index 89e8417..3f3c9de 100644 --- a/include/formula-cpp/series.hpp +++ b/include/formula-cpp/series.hpp @@ -131,8 +131,8 @@ namespace detail /// (`lookup.hpp`). `series_constant` with no values is refused in words /// already, so no author's spelling reaches the compiler's. /// -/// No `{}` default member initialiser, deliberately (defect class 4): a -/// constant must state its contents. +/// No `{}` default member initialiser, deliberately: a constant must state +/// its contents. template struct Elements { @@ -658,10 +658,9 @@ namespace detail /// What a refused `cumulative` of a single value stands for: a series, /// already refused (`refused`), so that nothing built over it -- a `sum`, /// another `cumulative`, an elementwise operator, `checked_evaluate` or - /// `checked_evaluate_series` -- reports the one mistake a second time - /// (defect class 2). It evaluates to a `DomainError` with no position and - /// tells no sink: a program holding one never compiles, so neither is - /// ever seen. + /// `checked_evaluate_series` -- reports the one mistake a second time. It + /// evaluates to a `DomainError` with no position and tells no sink: a + /// program holding one never compiles, so neither is ever seen. template struct RefusedSeries: SeriesNodeBase { diff --git a/test/least_squares_tests.cpp b/test/least_squares_tests.cpp index 7f20d57..60b38d9 100644 --- a/test/least_squares_tests.cpp +++ b/test/least_squares_tests.cpp @@ -105,7 +105,7 @@ TEST_CASE("the order the points are listed in does not change the fit", "[least- { // Through compute directly: a curve refuses a domain listed out of order // as its own NotAscending failure. Same four pairs, listed - // 4, 1, 7, 2 s: identical coefficients (defect class 6). + // 4, 1, 7, 2 s: identical coefficients. constexpr std::array shuffledTimes { rat(4), rat(1), rat(7), rat(2) }; constexpr std::array shuffledLengths { rat(121, 10'000), rat(102, 10'000), rat(143, 10'000), rat(109, 10'000) diff --git a/test/negative/band_zero_width.cpp b/test/negative/band_zero_width.cpp index ab640c6..6306c80 100644 --- a/test/negative/band_zero_width.cpp +++ b/test/negative/band_zero_width.cpp @@ -8,8 +8,8 @@ // as an inverted band -- and, like an inverted band, every adjacent pair // still shares its boundary exactly (103 == 103, 103 == 103), so only // well-formedness catches it, not gap/overlap checking. The zero-width band -// sits in the middle, matching how the other defect classes here place -// theirs. This must not compile. +// sits in the middle, matching how band_gap.cpp, band_overlap.cpp and +// band_inverted.cpp place theirs. This must not compile. #include namespace From 833a1c5813837581e88c903f49caaf36fa65bc87 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Mon, 5 Oct 2026 02:40:27 +0200 Subject: [PATCH 28/35] docs: correct comments that named tasks or claimed what the code does not do Comments in the lookup header and the tests still referred to tasks, task reports and reviewers, and to work owed by "a later task" that has since been done: a lookup's step now records its key, the interval its table covers and why it failed, and the lookup comments say so. A reference to a report or a reviewer is dropped. Two claims were untrue. describe(ArithmeticError) is what std::format and the trace's lines write, not an evaluation result's invalid arm, and snapping does not call checked_round_to_multiple. The fixture names in the record statistics tests now name the files that define them. Signed-off-by: Christian Parpart --- include/formula-cpp/error.hpp | 4 +- include/formula-cpp/lookup.hpp | 100 ++++++++++----------- include/formula-cpp/render.hpp | 4 +- include/formula-cpp/rounding.hpp | 3 +- test/CMakeLists.txt | 3 +- test/constraint_tests.cpp | 2 +- test/join_tests.cpp | 6 +- test/lookup_tests.cpp | 15 ++-- test/measured_tests.cpp | 7 +- test/negative/method_duplicate_tag.cpp | 2 +- test/negative/method_variants_disagree.cpp | 5 +- test/overflow_census_tests.cpp | 5 +- test/record_join_tests.cpp | 4 +- test/record_statistics_tests.cpp | 19 ++-- test/render_tests.cpp | 2 +- test/series_tests.cpp | 6 +- 16 files changed, 91 insertions(+), 96 deletions(-) diff --git a/include/formula-cpp/error.hpp b/include/formula-cpp/error.hpp index 2b7b998..9ca4136 100644 --- a/include/formula-cpp/error.hpp +++ b/include/formula-cpp/error.hpp @@ -43,8 +43,8 @@ enum class ArithmeticError : std::uint8_t }; /// A lowercase noun phrase with no trailing punctuation, so callers can embed it -/// in a longer sentence. An evaluation result's `invalid` arm is written with -/// it. +/// in a longer sentence. `std::format` (`format.hpp`) and the trace's lines +/// (`trace_render.hpp`) write an error with it. [[nodiscard]] constexpr std::string_view describe(ArithmeticError cause) noexcept { switch (cause) diff --git a/include/formula-cpp/lookup.hpp b/include/formula-cpp/lookup.hpp index 0390b52..aefe5d6 100644 --- a/include/formula-cpp/lookup.hpp +++ b/include/formula-cpp/lookup.hpp @@ -18,9 +18,9 @@ /// All three report a miss identically, split structure from contents /// identically, and share one `Corrections` wrapper and one key-unit guard. /// Everything below about a miss, about `documented()` carrying a table's -/// identity, and about what is left to a later task is written once and binds -/// all three; the exact-lookup and interpolating-lookup sections near the end -/// of this comment add only what is genuinely particular to each. +/// identity, and about what is deliberately not built is written once and +/// binds all three; the exact-lookup and interpolating-lookup sections near +/// the end of this comment add only what is genuinely particular to each. /// /// A method's own algebra sometimes needs a coefficient no formula computes -- /// a size-correction factor for a specimen's diameter, say -- that the @@ -108,22 +108,22 @@ /// `ArithmeticError` already does, with no new plumbing. /// /// **What "structured fields, never a composed sentence" means concretely -/// here, and what is deliberately left to a later task.** The value that -/// missed and the unit it is stated in are never lost -- they are the -/// operand's own evaluated result, sitting with whichever caller dispatched -/// it (and, once a lookup node is taught to a `RecordingSink`, recoverable -/// from the operand's own step exactly as any other value is). The table's +/// here.** The value that missed and the unit it is stated in are never +/// lost -- they are the operand's own evaluated result, sitting with +/// whichever caller dispatched it (and, under a `RecordingSink`, +/// recoverable from the operand's own step exactly as any other value +/// is). The table's /// identity is available the same way every other node's provenance is /// available in this library: wrap the lookup in `documented(...)` /// (`citation.hpp`), which already carries a title, a reference, a section /// and a full text as separate fields -- never a composed sentence -- and /// already composes with any `Node`, lookups included, with no change needed -/// here. Actually *rendering* a miss's structured fields into prose -- -/// giving `Step` a `StepKind::BandedLookup` and reading `KeyUnit`, `bands` -/// and a wrapping `Citation` back out the way `trace_render.hpp` already -/// does for every other kind -- is `trace.hpp`/`trace_render.hpp` work, and -/// is deliberately outside this task's own file list; nothing here forecloses -/// it, and nothing here composes a sentence that would make it harder. +/// here. *Rendering* a miss's structured fields into prose -- the step's +/// `StepKind::BandedLookup`, the interval the table covers +/// (`Step::coveredRange`) and why it failed (`Step::lookupFailure`), read +/// back out by `trace_render.hpp` as it does for every other kind -- is +/// `trace.hpp`/`trace_render.hpp` work, not this file's; nothing here +/// composes a sentence that would make it harder. /// /// **Bands are half-open, `[low, high)`, exactly as `band.hpp` declares them -- /// see `band.hpp`'s file comment.** A value sitting exactly on a shared @@ -145,11 +145,11 @@ /// representation a consumer teaches it: `RepTraits` is a documented public /// extension point (`evaluate.hpp`), and a `RepBandSelection` seam /// mirroring `RepRounding` would be the way to open the same door here. -/// **Deliberately not built in this task** -- it is additive and this task -/// should not absorb it -- so today every representation but `Rational` is -/// closed, full stop, until that seam exists. `checked_evaluate` -- -/// the entry point every test in this file uses -- always computes in -/// `Rational` internally, so this restriction is never reached from there. +/// **Deliberately not built** -- it is additive -- so today every +/// representation but `Rational` is closed, full stop, until that seam +/// exists. `checked_evaluate` -- the entry point every test +/// in this file uses -- always computes in `Rational` internally, so this +/// restriction is never reached from there. /// /// =========================================================================== /// @@ -262,11 +262,11 @@ /// aggregate, so that is one build per specimen, not one evaluation per /// specimen. **Giving `Environment` a categorical entry, so that a key could /// be supplied alongside the measurements, is deliberately not done here**: -/// it is a change to `environment.hpp`, outside this task's files. Nothing +/// it would be a change to `environment.hpp`, not to this file. Nothing /// here forecloses it -- `Environment`'s `detail::EntryTraits` is an open /// specialisation point, and this node's `checked_evaluate_si` already takes /// the environment -- but it is **a second node kind, not a field swap on -/// this one**, and a later task should plan for that rather than the easier +/// this one**, and should be planned as one rather than as the easier /// version. The reason is `key`'s own comment below: `KeyOf{}` is a /// legitimate key that hits a row, so `ExactLookupNode` has no spelling for /// "no key yet, take it from the environment". Whatever reads a key from an @@ -301,23 +301,23 @@ /// `checked_evaluate` always computes in `Rational`, so this is never /// reached from the entry point every test here uses. /// -/// **What a later task is owed, stated because the error channel cannot say -/// it.** A miss carries `DomainError` and nothing more, so "which key missed -/// which table" has to be rendered from the trace -- and for the exact lookup -/// that is a harder obligation than for the banded one. A banded miss still -/// leaves its evidence in the tree: the value that missed is the operand's -/// own evaluated result, and once a lookup node is taught to a -/// `RecordingSink` the operand contributes a step of its own carrying that -/// value. **An exact lookup has no operand**, so the key that missed appears -/// in no step at all unless `ExactLookupNode`'s own step records it. Nothing -/// here loses the key -- it is a plain data member of the node the sink is -/// handed, readable as `node.key`, and `Keys` is a compile-time property of -/// the node's type -- but recovering it *does* require the later task to add -/// a field for it, where the banded case can lean on a step that already -/// exists. `detail::StepKindOf` (`trace.hpp`) has a specialisation for -/// neither lookup node today, so both are equally untraceable right now; the -/// asymmetry is written down here so the later task does not discover it -/// after designing for the banded case alone. +/// **Where a missed key is recorded, stated because the error channel cannot +/// say it.** A miss carries `DomainError` and nothing more, so "which key +/// missed which table" has to be rendered from the trace -- and for the exact +/// lookup that asks more of the trace than the banded one does. A banded +/// miss leaves its evidence in the tree: the value that missed is the +/// operand's own evaluated result, and under a `RecordingSink` the operand +/// contributes a step of its own carrying that value. **An exact lookup has +/// no operand**, so the key that missed would appear in no step at all if +/// `ExactLookupNode`'s own step did not record it. It does: the key is a +/// plain data member of the node the sink is handed, readable as +/// `node.key`, and the step keeps its value in `Step::lookupKey` and its +/// name, when it has one, in `Step::lookupKeyName` (`trace.hpp`). A key that +/// names no row has no name there, and its value is all that is left of it. +/// The banded case needs no such field, because it leans on a step that +/// already exists. The asymmetry is written down here so that a change to +/// either kind does not assume the other records its miss the same way, +/// or that the banded case's way would serve the exact one. /// /// =========================================================================== /// @@ -453,22 +453,22 @@ /// node's ground and one of its own that is stronger: locating the segment is /// the same comparison band selection is, and the answer is then *computed*, so /// a representation that rounds would hand back a number that is not the one -/// the table's own rows imply -- the precise defect this task exists to avoid. +/// the table's own rows imply -- the precise defect this node exists to avoid. /// As with both other kinds the message says "this representation" rather than /// naming a type the instantiation backtrace already names, and no /// `RepInterpolation` seam is built, mirroring the decision not to build /// `RepBandSelection`. /// -/// **What a later task is owed.** Everything the banded lookup's own note above -/// says applies unchanged: a miss carries `DomainError` and nothing else, the -/// value that missed is the operand's own evaluated result, and the table's -/// identity comes from `documented()`. One thing is new, and belongs to the interpolating lookup -/// rather than here: this node can produce `ArithmeticError::Overflow` *of its -/// own*, from the interpolation, where the other two kinds only ever propagate -/// one they were handed. A trace that wants to say "the interpolation -/// overflowed" rather than "something below this overflowed" needs this node's -/// own step to say so; nothing here loses the information, and nothing here -/// composes a sentence that would make saying it harder. +/// **What the trace needs from this node.** Everything the banded lookup's +/// own note above says applies unchanged: a miss carries `DomainError` and +/// nothing else, the value that missed is the operand's own evaluated result, +/// and the table's identity comes from `documented()`. One thing is new, and +/// belongs to the interpolating lookup alone: this node can produce +/// `ArithmeticError::Overflow` *of its own*, from the interpolation, where the +/// other two kinds only ever propagate one they were handed. A trace that says +/// "the interpolation overflowed" rather than "something below this +/// overflowed" needs this node's own step to say so, and it does: +/// `Step::lookupFailure` is `LookupFailure::Computation` (`trace.hpp`). #include #include @@ -1188,7 +1188,7 @@ struct ExactLookupNode: NodeBase /// file comment gives at length; a key that names no row of `keys` is a /// miss, reported exactly as a value falling in no band is. /// - /// **There is no unset state, and a later task must not assume one.** The + /// **There is no unset state, and nothing may assume one.** The /// default member initialiser is `KeyOf{}` -- the enumerator whose /// value is zero -- which for the ordinary table is a perfectly legitimate /// key that hits a row. It does not mean "no key yet" and cannot be made diff --git a/include/formula-cpp/render.hpp b/include/formula-cpp/render.hpp index 25ecb01..913c164 100644 --- a/include/formula-cpp/render.hpp +++ b/include/formula-cpp/render.hpp @@ -1638,8 +1638,8 @@ template /// A sample's variance renders as a call on its sample, /// `sample_variance(m(i))`, and in LaTeX as `s^{2}({m}_{i})`, a spelling -/// measured to typeset clean under MathJax and tectonic. The variance is one -/// value and carries no series marker; its sample carries its own. +/// measured to typeset clean. The variance is one value and carries no series +/// marker; its sample carries its own. template [[nodiscard]] std::string render_node(SampleVarianceNode const& node, V const& vocabulary) { diff --git a/include/formula-cpp/rounding.hpp b/include/formula-cpp/rounding.hpp index 62abbf9..76f201e 100644 --- a/include/formula-cpp/rounding.hpp +++ b/include/formula-cpp/rounding.hpp @@ -179,8 +179,7 @@ struct SignificantDigits /// Rounds `unrounded` to the nearest multiple of `increment` under `roundingMode`. /// /// This is the primitive the decimal-place and significant-digit forms are built -/// on, and it is also what snapping a computed sieve size onto a standard sieve -/// series needs (`snap.hpp`). +/// on. /// /// @pre `increment` is strictly positive; otherwise DomainError. [[nodiscard]] constexpr std::expected checked_round_to_multiple( diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 78edfc1..6c10111 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -1471,8 +1471,7 @@ formula_add_negative_test(interpolating_lookup_malformed_breakpoint # # The guard lives in `Variants`'s class body rather than in `variants()`, so # this one case covers the no-factory route too -- declaring a `Variants<...>` -# directly completes the same class. That is measured, not assumed; the task -# report records the build that showed it. +# directly completes the same class. That is measured, not assumed. formula_add_negative_test(method_variants_disagree "formula: two variants of this method measure different dimensions") diff --git a/test/constraint_tests.cpp b/test/constraint_tests.cpp index 6308eb3..69697ee 100644 --- a/test/constraint_tests.cpp +++ b/test/constraint_tests.cpp @@ -85,7 +85,7 @@ TEST_CASE("constraint: an unmeasured input is not checked, and is never satisfie { constexpr auto outcome = formula::check(minimumStrength, nothingMeasured()); STATIC_REQUIRE(outcome.is_not_checked()); - STATIC_REQUIRE(!outcome.is_satisfied()); // the property this whole task exists for + STATIC_REQUIRE(!outcome.is_satisfied()); // the property constraints exist for STATIC_REQUIRE(!outcome.is_violated()); // and it is not a failure either } diff --git a/test/join_tests.cpp b/test/join_tests.cpp index cfe682a..b1d1e05 100644 --- a/test/join_tests.cpp +++ b/test/join_tests.cpp @@ -1,9 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 // // The join: an overlaid method, its selected variant, and a jurisdiction's -// vocabulary, together. Each was verified on its own by the task that built -// it; this file checks them combined, in one method that uses every overlay -// operation, and across two translation units (`method_cross_tu.hpp`). +// vocabulary, together. Each has tests of its own; this file checks them +// combined, in one method that uses every overlay operation, and across two +// translation units (`method_cross_tu.hpp`). #include "method_cross_tu.hpp" #include diff --git a/test/lookup_tests.cpp b/test/lookup_tests.cpp index e36e63d..9b6a577 100644 --- a/test/lookup_tests.cpp +++ b/test/lookup_tests.cpp @@ -155,8 +155,7 @@ TEST_CASE("a value below the lowest band is reported as a miss, not a value", "[ { // -5 mm == -0.5 cm, below SizeBands[0]'s low bound (0). A lazy // implementation that clamped to the nearest band would answer with the - // first band's correction (863/1000) here instead of missing -- see the - // mutation in this task's report. + // first band's correction (863/1000) here instead of missing. constexpr auto computed = formula::checked_evaluate(lookup(), millimetresOfDiameter(-5)); STATIC_REQUIRE(!computed.has_value()); STATIC_REQUIRE(computed.error() == formula::ArithmeticError::DomainError); @@ -718,12 +717,12 @@ TEST_CASE("an interpolated value stays exact, even when no finite decimal could // percent is converted away -- 0.868333... in decimal, a number no // rounding of any fixed precision holds exactly. // - // This is this task's exactness question, asserted rather than asserted - // about: the answer is the exact rational the two rows imply, and nothing - // anywhere rounded it to get there. The denominator is checked as well as - // the value, because an implementation that computed in a fixed decimal - // precision could still compare equal to a rounded literal while having - // thrown the remainder away. + // This is the interpolating lookup's exactness question, asserted rather + // than asserted about: the answer is the exact rational the two rows imply, + // and nothing anywhere rounded it to get there. The denominator is checked + // as well as the value, because an implementation that computed in a fixed + // decimal precision could still compare equal to a rounded literal while + // having thrown the remainder away. constexpr auto computed = formula::checked_evaluate(curve(), millimetresOfDiameter(137, 30)); STATIC_REQUIRE(computed.has_value()); STATIC_REQUIRE(computed->is_value()); diff --git a/test/measured_tests.cpp b/test/measured_tests.cpp index 51ac646..aa89147 100644 --- a/test/measured_tests.cpp +++ b/test/measured_tests.cpp @@ -124,8 +124,8 @@ TEST_CASE("a measurement carries its quantity's own metadata", "[measured]") { // All four readers, on two different quantities. Checking one reader against // one quantity is not enough: a reader hardwired to return WaterVolume's - // answer would satisfy that, and two of these were reachable by no test at - // all until the task review mutated them and the suite stayed green. + // answer would satisfy that, and two of these were once reachable by no + // test at all: mutating them left the suite green. CHECK(Measured::quantity_unit() == unit::Litre); CHECK(Measured::quantity_symbol() == std::string_view { "V_w" }); CHECK(Measured::quantity_description() == std::string_view { "volume of water added" }); @@ -210,8 +210,7 @@ namespace /// A quantity whose unit DOES declare bounds. No shipped `unit::` constant has /// any, so without this the absent-in-a-bounded-unit case cannot be written -- /// and that is the one case where `NotMeasured` and a real verdict actually -/// compete. The task reviewer had to build this locally to check it; it belongs -/// in the suite. +/// compete. inline constexpr formula::Unit BoundedGauge { .dimension = formula::dim::Scalar, .magnitudeNumerator = 1, .magnitudeDenominator = 100, diff --git a/test/negative/method_duplicate_tag.cpp b/test/negative/method_duplicate_tag.cpp index 85cd5c6..3bb1de4 100644 --- a/test/negative/method_duplicate_tag.cpp +++ b/test/negative/method_duplicate_tag.cpp @@ -6,7 +6,7 @@ // and this placement is what tells that apart from its three likeliest // narrowings -- the first pair only (0,1), the last pair only (3,4), and // neighbours only -- each of which finds no repeat here and lets the pack -// compile. One case kills all three; the task report records each. +// compile. One case kills all three. // // The five variants agree in dimension, so the agreement rule has nothing to // say, and every tag is a plain class type, so the tag rule has nothing diff --git a/test/negative/method_variants_disagree.cpp b/test/negative/method_variants_disagree.cpp index 14bff88..351bf3d 100644 --- a/test/negative/method_variants_disagree.cpp +++ b/test/negative/method_variants_disagree.cpp @@ -16,9 +16,8 @@ // rest still refuses it. Measured on cl 19.51 against exactly that fixture: // the mutation survived, the test passed, and the defect would have shipped. // -// Third of four lands in (0,2), between (0,1) and (0,3). Both mutations are -// measured in the task report: each makes this file compile, and each is -// caught. +// Third of four lands in (0,2), between (0,1) and (0,3). Both mutations were +// measured: each makes this file compile, and so each is caught. // // The three variants that DO agree are deliberately different types from one // another, so this file does not quietly assume a pack's agreeing members diff --git a/test/overflow_census_tests.cpp b/test/overflow_census_tests.cpp index f865886..ee9b9e6 100644 --- a/test/overflow_census_tests.cpp +++ b/test/overflow_census_tests.cpp @@ -228,7 +228,7 @@ template return formula::checked_evaluate(spread, series_environment(values)).has_value(); } -// ---- The norm-shaped cases, written for this task, numbers invented ------------- +// ---- The norm-shaped cases, numbers invented ------------------------------------ // Twenty masses at 3 decimal places of g, near 40 g. std::array const twentyMasses { @@ -320,8 +320,7 @@ template // ---- Least squares: three data shapes --------------------------------------------- // Point k of each shape, in coherent SI -- seconds and newtons -- so the fit -// sees exactly these numbers. Invented: the two decimal shapes' offsets are -// primes. +// sees exactly these numbers. Invented. struct FitPoint { Rational x; diff --git a/test/record_join_tests.cpp b/test/record_join_tests.cpp index aac409c..7396510 100644 --- a/test/record_join_tests.cpp +++ b/test/record_join_tests.cpp @@ -2,8 +2,8 @@ // // The join: an overlaid method that reads from another record, evaluated // through a context and a renaming vocabulary, traced, documented, checked, -// and across two translation units (`record_cross_tu.hpp`). Each part was -// verified on its own in earlier tasks; this file verifies that they compose. +// and across two translation units (`record_cross_tu.hpp`). Each part has +// tests of its own; this file verifies that they compose. #include "record_cross_tu.hpp" #include diff --git a/test/record_statistics_tests.cpp b/test/record_statistics_tests.cpp index 578f9da..9f62c96 100644 --- a/test/record_statistics_tests.cpp +++ b/test/record_statistics_tests.cpp @@ -46,9 +46,10 @@ constexpr formula::Measured grams(formula::Rational value) return formula::Measured { value }; } -// The reference holds the statistics fixture A, 40.2, 39.8, 40.5, 44.0, 40.0 and -// 43.3 g, and fixture P's pair, 40 g and 40.905 g. This record holds other -// values of each, so a step read from the wrong record gives another number. +// The reference holds `rejection_tests.cpp`'s fixture A, 40.2, 39.8, 40.5, +// 44.0, 40.0 and 43.3 g, and `precision_tests.cpp`'s fixture P's pair, 40 g +// and 40.905 g. This record holds other values of each, so a step read from +// the wrong record gives another number. inline constexpr auto here = formula::environment( formula::measured_series(grams(rat(41)), grams(rat(41)), grams(rat(41)), grams(rat(41)), grams(rat(41)), grams(rat(41))), @@ -100,10 +101,10 @@ TEST_CASE("a statistic read from another record is stamped with that record", "[ TEST_CASE("an outlier rejection read from another record is stamped with that record, pass by pass", "[record-statistics]") { - // The statistics fixture A under a 6 % deviation from each pass's mean: pass - // 1 rejects 44.0 g, pass 2 rejects 43.3 g, pass 3 settles at 321/8 g. - // Every pass, rejection and verdict is a step `push_rejection_step` - // records, and each is the reference's. + // `rejection_tests.cpp`'s fixture A under a 6 % deviation from each pass's + // mean: pass 1 rejects 44.0 g, pass 2 rejects 43.3 g, pass 3 settles at + // 321/8 g. Every pass, rejection and verdict is a step + // `push_rejection_step` records, and each is the reference's. constexpr auto survivorsThere = formula::from_record(formula::sample_mean( formula::without_outliers, formula::KeepAtLeast<4>>( @@ -124,8 +125,8 @@ TEST_CASE("an outlier rejection read from another record is stamped with that re TEST_CASE("a precision limit read from another record is stamped with that record, its level included", "[record-statistics]") { - // The statistics fixture P: the level is the pair's mean, 40.4525 g, and r = - // 0.1 g + level / 50 = 0.90905 g. The level's first pass is a step + // `precision_tests.cpp`'s fixture P: the level is the pair's mean, + // 40.4525 g, and r = 0.1 g + level / 50 = 0.90905 g. The level's first pass is a step // `precision_level_produced` records, and it is the reference's. constexpr auto limitThere = formula::from_record(formula::precision_limit( (var + var) / rat(2), diff --git a/test/render_tests.cpp b/test/render_tests.cpp index 3ccff15..5c3b4ee 100644 --- a/test/render_tests.cpp +++ b/test/render_tests.cpp @@ -683,7 +683,7 @@ TEST_CASE("render: a conditional inside a power keeps its bracket", "[render][co TEST_CASE("render: a conditional inside a product keeps its bracket", "[render][conditional]") { - // The exact scenario named in the task: when(p, a, b) * 2 must not read + // The exact scenario this guards: when(p, a, b) * 2 must not read // as when(p, a, b * 2), which is a different formula. constexpr auto overThreshold = var > formula::constant(rat(473, 10)); constexpr auto chosen = formula::when(overThreshold, var * rat(2), var * rat(4)); diff --git a/test/series_tests.cpp b/test/series_tests.cpp index af7027d..376fdf6 100644 --- a/test/series_tests.cpp +++ b/test/series_tests.cpp @@ -249,9 +249,9 @@ TEST_CASE("an element that cannot be written back in the declared unit names tha TEST_CASE("a failure's position is optional, so a failure of no element can say so", "[series]") { - // A shape pin, not behavioural coverage: nothing in this task produces a - // failure of no element (a series variable fails only at an element). - // Later reductions do (a sum's overflow), and their tests supply the + // A shape pin, not behavioural coverage: a series variable fails only at + // an element, so no case above produces a failure of no element. + // Reductions do (a sum's overflow), and their tests supply the // behaviour. This line fails if the position reverts to a plain size_t, // which would force such a failure to name element 0. STATIC_REQUIRE(std::is_same_v>); From d4da380d4f73adb38587843d1e7c57a90c0b6fd2 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Mon, 5 Oct 2026 02:40:27 +0200 Subject: [PATCH 29/35] docs: name the 0.1.0 changelog's groups after their features The released 0.1.0 entries were grouped under "Phase N:" headings that named development stages. Each heading now names only the feature it groups; every entry and the release structure are unchanged. Signed-off-by: Christian Parpart --- CHANGELOG.md | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c7ec87..6d6b7d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -542,45 +542,45 @@ lineage, opaque operations such as least squares, and bounded retry. ### Added -**Phase 1: a consumable project.** Apache-2.0 licensing, an `INTERFACE` CMake target with install +**A consumable project.** Apache-2.0 licensing, an `INTERFACE` CMake target with install and export, presets for `cl`, `clang-cl`, Clang and GCC, Catch2 through a pinned CPM bootstrap, a must-not-compile harness that asserts both that a build fails and that it fails with the library's own message, and CI that installs the library and builds a consumer against it. -**Phase 2: exact numbers.** An exact rational type with checked arithmetic that reports overflow +**Exact numbers.** An exact rational type with checked arithmetic that reports overflow rather than wrapping, and rounding to decimal places, significant digits and multiples under named rounding modes. -**Phase 3: dimensions and units.** Dimensions with rational exponents, units with exact conversion +**Dimensions and units.** Dimensions with rational exponents, units with exact conversion between them, and each unit's decimals and bounds. -**Phase 4: quantities.** Quantities declared once with their symbol, description and unit, read +**Quantities.** Quantities declared once with their symbol, description and unit, read through `Describe`, and measurements that may be absent without being an error. -**Phase 5: formulas.** An expression layer with the arithmetic operators, powers and roots, +**Formulas.** An expression layer with the arithmetic operators, powers and roots, environments of measured values, and evaluation into an `Outcome` that holds a value, a verdict, an invalid result or nothing, never a bare number that hides which. -**Phase 6: citations and documentation.** `documented()` citations on any part of a formula, +**Citations and documentation.** `documented()` citations on any part of a formula, rendering in plain text, Markdown and LaTeX, generated documentation with a symbol table, a MkDocs and Doxygen site, and a formula gallery produced by running the library. -**Phase 7: tracing.** Composable sinks, a trace that records every step of an evaluation, and +**Tracing.** Composable sinks, a trace that records every step of an evaluation, and bounded rendering of it as an audit trail a person can check. -**Phase 8: rounding, conditionals and an escape hatch.** Rounding as a node of a formula, +**Rounding, conditionals and an escape hatch.** Rounding as a node of a formula, `when()` conditionals, and `numeric_value_of`, which takes a number out of its unit only with a stated justification that the trace records. -**Phase 9: constraints.** Constraints as peers of formulas, with verdicts recorded as trace steps, +**Constraints.** Constraints as peers of formulas, with verdicts recorded as trace steps, and checking a whole set without stopping at the first failure. -**Phase 10: lookup tables.** Exact, banded and interpolating lookups over tables validated at +**Lookup tables.** Exact, banded and interpolating lookups over tables validated at compile time for gaps, overlaps and order, each traced with the row it used or the reason it found none. Lookup keys are shown by their enumerator names, or by an author's own spelling through `EnumeratorName`. -**Phase 11: methods and jurisdiction overlays.** A method holds variants chosen by tag, a rounding +**Methods and jurisdiction overlays.** A method holds variants chosen by tag, a rounding rule and constraints; `evaluate_method` and `check_method` evaluate and check it, and the trace names the variant that ran and its position in the method as published. An overlay pins or prunes variants, fixes a quantity with `with_constant`, defines one with `add_derived`, replaces a @@ -591,7 +591,7 @@ generated documentation, and an overlay that would silently do nothing is refuse the later one holding. A vocabulary renders a formula and its trace in a jurisdiction's own symbols, and `TagName` spells a variant's tag. -**Phase 12: series.** A quantity measured at every point of a method's domain, `series`, +**Series.** A quantity measured at every point of a method's domain, `series`, with each element absent or present on its own; elementwise arithmetic with a broadcast scalar, per-element constants, running totals from either end, `sum`, and per-element rounding. A failure names its element. Conformity judges each element against its own row of a limit envelope, closed @@ -613,7 +613,7 @@ a conformity check -- is evaluated with `Rational` only, and refuses any other ` time. A new guide, *Series and grading curves*, works a screen analysis through all of it, and the gallery gains a series, a grading curve and a binning. -**Phase 13: statistics.** A sample of determinations reduced to one value: `sample_count`, +**Statistics.** A sample of determinations reduced to one value: `sample_count`, `sample_mean`, `sample_variance` (over n - 1, in two passes) and `sample_range`, strict about absence, exact, and naming the determination at which an overflow happened. `rounded_sqrt` rounds a square root exactly to a declared granularity, so a standard deviation is the correctly rounded @@ -633,7 +633,7 @@ observations actually made. None made count 0 and have no mean. leave 30 bits or more, but a sample variance of masses read to 0.01 mg leaves 4, and read to 1 µg it overflows on 423 of 1,000 samples -- which recommends 128-bit intermediates. -**Phase 14: other samples and other tests.** A formula reads from a record other than the one +**Other samples and other tests.** A formula reads from a record other than the one being evaluated through `from_record(expression)`: one value, or a computation over the other specimen's own measurements. A role is a type the author declares and a record is data -- a sample and test key, an environment and lineage keys -- held by role in a `record_context`, @@ -656,7 +656,7 @@ observations read inside a read from another record are traced with that record, own row on the page; a read whose value would be a whole series is refused, and reduced inside instead: `from_record(sum(series))`. -**Phase 15: opaque operations and bounded retry.** An opaque operation is a named computation a +**Opaque operations and bounded retry.** An opaque operation is a named computation a method relies on but does not spell out: a type the author declares with its name, the shape of each input, a name for each output and what each measures, and a `compute` that receives the inputs' values -- never the environment -- and returns the outputs. `opaque(citation, inputs...)` From cbedb661c31c3e50348932c3a502a0cf66294761 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Mon, 5 Oct 2026 02:44:55 +0200 Subject: [PATCH 30/35] docs: rewrap two comments left uneven by the label rewrites Signed-off-by: Christian Parpart --- include/formula-cpp/lookup.hpp | 30 +++++++++++++++--------------- test/record_statistics_tests.cpp | 5 +++-- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/include/formula-cpp/lookup.hpp b/include/formula-cpp/lookup.hpp index aefe5d6..23d2bda 100644 --- a/include/formula-cpp/lookup.hpp +++ b/include/formula-cpp/lookup.hpp @@ -108,22 +108,22 @@ /// `ArithmeticError` already does, with no new plumbing. /// /// **What "structured fields, never a composed sentence" means concretely -/// here.** The value that missed and the unit it is stated in are never -/// lost -- they are the operand's own evaluated result, sitting with -/// whichever caller dispatched it (and, under a `RecordingSink`, -/// recoverable from the operand's own step exactly as any other value -/// is). The table's -/// identity is available the same way every other node's provenance is -/// available in this library: wrap the lookup in `documented(...)` -/// (`citation.hpp`), which already carries a title, a reference, a section -/// and a full text as separate fields -- never a composed sentence -- and -/// already composes with any `Node`, lookups included, with no change needed -/// here. *Rendering* a miss's structured fields into prose -- the step's +/// here.** The value that missed and the unit it is stated in are never lost -- +/// they are the operand's own evaluated result, sitting with whichever caller +/// dispatched it (and, under a `RecordingSink`, recoverable from the operand's +/// own step exactly as any other value is). The table's identity is available +/// the same way every other node's provenance is available in this library: +/// wrap the lookup in `documented(...)` (`citation.hpp`), which already carries +/// a title, a reference, a section and a full text as separate fields -- never +/// a composed sentence -- and already composes with any `Node`, lookups +/// included, with no change needed here. +/// +/// *Rendering* a miss's structured fields into prose -- the step's /// `StepKind::BandedLookup`, the interval the table covers -/// (`Step::coveredRange`) and why it failed (`Step::lookupFailure`), read -/// back out by `trace_render.hpp` as it does for every other kind -- is -/// `trace.hpp`/`trace_render.hpp` work, not this file's; nothing here -/// composes a sentence that would make it harder. +/// (`Step::coveredRange`) and why it failed (`Step::lookupFailure`), read back +/// out by `trace_render.hpp` as it does for every other kind -- is +/// `trace.hpp`/`trace_render.hpp` work, not this file's; nothing here composes +/// a sentence that would make it harder. /// /// **Bands are half-open, `[low, high)`, exactly as `band.hpp` declares them -- /// see `band.hpp`'s file comment.** A value sitting exactly on a shared diff --git a/test/record_statistics_tests.cpp b/test/record_statistics_tests.cpp index 9f62c96..3c2460e 100644 --- a/test/record_statistics_tests.cpp +++ b/test/record_statistics_tests.cpp @@ -126,8 +126,9 @@ TEST_CASE("a precision limit read from another record is stamped with that recor "[record-statistics]") { // `precision_tests.cpp`'s fixture P: the level is the pair's mean, - // 40.4525 g, and r = 0.1 g + level / 50 = 0.90905 g. The level's first pass is a step - // `precision_level_produced` records, and it is the reference's. + // 40.4525 g, and r = 0.1 g + level / 50 = 0.90905 g. The level's first + // pass is a step `precision_level_produced` records, and it is the + // reference's. constexpr auto limitThere = formula::from_record(formula::precision_limit( (var + var) / rat(2), formula::constant(rat(1, 10)) + rat(1, 50) * formula::precision_level)); From d349de547f6a1a022f5c6928eee7d9b09a5736b3 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Mon, 5 Oct 2026 02:59:28 +0200 Subject: [PATCH 31/35] test: name the dimensionless unit in two no-unit-clause test cases Only a dimensionless unit at scale 1 drops the unit clause; a dimensioned unit with no symbol is written in its coherent unit instead. Both cases test `unit::One` alone, so their names now say so. Signed-off-by: Christian Parpart --- test/render_tests.cpp | 3 ++- test/trace_render_tests.cpp | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/test/render_tests.cpp b/test/render_tests.cpp index 5c3b4ee..16288bc 100644 --- a/test/render_tests.cpp +++ b/test/render_tests.cpp @@ -1809,7 +1809,8 @@ TEST_CASE("render: Markdown output never contains text a CommonMark parser reint { rat(863, 1000), rat(1381, 1000), rat(1043, 1000) })))); } -TEST_CASE("render: a rounding or a numeric value in a unit with no symbol adds no unit clause", "[render][rounding]") +TEST_CASE("render: a rounding or a numeric value in a dimensionless unit with no symbol adds no unit clause", + "[render][rounding]") { // `unit::One`'s symbol is empty, and the clause once read `to 2 dp of )` // and `numeric(..., in )`. A value with no unit is shown with none, as a diff --git a/test/trace_render_tests.cpp b/test/trace_render_tests.cpp index c534771..7e136a0 100644 --- a/test/trace_render_tests.cpp +++ b/test/trace_render_tests.cpp @@ -1958,7 +1958,8 @@ TEST_CASE("a variant's tag and a lookup key's name are escaped", "[trace-render] CHECK(formula::render_trace(keyTrace, { .maxSteps = 10 }) == "1. lookup(key steel\\; y \\\\) = 863/1000\n"); } -TEST_CASE("a rounding or a numeric value in a unit with no symbol adds no unit clause to its line", "[trace-render]") +TEST_CASE("a rounding or a numeric value in a dimensionless unit with no symbol adds no unit clause to its line", + "[trace-render]") { // `unit::One`'s symbol is empty, and a method's rounding step once read // `round(#4, in )`, a numeric value `numeric(#3, in )`. The clause is From e1324b69e85f7f7957b3c7b7b6d1a2904bd0ac73 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Mon, 5 Oct 2026 03:02:05 +0200 Subject: [PATCH 32/35] style: wrap long test lines and split a doc comment paragraph Wraps the test lines over 125 columns that came in with the unnamed-unit display work, following each file's own continuation style, and sets the note on a dimensionless unit with no symbol in `spells_coherent_unit`'s comment apart as its own paragraph. The header keeps its line count. Signed-off-by: Christian Parpart --- include/formula-cpp/render.hpp | 8 ++++---- test/render_tests.cpp | 12 ++++++++---- test/trace_shown_unit_tests.cpp | 17 +++++++++++------ 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/include/formula-cpp/render.hpp b/include/formula-cpp/render.hpp index 913c164..41e3290 100644 --- a/include/formula-cpp/render.hpp +++ b/include/formula-cpp/render.hpp @@ -222,10 +222,10 @@ namespace detail /// curve or a permitted set declared, a limit (`shown_number`, /// `shown_bound_text`, `shown_limit_row`). So every number is in the unit /// written after it. - /// A dimensionless unit with no symbol is always at scale 1 here: - /// one with a scale is refused where it is written - /// (`RequireNamedScaledScalar`, `unit.hpp`), so its bare number is the - /// value. + /// + /// A dimensionless unit with no symbol is always at scale 1 here: one with + /// a scale is refused where it is written (`RequireNamedScaledScalar`, + /// `unit.hpp`), so its bare number is the value. [[nodiscard]] constexpr bool spells_coherent_unit(Unit const& declared, Dimension dimension) { return view(declared.symbolText).empty() && !(dimension == dim::Scalar); diff --git a/test/render_tests.cpp b/test/render_tests.cpp index 16288bc..1856e51 100644 --- a/test/render_tests.cpp +++ b/test/render_tests.cpp @@ -65,12 +65,14 @@ struct UnlabelledLoading: formula::Quantity +struct UnlabelledHeft: + formula::Quantity { }; // A mass unit of a whole thousand kilograms with no symbol. inline constexpr formula::Unit UnlabelledTonne { .dimension = formula::dim::Mass, .magnitudeNumerator = 1000 }; -struct UnlabelledLoad: formula::Quantity +struct UnlabelledLoad: + formula::Quantity { }; @@ -562,10 +564,12 @@ TEST_CASE("render: a rounding in a unit with no symbol names that unit by its si // A numeric value and a constant in such a unit, in LaTeX: the size // grouped after the quotient's slash, and the coherent unit set upright. - constexpr auto bareWeight = formula::numeric_value_of(var); + constexpr auto bareWeight = + formula::numeric_value_of(var); CHECK(formula::render(bareWeight) == "numeric(w, in 1/1000 kg)"); CHECK(formula::render(bareWeight) == "\\{w/(1/1000\\,\\mathrm{kg})\\}"); - CHECK(formula::render(formula::constant(formula::Rational { 3 })) == "3/1000\\,\\mathrm{kg}"); + CHECK(formula::render(formula::constant(formula::Rational { 3 })) + == "3/1000\\,\\mathrm{kg}"); CHECK(formula::render(formula::constant(formula::Rational { 2 })) == "2000 kg^-1"); CHECK(formula::render(formula::constant(formula::Rational { 2 })) == "2000\\,\\mathrm{kg}^{-1}"); diff --git a/test/trace_shown_unit_tests.cpp b/test/trace_shown_unit_tests.cpp index 5dfe4b5..51eef4b 100644 --- a/test/trace_shown_unit_tests.cpp +++ b/test/trace_shown_unit_tests.cpp @@ -338,8 +338,9 @@ TEST_CASE("a rounding in a unit with no symbol names that unit by its size", "[t masses) == "1. m_u = 3141/1000000 kg\n" "2. round(#1, to 2 dp of 1/1000 kg) = 157/50000 kg [nearest, ties to even]\n"); - CHECK(trace_text(formula::rounded_to_digits( - var), + CHECK(trace_text(formula::rounded_to_digits(var), masses) .find("2. round(#1, to 2 sf of 1/1000 kg) = 31/10000 kg") != std::string::npos); @@ -359,8 +360,10 @@ TEST_CASE("a rounding in a unit with no symbol names that unit by its size", "[t masses) .find("round(sqrt(#3), to 2 dp of 1/1000 kg) = 157/50000 kg") != std::string::npos); - CHECK(trace_text(formula::rounded_output<"span", UnnamedGram, formula::DecimalPlaces { 2 }, formula::RoundingMode::HalfEven>( - lowestAndSpan), + CHECK(trace_text(formula::rounded_output<"span", + UnnamedGram, + formula::DecimalPlaces { 2 }, + formula::RoundingMode::HalfEven>(lowestAndSpan), determinations) .find(", to 2 dp of 1/1000 kg) = 7/2000 kg") != std::string::npos); @@ -540,7 +543,8 @@ TEST_CASE("a conditional reads in its chosen branch's unit, offset or not", "[tr "4. if #1 > #2 then #3 = 25 \xc2\xb0" "C\n"); } -TEST_CASE("a precision limit's first pass reads in the unit of the level it restates", "[trace-render][shown-unit][precision]") +TEST_CASE("a precision limit's first pass reads in the unit of the level it restates", + "[trace-render][shown-unit][precision]") { // The level is a constant in grams and the limit names no quantity, so // nothing in the types says grams: pass 1 reads off the step it restates, @@ -563,7 +567,8 @@ TEST_CASE("a precision limit's first pass reads in the unit of the level it rest // degrees Celsius, as the constant's own line does, never as a kelvin // difference. CHECK(trace_text(formula::precision_limit( - formula::constant(Rational { 20 }), formula::constant(Rational { 1 })), + formula::constant(Rational { 20 }), + formula::constant(Rational { 1 })), determinations) .starts_with("1. 20 \xc2\xb0" "C\n" "2. level (pass 1 of 2) = #1 = 20 \xc2\xb0" "C\n")); From ef90a7415d491dff7d30f35be576ddba97ac447b Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Mon, 5 Oct 2026 03:19:58 +0200 Subject: [PATCH 33/35] fix: write a Measured value in an unnamed unit in the coherent unit `number_text` and `std::format` of a `Measured` value whose unit has a dimension but no symbol wrote the number on that unit's scale with nothing after it: 3 of a unit of 1/1000 kg read `3`. They now move it exactly into the coherent unit and write that unit's spelling after it, `3/1000 kg` or `0.003 kg`, as `render()` and a trace already do. A unit with a symbol, and a dimensionless unit with no symbol, are written as before. The coherent unit's order and shape are now written once, by a constexpr walker in `number_text.hpp` that both `coherent_unit_spelling` and a `NumberText` use, along with the plain spelling of a power; `spells_coherent_unit`, `shown_unit_of` and `shown_number` move there with it, and `coherent()` moves from `evaluate.hpp` to `unit.hpp`. A move into the coherent unit that fails returns its error, and a spelling too long for a `NumberText` returns `Overflow`. The gallery writes its worked value through `number_text`, and names a dimensioned unit with no symbol by its size instead of calling it dimensionless. Signed-off-by: Christian Parpart --- CHANGELOG.md | 3 +- docs/display.md | 15 +- include/formula-cpp/evaluate.hpp | 11 -- include/formula-cpp/format.hpp | 47 +++-- include/formula-cpp/number_text.hpp | 286 ++++++++++++++++++++++++++-- include/formula-cpp/render.hpp | 128 ++++--------- include/formula-cpp/unit.hpp | 11 ++ test/format_tests.cpp | 32 ++++ test/number_text_tests.cpp | 74 +++++++ tools/gallery/main.cpp | 29 +-- 10 files changed, 482 insertions(+), 154 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d6b7d9..b563f76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,7 +64,8 @@ change is recorded here. `\operatorname{round}_{2\,(1/1000\,\mathrm{kg})}`, `2000\,\mathrm{kg}^{-1}`. Every other number a formula declares in such a unit renders in the coherent unit too, as its trace writes it: a per-element constant's values (`values(3/1000 kg, 1/200 kg)`), a lookup's bands, rows and the values it gives, a binning's classes, a snap's - permitted values, a domain's points and an envelope's limits. + permitted values, a domain's points and an envelope's limits. `number_text` and `std::format` of a `Measured` + value in such a unit write it the same way, `3/1000 kg` or `0.003 kg`, where they wrote a bare `3`. - A precision limit's first pass reads in the unit of the level step it restates, as its second pass already did: a level constant declared in grams reads `40 g` on both lines, where the first pass read `1/25 kg`. The unit the limit's quantities give is still used when the level's step has none to lend. diff --git a/docs/display.md b/docs/display.md index 0c851a2..0283a6c 100644 --- a/docs/display.md +++ b/docs/display.md @@ -436,7 +436,7 @@ and writes exactly what [`checked_round`](numbers.md#rounding) rounds to. Both l `number_text.hpp`, which `formula.hpp` includes, and both: - need **no ``** and no ``: the result is a `NumberText`, a - fixed 64-byte buffer, so they **allocate nothing**; + fixed 128-byte buffer, so they **allocate nothing**; - are **`constexpr`**, so a spelling can be checked at compile time: ```cpp @@ -465,6 +465,13 @@ two places: 11.31 A `Measured` value that is absent -- here one constructed with nothing, `{}` -- reads `(not measured)`, in every style. +A `Measured` value in a dimensioned unit with no symbol is written as a +trace writes it: moved exactly into the coherent unit and followed by that +unit's spelling, since a unit with no symbol cannot say what scale its +number is on. 3 of a unit of 1/1000 kg reads `3/1000 kg` as a fraction and +`0.003 kg` as a decimal, never a bare `3`; `std::format` writes it the same +way. A value in a dimensionless unit with no symbol is a bare number. + A `NumberText`'s characters are read through `view()`, a `std::string_view`, on a named object -- `view()` on a temporary does not compile, since the view would outlive the buffer. The example prints each one so: @@ -478,8 +485,10 @@ std::println("two places: {}\n", twoPlaces.view()); **When a number cannot be spelled.** `decimal_text` throws `ArithmeticException` for more than 18 places, and where rounding to whole tens or thousands overflows. `number_text` throws it where its rounding -overflows so, and for a padded or approximating style in a unit whose -declared decimals lie outside -18 to 18. Each has a `checked_` form, +overflows so, for a padded or approximating style in a unit whose +declared decimals lie outside -18 to 18, where a value in a unit with no +symbol cannot move into the coherent unit, and where that unit's spelling +does not fit the buffer. Each has a `checked_` form, `checked_decimal_text` and `checked_number_text`, that returns the `ArithmeticError` in a `std::expected` instead of throwing. A trace never throws for a number: a line whose value its style cannot spell reads diff --git a/include/formula-cpp/evaluate.hpp b/include/formula-cpp/evaluate.hpp index 4e6c616..ccf5f00 100644 --- a/include/formula-cpp/evaluate.hpp +++ b/include/formula-cpp/evaluate.hpp @@ -42,17 +42,6 @@ namespace formula { -/// The coherent unit of a dimension: magnitude one, offset zero, no symbol -- -/// the SI unit, times one of each named base dimension it has: the unit named -/// after a base, which by convention has magnitude one. -/// -/// Every `Unit` already states its own exact conversion to this one, so it is -/// the single scale on which values from different units can meet. -[[nodiscard]] constexpr Unit coherent(Dimension dimensionOfUnit) noexcept -{ - return Unit { .dimension = dimensionOfUnit }; -} - /// How arithmetic is done for one representation. /// /// The primary template is deliberately undefined: a representation that has diff --git a/include/formula-cpp/format.hpp b/include/formula-cpp/format.hpp index 929a7bc..845406c 100644 --- a/include/formula-cpp/format.hpp +++ b/include/formula-cpp/format.hpp @@ -109,12 +109,12 @@ namespace formula::detail "rounding mode (~ without .N only for a Measured value, whose unit declares the places)"); } -/// Refuses to write a value the format could not spell: `~Mode` on a -/// `Measured` whose unit declares negative decimals, for a value it must -/// round (one with no exact decimal of at most 18 places) that exact -/// arithmetic cannot divide by 10^-decimals (see `formatter>`) -/// -- the one case the parser's checks cannot see. Only `format` reaches it. -/// @throws std::format_error always. +/// Refuses to write a value the format could not spell, where the parser's +/// checks cannot see it: `~Mode` on a `Measured` whose unit declares negative +/// decimals, for a value with no exact decimal of 18 places or fewer that +/// exact arithmetic cannot divide by 10^-decimals; or a `Measured` value that +/// cannot move into the coherent unit its text names (`shown_number`). Only +/// `format` reaches it. @throws std::format_error always. [[noreturn]] inline void number_format_failed(ArithmeticError spellingFailure) { if (spellingFailure == ArithmeticError::Overflow) @@ -437,14 +437,14 @@ template /// Reads a `Measured` or `Outcome` replacement field's spec, as /// `parse_number_format_field` does, and refuses `~Mode` without `.N` when -/// @p Q's unit declares decimals outside the -18 to 18 that `DecimalPlaces` -/// spans. +/// the unit @p Q's value is shown in (`shown_unit_of`) declares decimals +/// outside the -18 to 18 that `DecimalPlaces` spans. template [[nodiscard]] constexpr std::format_parse_context::iterator parse_measured_format_field( std::format_parse_context& parseContext, NumberFormatSpec& parsed) { auto const specEnd = parse_number_format_field(parseContext, parsed); - constexpr int declaredPlaces = Describe::unit.decimals; + constexpr int declaredPlaces = shown_unit_of(Describe::unit, Describe::dimension).decimals; if (parsed.body == NumberFormatBody::Approximated && !parsed.places.has_value() && (declaredPlaces > 18 || declaredPlaces < -18)) formula_number_format_places_out_of_range(); @@ -452,9 +452,11 @@ template } /// Writes @p shownMeasured to @p destination as @p formatSpec says: the -/// number in @p Q's declared unit and its symbol, or `(not measured)` when it -/// is absent. Throws `std::format_error` when the number cannot be spelled as -/// asked (`spell_formatted_number`). +/// number in @p Q's declared unit and its symbol -- or, for a dimensioned unit +/// with no symbol, moved into the coherent unit and followed by its spelling, +/// as `number_text` writes it (`shown_number`, `write_shown_unit`) -- or +/// `(not measured)` when it is absent. Throws `std::format_error` when the +/// number cannot be moved or spelled as asked (`number_format_failed`). template [[nodiscard]] OutputIterator format_measured(Measured const& shownMeasured, NumberFormatSpec const& formatSpec, @@ -462,9 +464,17 @@ template { if (shownMeasured.is_absent()) return write_formatted_number(NotMeasuredText, std::string_view {}, formatSpec, destination); - Unit const shownIn = Describe::unit; - NumberText const spelled = spell_formatted_number(*shownMeasured.stored(), shownIn, formatSpec); - return write_formatted_number(spelled.view(), view(shownIn.symbolText), formatSpec, destination); + Unit const declaredIn = Describe::unit; + std::expected const shownValue = + shown_number(*shownMeasured.stored(), declaredIn, declaredIn.dimension); + if (!shownValue) + number_format_failed(shownValue.error()); + NumberText const spelled = + spell_formatted_number(*shownValue, shown_unit_of(declaredIn, declaredIn.dimension), formatSpec); + std::string unitText; + auto appendTo = [&unitText](std::string_view written) { unitText += written; }; + write_shown_unit(appendTo, declaredIn); + return write_formatted_number(spelled.view(), unitText, formatSpec, destination); } /// @p shown's `describe()` words. Called from inside `formula::detail`, so @@ -682,6 +692,13 @@ struct formatter /// `~.N Mode` at N places instead; both write the exact decimal where the /// value has one, and mark a rounding `≈`. /// +/// **A dimensioned unit with no symbol** cannot say what scale its number is +/// on, so the number is moved exactly into the coherent unit and followed by +/// that unit's spelling, as `number_text` writes it: 3 of a unit of 1/1000 kg +/// is `0.003 kg`, `{:/}` `3/1000 kg`. Every body, the decimals `~Mode` reads +/// included, then applies to the number in the coherent unit. A value that +/// cannot be moved throws, never writing the number on the other scale. +/// /// **The modes** are `RoundingMode`'s enumerators, spelled exactly as they /// are there. **There is no default mode**: the same number rounds /// differently under different methods -- 2.5 is 3 under `HalfAwayFromZero` diff --git a/include/formula-cpp/number_text.hpp b/include/formula-cpp/number_text.hpp index 7cf8496..24607f0 100644 --- a/include/formula-cpp/number_text.hpp +++ b/include/formula-cpp/number_text.hpp @@ -41,7 +41,9 @@ namespace formula /// denominator, a space and a unit symbol of `SymbolCapacity` bytes -- of 97 /// bytes, and a `static_assert` below keeps it within this. A fraction is /// never marked approximate, and the longest marked decimal, at 18 places, is -/// shorter. +/// shorter. A value in a dimensioned unit with no symbol is followed by its +/// coherent unit's spelling instead, which no such bound covers: a text that +/// would not fit is refused with `ArithmeticError::Overflow`. inline constexpr std::size_t NumberTextCapacity = 128; /// The one spelling of "approximately": U+2248, `≈`, in UTF-8. Not `~`: a @@ -205,8 +207,9 @@ namespace detail [[nodiscard]] static constexpr NumberText blank() noexcept { return NumberText {}; } /// Appends @p written. Never past the end: no caller in this header - /// writes more than `LongestNumberText` bytes, which the - /// `static_assert` below keeps within the buffer. + /// writes more than `LongestNumberText` bytes through it, which the + /// `static_assert` below keeps within the buffer; a coherent unit's + /// spelling goes through `put_within`. static constexpr void put(NumberText& spelled, char written) noexcept { spelled._characters[spelled._length] = written; @@ -220,6 +223,18 @@ namespace detail put(spelled, each); } + /// Appends every byte of @p written when all of them fit in the + /// buffer, and nothing when they do not: for text whose length no + /// bound in this header covers, a coherent unit's spelling. + /// @return whether they fit. + [[nodiscard]] static constexpr bool put_within(NumberText& spelled, std::string_view written) noexcept + { + if (written.size() > NumberTextCapacity - spelled._length) + return false; + put(spelled, written); + return true; + } + /// Appends @p wholeNumber in decimal: at most 39 digits. static constexpr void put_whole(NumberText& spelled, UInt128 wholeNumber) noexcept { @@ -582,12 +597,248 @@ namespace detail return detail::or_throw(checked_number_text(shownValue, shownStyle, shownIn)); } -/// @p shownMeasurement as @p shownStyle writes it in `Q`'s declared unit, -/// followed by a space and the unit's symbol when it has one -- `5.2 kJ`, -/// `3/5` in `unit::One` -- or `NotMeasuredText` when it is absent. +namespace detail +{ + /// Whether a value of @p dimension in @p declared is shown in the coherent + /// unit, spelt from its base units (`put_coherent_unit`), rather than in + /// @p declared: when @p declared has no symbol and @p dimension is not + /// dimensionless. A unit with no symbol cannot say what scale its number + /// is on, so the number is moved into the one scale its spelling names. + /// The one rule for every place a number is written with its unit: in a + /// trace, a step's value, a squared deviation and a derivation's header; + /// in a trace and in `render()` alike, every number a formula declares -- + /// a constant, a per-element constant's values, a bound or a row a table, + /// a curve or a permitted set declared, a limit (`shown_number`, + /// `shown_bound_text`, `shown_limit_row`); and a `Measured` value's text, + /// here and through `std::format`. So every number is in the unit written + /// after it. + /// + /// A dimensionless unit with no symbol is always at scale 1 here: one with + /// a scale is refused where it is written (`RequireNamedScaledScalar`, + /// `unit.hpp`), so its bare number is the value. + [[nodiscard]] constexpr bool spells_coherent_unit(Unit const& declared, Dimension dimension) + { + return formula::view(declared.symbolText).empty() && !(dimension == dim::Scalar); + } + + /// Whether a number declared in @p declared is followed by a unit: its + /// symbol, or the coherent unit's spelling (`spells_coherent_unit`). + /// Only a dimensionless unit with no symbol writes none. + [[nodiscard]] constexpr bool writes_a_unit(Unit const& declared) + { + return !formula::view(declared.symbolText).empty() || spells_coherent_unit(declared, declared.dimension); + } + + /// The unit a value of @p dimension declared in @p declared is shown in: + /// the coherent unit where `spells_coherent_unit` says so, @p declared + /// otherwise. + [[nodiscard]] constexpr Unit shown_unit_of(Unit const& declared, Dimension dimension) + { + return spells_coherent_unit(declared, dimension) ? coherent(dimension) : declared; + } + + /// @p declaredNumber, a number of @p dimension declared in @p declared, + /// moved exactly into the unit it is shown in (`shown_unit_of`): the + /// coherent unit for a dimensioned unit with no symbol, and @p declared, + /// unchanged, otherwise. + /// + /// **The one rule for every number written with its unit**, in a + /// formula's text and in its trace alike: a bound or a row a table + /// declares, a permitted value, a limit, a constant and a per-element + /// constant's values, so that no number is in a scale the text after it + /// does not name; and a `Measured` value's text. Only the move can fail + /// -- for a malformed unit, or a unit with an offset whose sum overflows + /// -- and the caller then writes `not_shown_text` (`render.hpp`) or + /// returns the error, never the number in the wrong scale. + [[nodiscard]] constexpr std::expected shown_number(Rational declaredNumber, + Unit const& declared, + Dimension dimension) + { + if (!spells_coherent_unit(declared, dimension)) + return declaredNumber; + return checked_convert(declaredNumber, declared, coherent(dimension)); + } + + /// Writes the power a base unit's factor is raised to, as plain text and a + /// trace write it -- `^-1`, `^(1/2)`, `^(-1/2)`, and nothing for a power of + /// 1 -- through @p writer, which takes each piece as a `std::string_view`. + /// The one spelling of it, for `plain_unit_power` (`render.hpp`) and for a + /// number's text here alike. + template + constexpr void write_plain_unit_power(Writer& writer, std::int32_t numeratorPart, std::int32_t denominatorPart) + { + auto const writeWhole = [&writer](std::int32_t wholePart) { + std::int64_t const widened = wholePart; + if (widened < 0) + writer("-"); + DecimalSpelling const written = + u128_decimal(UInt128::from_u64(static_cast(widened < 0 ? -widened : widened))); + writer(std::string_view { written.characters, static_cast(written.length) }); + }; + if (denominatorPart != 1) + { + writer("^("); + writeWhole(numeratorPart); + writer("/"); + writeWhole(denominatorPart); + writer(")"); + } + else if (numeratorPart != 1) + { + writer("^"); + writeWhole(numeratorPart); + } + } + + /// Writes the coherent unit of @p dimension, spelt from its base units, to + /// @p unitSink: the one order and shape of that spelling, which + /// `coherent_unit_spelling` (`render.hpp`) sets in each notation and a + /// number's text here writes plain, so the two cannot drift. + /// + /// The named bases come first, in the dimension's own order, then the SI + /// base units, `m kg s A K mol cd`. Factors with a positive exponent stand + /// above a slash and the rest below it, with their exponents negated and + /// bracketed when there are two or more: `kg/(m s^2)`. A dimension with no + /// positive exponent is written with negative exponents and no slash, + /// `kg^-1`; a dimensionless one writes nothing. + /// + /// @p unitSink takes three calls: `factor(symbolText, namedBase, + /// numeratorPart, denominatorPart)` for one base unit and its power, where + /// `namedBase` says that `symbolText` is a named base dimension's name; + /// `between()` between two factors on one side of the slash; and + /// `put(text)` for the slash and the brackets. + template + constexpr void put_coherent_unit(Dimension const& dimension, UnitSink& unitSink) + { + struct BaseUnit + { + std::string_view symbolText; + Exponent exponent; + }; + BaseUnit const bases[] { BaseUnit { "m", dimension.length }, BaseUnit { "kg", dimension.mass }, + BaseUnit { "s", dimension.time }, BaseUnit { "A", dimension.current }, + BaseUnit { "K", dimension.temperature }, BaseUnit { "mol", dimension.amount }, + BaseUnit { "cd", dimension.luminosity } }; + auto const forEachFactor = [&](auto const& visit) { + for (std::size_t slot = 0; named_base_in_use(dimension, slot); ++slot) + visit(formula::view(dimension.namedBases[slot].name), true, dimension.namedBases[slot].exponent); + for (BaseUnit const& base: bases) + visit(base.symbolText, false, base.exponent); + }; + + std::size_t aboveCount = 0; + std::size_t belowCount = 0; + forEachFactor([&](std::string_view, bool, Exponent baseExponent) { + if (baseExponent.numerator > 0) + ++aboveCount; + else if (baseExponent.numerator < 0) + ++belowCount; + }); + auto const writeSide = [&](bool aboveTheSlash, bool negated) { + bool firstOnSide = true; + forEachFactor([&](std::string_view symbolText, bool namedBase, Exponent baseExponent) { + if (aboveTheSlash ? baseExponent.numerator <= 0 : baseExponent.numerator >= 0) + return; + if (!firstOnSide) + unitSink.between(); + firstOnSide = false; + unitSink.factor(symbolText, + namedBase, + negated ? -baseExponent.numerator : baseExponent.numerator, + baseExponent.denominator); + }); + }; + + if (aboveCount == 0 || belowCount == 0) + { + // One side only, every exponent as it is, and no slash. + writeSide(belowCount == 0, false); + return; + } + writeSide(true, false); + unitSink.put("/"); + if (belowCount > 1) + unitSink.put("("); + writeSide(false, true); + if (belowCount > 1) + unitSink.put(")"); + } + + /// A `put_coherent_unit` sink that writes plain text, `kg/(m s^2)`, through + /// a `Writer`, which takes each piece as a `std::string_view`: each symbol + /// and name as it is, a power as `write_plain_unit_power` writes it, and + /// a space between two factors. + template + struct PlainUnitSink + { + /// Where the text goes. + Writer& writer; + + /// Writes a slash or a bracket. + constexpr void put(std::string_view written) { writer(written); } + + /// Writes the space between two factors. + constexpr void between() { writer(" "); } + + /// Writes one base unit's symbol, or a named base's name, and its power. + constexpr void factor(std::string_view symbolText, bool, std::int32_t numeratorPart, std::int32_t denominatorPart) + { + writer(symbolText); + write_plain_unit_power(writer, numeratorPart, denominatorPart); + } + }; + + /// Writes the unit after a number declared in @p declared, in plain text, + /// through @p writer, which takes each piece as a `std::string_view`: the + /// coherent unit's spelling where `spells_coherent_unit` says so, + /// @p declared's symbol otherwise, and so nothing for a dimensionless unit + /// with no symbol. The number before it is the one `shown_number` moved. + template + constexpr void write_shown_unit(Writer& writer, Unit const& declared) + { + if (spells_coherent_unit(declared, declared.dimension)) + { + PlainUnitSink plainSink { writer }; + put_coherent_unit(declared.dimension, plainSink); + } + else + writer(formula::view(declared.symbolText)); + } + + /// A writer for `write_shown_unit` that appends to a `NumberText` through + /// `NumberTextAccess::put_within`. A piece that does not fit sets + /// `overflowed`, and nothing after it is written. + struct NumberTextWriter + { + /// The text appended to. + NumberText& spelled; + /// Whether a piece did not fit. + bool overflowed = false; + + /// Appends @p written, unless it or an earlier piece did not fit. + constexpr void operator()(std::string_view written) noexcept + { + overflowed = overflowed || !NumberTextAccess::put_within(spelled, written); + } + }; +} // namespace detail + +/// @p shownMeasurement as @p shownStyle writes it, followed by a space and +/// its unit -- `5.2 kJ`, `3/5` in `unit::One` -- or `NotMeasuredText` when it +/// is absent. +/// +/// The number is in `Q`'s declared unit, followed by its symbol -- unless +/// that unit has no symbol and a dimension (`detail::spells_coherent_unit`): +/// the number is then moved exactly into the coherent unit and followed by +/// that unit's spelling, `3/1000 kg` for 3 of a unit of 1/1000 kg, so it is +/// never on a scale nothing after it names. A dimensionless unit with no +/// symbol writes the number alone. /// /// @return any error of `checked_number_text(Rational, NumberStyle, Unit const&)`; -/// never one for an absent value. +/// any error of the move into the coherent unit (`checked_convert`), +/// never the number in the declared unit's scale; `Overflow` when the +/// coherent unit's spelling does not fit a `NumberText`. Never one +/// for an absent value. template [[nodiscard]] constexpr std::expected checked_number_text( Measured const& shownMeasurement, NumberStyle shownStyle) noexcept @@ -599,15 +850,20 @@ template return absentText; } - Unit const shownIn = Describe::unit; + Unit const declaredIn = Describe::unit; + std::expected const shownValue = + detail::shown_number(*shownMeasurement.stored(), declaredIn, declaredIn.dimension); + if (!shownValue) + return std::unexpected { shownValue.error() }; std::expected spelled = - checked_number_text(*shownMeasurement.stored(), shownStyle, shownIn); - std::string_view const unitSymbol = formula::view(shownIn.symbolText); - if (spelled && !unitSymbol.empty()) - { - detail::NumberTextAccess::put(*spelled, ' '); - detail::NumberTextAccess::put(*spelled, unitSymbol); - } + checked_number_text(*shownValue, shownStyle, detail::shown_unit_of(declaredIn, declaredIn.dimension)); + if (!spelled || !detail::writes_a_unit(declaredIn)) + return spelled; + detail::NumberTextWriter appendTo { *spelled }; + appendTo(" "); + detail::write_shown_unit(appendTo, declaredIn); + if (appendTo.overflowed) + return std::unexpected { ArithmeticError::Overflow }; return spelled; } diff --git a/include/formula-cpp/render.hpp b/include/formula-cpp/render.hpp index 41e3290..793cb89 100644 --- a/include/formula-cpp/render.hpp +++ b/include/formula-cpp/render.hpp @@ -210,42 +210,18 @@ namespace detail return PrecedenceOf::value; } - /// Whether a value of @p dimension in @p declared is shown in the coherent - /// unit, spelt by `coherent_unit_spelling`, rather than in @p declared: - /// when @p declared has no symbol and @p dimension is not dimensionless. A - /// unit with no symbol cannot say what scale its number is on, so the - /// number is moved into the one scale its spelling names. The one rule - /// for every place a number is written with its unit: in a trace, a - /// step's value, a squared deviation and a derivation's header; and in a - /// trace and in `render()` alike, every number a formula declares -- a - /// constant, a per-element constant's values, a bound or a row a table, a - /// curve or a permitted set declared, a limit (`shown_number`, - /// `shown_bound_text`, `shown_limit_row`). So every number is in the unit - /// written after it. - /// - /// A dimensionless unit with no symbol is always at scale 1 here: one with - /// a scale is refused where it is written (`RequireNamedScaledScalar`, - /// `unit.hpp`), so its bare number is the value. - [[nodiscard]] constexpr bool spells_coherent_unit(Unit const& declared, Dimension dimension) - { - return view(declared.symbolText).empty() && !(dimension == dim::Scalar); - } - /// A constant's rendered text is not always an atom in two data-dependent /// ways the type does not carry: a negative number opens with a `-` that /// reads like a unary minus, and a unit with a symbol renders as *two* /// tokens ("150 mm") rather than one -- so `pow<2>` of it would otherwise /// read as `150 mm^2`, i.e. `150 * mm^2`, when the tree means `(150 mm)^2`. /// A dimensioned unit with no symbol writes a unit too, the coherent one - /// (`3/1000 kg`, `spells_coherent_unit`). Both cases must bracket exactly + /// (`3/1000 kg`, `writes_a_unit`). Both cases must bracket exactly /// where a `UnaryNode` would. template [[nodiscard]] constexpr Precedence precedence_of(ConstantNode const& node) noexcept { - constexpr Unit declaredUnit = U; - bool const writesUnit = - !view(declaredUnit.symbolText).empty() || spells_coherent_unit(declaredUnit, declaredUnit.dimension); - return node.number.sign() < 0 || writesUnit ? Precedence::Unary : Precedence::Atom; + return node.number.sign() < 0 || writes_a_unit(U) ? Precedence::Unary : Precedence::Atom; } /// A wrapper's *type* answer forwards correctly (`PrecedenceOf` above), @@ -508,14 +484,14 @@ namespace detail } /// The power a base unit's factor is raised to, as plain text and a trace - /// write it: `^-1`, `^(1/2)`, `^(-1/2)`, and nothing for a power of 1. + /// write it: `^-1`, `^(1/2)`, `^(-1/2)`, and nothing for a power of 1 + /// (`write_plain_unit_power`, `number_text.hpp`). [[nodiscard]] inline std::string plain_unit_power(std::int32_t numeratorPart, std::int32_t denominatorPart) { - if (denominatorPart != 1) - return "^(" + std::to_string(numeratorPart) + "/" + std::to_string(denominatorPart) + ")"; - if (numeratorPart != 1) - return "^" + std::to_string(numeratorPart); - return {}; + std::string spelled; + auto appendTo = [&spelled](std::string_view written) { spelled += written; }; + write_plain_unit_power(appendTo, numeratorPart, denominatorPart); + return spelled; } /// The same power as LaTeX sets it, a superscript: `^{-1}`, `^{1/2}`, @@ -579,49 +555,38 @@ namespace detail /// Each factor, its power and the space between two are set in /// @p notation: as above by default, and in LaTeX /// `\mathrm{kg}/(\mathrm{m}\,\mathrm{s}^{2})` (`LatexUnitNotation`). + /// + /// The order and the shape are `put_coherent_unit`'s (`number_text.hpp`), + /// which a `Measured` value's text writes with too, so the two cannot + /// drift; this sets each piece it writes in @p notation. [[nodiscard]] inline std::string coherent_unit_spelling(Dimension dimension, AuthorTextSpelling spellName, UnitNotation const& notation = PlainUnitNotation) { - struct BaseUnit + // A `put_coherent_unit` sink that sets each piece in a notation. + struct NotationSink { - std::string_view symbol; - Exponent exponent; - }; - std::array const bases { BaseUnit { "m", dimension.length }, BaseUnit { "kg", dimension.mass }, - BaseUnit { "s", dimension.time }, BaseUnit { "A", dimension.current }, - BaseUnit { "K", dimension.temperature }, BaseUnit { "mol", dimension.amount }, - BaseUnit { "cd", dimension.luminosity } }; - auto const unitPower = [&](std::string_view symbolText, std::int32_t numeratorPart, std::int32_t denominatorPart) { - return notation.symbol(symbolText) + notation.power(numeratorPart, denominatorPart); - }; - std::string const between { notation.between }; - std::string above; - std::string below; - std::string inverse; - std::size_t belowCount = 0; - auto const place = [&](std::string_view symbolText, Exponent baseExponent) { - if (baseExponent.numerator > 0) - above += (above.empty() ? "" : between) - + unitPower(symbolText, baseExponent.numerator, baseExponent.denominator); - else if (baseExponent.numerator < 0) + std::string& spelledSoFar; + AuthorTextSpelling nameSpelling; + UnitNotation const& setIn; + + void put(std::string_view written) { spelledSoFar += written; } + + void between() { spelledSoFar += setIn.between; } + + void factor(std::string_view symbolText, + bool namedBase, + std::int32_t numeratorPart, + std::int32_t denominatorPart) { - below += (below.empty() ? "" : between) - + unitPower(symbolText, -baseExponent.numerator, baseExponent.denominator); - inverse += (inverse.empty() ? "" : between) - + unitPower(symbolText, baseExponent.numerator, baseExponent.denominator); - ++belowCount; + spelledSoFar += namedBase ? setIn.symbol(nameSpelling(symbolText)) : setIn.symbol(symbolText); + spelledSoFar += setIn.power(numeratorPart, denominatorPart); } }; - for (std::size_t slot = 0; named_base_in_use(dimension, slot); ++slot) - place(spellName(view(dimension.namedBases[slot].name)), dimension.namedBases[slot].exponent); - for (BaseUnit const& base: bases) - place(base.symbol, base.exponent); - if (below.empty()) - return above; - if (above.empty()) - return inverse; - return above + "/" + (belowCount > 1 ? "(" + below + ")" : below); + std::string spelled; + NotationSink notationSink { spelled, spellName, notation }; + put_coherent_unit(dimension, notationSink); + return spelled; } /// The unit a rounding's places or digits count in, or a numeric value's @@ -691,14 +656,6 @@ namespace detail return rounding_unit_text(roundedIn, verbatim_text); } - /// The unit a value of @p dimension declared in @p declared is shown in: - /// the coherent unit where `spells_coherent_unit` says so, @p declared - /// otherwise. - [[nodiscard]] inline Unit shown_unit_of(Unit const& declared, Dimension dimension) - { - return spells_coherent_unit(declared, dimension) ? coherent(dimension) : declared; - } - /// A value no line can spell, and why: `(not shown: )`. The one /// spelling of it, for a value its unit cannot show and for a value its /// style cannot spell in that unit alike. @@ -724,27 +681,6 @@ namespace detail return notation.symbol(spellAuthorText(view(declared.symbolText))); } - /// @p declaredNumber, a number of @p dimension declared in @p declared, - /// moved exactly into the unit it is shown in (`shown_unit_of`): the - /// coherent unit for a dimensioned unit with no symbol, and @p declared, - /// unchanged, otherwise. - /// - /// **The one rule for every number written with its unit**, in a - /// formula's text and in its trace alike: a bound or a row a table - /// declares, a permitted value, a limit, a constant and a per-element - /// constant's values, so that no number is in a scale the text after it - /// does not name. Only the move can fail -- for a malformed unit, or a - /// unit with an offset whose sum overflows -- and the caller then writes - /// `not_shown_text`, never the number in the wrong scale. - [[nodiscard]] inline std::expected shown_number(Rational declaredNumber, - Unit const& declared, - Dimension dimension) - { - if (!spells_coherent_unit(declared, dimension)) - return declaredNumber; - return checked_convert(declaredNumber, declared, coherent(dimension)); - } - /// @p declaredNumber, declared in @p declaredIn, as text: moved into the /// unit it is shown in (`shown_number`) and spelled exact in /// @p numberStyle there (`styled_number_text`), without that unit's text. diff --git a/include/formula-cpp/unit.hpp b/include/formula-cpp/unit.hpp index 4cc9e49..a736f70 100644 --- a/include/formula-cpp/unit.hpp +++ b/include/formula-cpp/unit.hpp @@ -579,6 +579,17 @@ namespace detail }; } // namespace detail +/// The coherent unit of a dimension: magnitude one, offset zero, no symbol -- +/// the SI unit, times one of each named base dimension it has: the unit named +/// after a base, which by convention has magnitude one. +/// +/// Every `Unit` already states its own exact conversion to this one, so it is +/// the single scale on which values from different units can meet. +[[nodiscard]] constexpr Unit coherent(Dimension dimensionOfUnit) noexcept +{ + return Unit { .dimension = dimensionOfUnit }; +} + /// Converts @p magnitude from @p from into @p to, exactly. /// /// Applies integer factors by multiply-then-divide rather than a precomputed diff --git a/test/format_tests.cpp b/test/format_tests.cpp index 575bc10..186f247 100644 --- a/test/format_tests.cpp +++ b/test/format_tests.cpp @@ -12,6 +12,7 @@ #include #include +#include #include #include @@ -60,6 +61,20 @@ struct CoarseLength: formula::Quantity +{ +}; + +/// The Celsius scale with no symbol. Invented. +inline constexpr formula::Unit UnnamedCelsius { .dimension = formula::dim::Temperature, + .offsetNumerator = 27315, + .offsetDenominator = 100 }; +struct UnnamedReading: formula::Quantity +{ +}; + /// The text of the `std::format_error` @p formatString throws formatting /// @p shown through `std::vformat`, which checks the spec only at run time; /// empty when it throws none. @@ -149,6 +164,23 @@ TEST_CASE("a Measured formats in its unit, with its symbol, or as not measured", CHECK(std::format("{:~HalfEven}", Measured { Rational { 1, 3 } }) == "\xe2\x89\x88" "0.333"); } +TEST_CASE("a Measured in a dimensioned unit with no symbol formats in the coherent unit", "[format]") +{ + // 3 of a unit of 1/1000 kg is written as 3/1000 kg, in every body, and + // the width counts the coherent unit's spelling. + Measured const threeUnnamed { Rational { 3 } }; + CHECK(std::format("{}", threeUnnamed) == "0.003 kg"); + CHECK(std::format("{:/}", threeUnnamed) == "3/1000 kg"); + CHECK(std::format("{:.4HalfEven}", threeUnnamed) == "0.0030 kg"); + CHECK(std::format("{:>10}", threeUnnamed) == " 0.003 kg"); + // The same text number_text writes. + formula::NumberText const fraction = formula::number_text(threeUnnamed, NumberStyle::fraction()); + CHECK(std::format("{:/}", threeUnnamed) == fraction.view()); + // A move that fails is refused, never written in the declared unit's scale. + CHECK(refusalOf("{}", Measured { Rational { std::numeric_limits::max() } }) + .starts_with("formula: this number cannot be spelled as the format asks")); +} + TEST_CASE("std::format and number_text spell one value in one style alike", "[format]") { // The same value, the same notation, the same unit: the two surfaces must diff --git a/test/number_text_tests.cpp b/test/number_text_tests.cpp index 4f4cad6..b41c001 100644 --- a/test/number_text_tests.cpp +++ b/test/number_text_tests.cpp @@ -66,6 +66,47 @@ inline constexpr formula::Unit Tens { .dimension = dim::Scalar, .symbolText = fo inline constexpr formula::Unit TooFine { .dimension = dim::Scalar, .symbolText = formula::symbol("tf"), .decimals = 19 }; inline constexpr formula::Unit TooCoarse { .dimension = dim::Scalar, .symbolText = formula::symbol("tc"), .decimals = -19 }; +/// A mass unit of a thousandth of a kilogram with no symbol: the gram's size +/// under no name. +inline constexpr formula::Unit UnnamedGram { .dimension = dim::Mass, .magnitudeDenominator = 1000 }; +struct UnnamedMass: formula::Quantity +{ +}; + +/// A count per unnamed gram, a unit of 1000 kg^-1 with no symbol. +inline constexpr formula::Unit UnnamedPerGram { .dimension = dim::Scalar / dim::Mass, .magnitudeNumerator = 1000 }; +struct UnnamedLoading: formula::Quantity +{ +}; + +/// The Celsius scale with no symbol: the kelvin's size, its zero at 273.15 K. +inline constexpr formula::Unit UnnamedCelsius { .dimension = dim::Temperature, + .offsetNumerator = 27315, + .offsetDenominator = 100 }; +struct UnnamedReading: formula::Quantity +{ +}; + +/// A mass in grams, with the gram's symbol. +struct NamedMass: formula::Quantity +{ +}; + +/// Four named bases with 15-byte names and every SI base, each to the power +/// 11/13: a coherent unit whose spelling alone is longer than a `NumberText` +/// holds. +inline constexpr formula::Dimension Sprawling = formula::nth_root( + formula::power(formula::base_dimension("Aaaaaaaaaaaaaaa") * formula::base_dimension("Bbbbbbbbbbbbbbb") + * formula::base_dimension("Ccccccccccccccc") * formula::base_dimension("Ddddddddddddddd") + * dim::Length * dim::Mass * dim::Time * dim::Current * dim::Temperature * dim::Amount + * dim::Luminosity, + 11), + 13); +inline constexpr formula::Unit UnnamedSprawl { .dimension = Sprawling, .magnitudeDenominator = 1000 }; +struct SprawlingReading: formula::Quantity +{ +}; + /// Whether `view()` can be called on a @p T. template concept Viewable = requires(T&& spelled) { std::forward(spelled).view(); }; @@ -507,6 +548,39 @@ TEST_CASE("a measured value is its number then its unit's symbol", "[number_text STATIC_REQUIRE(formula::number_text(Measured {}, evenApproximation).is_exact()); } +TEST_CASE("a measured value in a dimensioned unit with no symbol is shown in the coherent unit", "[number_text]") +{ + // 3 of a unit of 1/1000 kg is 3/1000 kg: the number is moved into the unit + // its text names, never left on a scale nothing after it states. + STATIC_REQUIRE(formula::number_text(Measured { Rational { 3 } }, NumberStyle::fraction()) == "3/1000 kg"); + STATIC_REQUIRE(formula::number_text(Measured { Rational { 3 } }, NumberStyle::exact_decimal()) + == "0.003 kg"); + STATIC_REQUIRE(formula::number_text(Measured { Rational { 3 } }, + NumberStyle::approximate_decimal(RoundingMode::HalfEven)) + == "0.003 kg"); + // A unit with only a negative exponent is written with it, and no slash. + STATIC_REQUIRE(formula::number_text(Measured { Rational { 3 } }, NumberStyle::fraction()) + == "3000 kg^-1"); + // A point on an offset scale moves to the coherent unit's: 21 on the + // unnamed Celsius scale is 294.15 K. + STATIC_REQUIRE(formula::number_text(Measured { Rational { 21 } }, NumberStyle::exact_decimal()) + == "294.15 K"); + + // A unit with a symbol keeps the number in that unit, as before. + STATIC_REQUIRE(formula::number_text(Measured { Rational { 3 } }, NumberStyle::fraction()) == "3 g"); + + // A move that fails is refused, never written in the declared unit's scale: + // the largest number on the unnamed Celsius scale has no place in kelvin. + STATIC_REQUIRE(formula::checked_number_text(Measured { Rational { IntMax } }, NumberStyle::fraction()) + .error() + == ArithmeticError::Overflow); + // A coherent unit's spelling that does not fit the buffer is refused as a + // number too long for it would be. + STATIC_REQUIRE(formula::checked_number_text(Measured { Rational { 3 } }, NumberStyle::fraction()) + .error() + == ArithmeticError::Overflow); +} + TEST_CASE("the longest text this library spells fits its buffer", "[number_text]") { // A sign, the 39 digits of 2^127, a slash, the 39 digits of 2^127 - 1, a diff --git a/tools/gallery/main.cpp b/tools/gallery/main.cpp index e393734..0fa0011 100644 --- a/tools/gallery/main.cpp +++ b/tools/gallery/main.cpp @@ -470,19 +470,14 @@ constexpr auto settledEstimate = formula::retrymeasurement().value(); + // Spelled with its unit by `number_text`, which writes a value in a unit + // with no symbol in the coherent unit it names; this ratio has none. + auto const asFraction = formula::checked_number_text(outcome->measurement(), formula::NumberStyle::fraction()); + auto const asDecimal = formula::checked_number_text(outcome->measurement(), formula::NumberStyle::exact_decimal()); + if (!asFraction.has_value() || !asDecimal.has_value()) + { + std::println(stderr, "formula-cpp-gallery: the worked evaluation's value could not be spelled"); + return 1; + } write_worked_formula(out, waterCementRatio); out << "```\n"; - out << "with V_w = 180 l and V_c = 300 l: " << exact_text(result) << " = " << result.to_double() << "\n"; + out << "with V_w = 180 l and V_c = 300 l: " << asFraction->view() << " = " << asDecimal->view() << "\n"; out << "```\n\n"; // ---- A worked derivation, so the page shows how a number was reached, not only what it is ---- From 51493ba1507d15ce2b2d350e91b23480f0844703 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Mon, 5 Oct 2026 03:29:14 +0200 Subject: [PATCH 34/35] fix: spell a value moved into the coherent unit as a trace line does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `number_text` and `std::format` wrote a `Measured` value they had moved into the coherent unit at that unit's default 3 places, so a decimal style padded it and an approximating one could round a small value to `≈0 kg`: 1/3 of a unit of 1/1000 kg read `≈0 kg`. They now spell it through the trace's own `checked_shown_text`, which never pads those places and extends them to the value's first significant digit: `≈0.0003 kg`, as its trace line reads. `~.N` and `.N` still round at the places they name, and a value in any other unit keeps the decimals its unit declares. `checked_shown_text` and its helpers move from `render.hpp` to `number_text.hpp`, so the three surfaces share one definition. The gallery says why its unit cell uses the library's one spelling of a unit by its size, which has no public form. Signed-off-by: Christian Parpart --- docs/display.md | 7 +- include/formula-cpp/format.hpp | 38 ++++++----- include/formula-cpp/number_text.hpp | 102 +++++++++++++++++++++++++++- include/formula-cpp/render.hpp | 73 -------------------- test/format_tests.cpp | 6 ++ test/number_text_tests.cpp | 8 +++ test/trace_shown_unit_tests.cpp | 31 +++++++++ tools/gallery/main.cpp | 4 ++ 8 files changed, 175 insertions(+), 94 deletions(-) diff --git a/docs/display.md b/docs/display.md index 0283a6c..71a7bae 100644 --- a/docs/display.md +++ b/docs/display.md @@ -469,8 +469,11 @@ A `Measured` value in a dimensioned unit with no symbol is written as a trace writes it: moved exactly into the coherent unit and followed by that unit's spelling, since a unit with no symbol cannot say what scale its number is on. 3 of a unit of 1/1000 kg reads `3/1000 kg` as a fraction and -`0.003 kg` as a decimal, never a bare `3`; `std::format` writes it the same -way. A value in a dimensionless unit with no symbol is a bare number. +`0.003 kg` as a decimal, never a bare `3`. Its places are read as a trace +reads them: the coherent unit's 3 are a default nobody chose, so they are +never padded to, and never round a value that is not zero to `≈0` -- 1/3 of +that unit reads `≈0.0003 kg`. `std::format` writes it the same way. A value +in a dimensionless unit with no symbol is a bare number. A `NumberText`'s characters are read through `view()`, a `std::string_view`, on a named object -- `view()` on a temporary does not compile, since the view diff --git a/include/formula-cpp/format.hpp b/include/formula-cpp/format.hpp index 845406c..e98479f 100644 --- a/include/formula-cpp/format.hpp +++ b/include/formula-cpp/format.hpp @@ -367,14 +367,19 @@ inline constexpr RoundingModeName RoundingModeNames[] { return specEnd; } -/// @p shownValue, a number in @p shownIn, spelled as @p formatSpec's body -/// asks -- the number alone, without the unit's symbol. Throws -/// `std::format_error` when it cannot be spelled (`number_format_failed`). +/// @p shownValue, a number declared in @p declaredIn that `shown_number` has +/// already moved into the unit it is shown in, spelled as @p formatSpec's +/// body asks -- the number alone, without the unit's text. `{}` and `~Mode` +/// spell it as `number_text` does (`checked_shown_value_text`), so a value +/// moved into the coherent unit reads as a trace line reads it; `~.N Mode` +/// and `.N Mode` round at the N places they name. Throws `std::format_error` +/// when it cannot be spelled (`number_format_failed`). [[nodiscard]] inline NumberText spell_formatted_number(Rational shownValue, - Unit const& shownIn, + Unit const& declaredIn, NumberFormatSpec const& formatSpec) { auto const spelling = [&]() -> std::expected { + NumberStyle const approximating = NumberStyle::approximate_decimal(formatSpec.roundingMode); switch (formatSpec.body) { case NumberFormatBody::Fraction: @@ -385,17 +390,16 @@ inline constexpr RoundingModeName RoundingModeNames[] { formatSpec.roundingMode, DecimalPadding::Padded); case NumberFormatBody::Approximated: { - Unit roundedIn = shownIn; - if (formatSpec.places.has_value()) - roundedIn.decimals = *formatSpec.places; - return checked_number_text(shownValue, - NumberStyle::approximate_decimal(formatSpec.roundingMode), - roundedIn); + if (!formatSpec.places.has_value()) + return checked_shown_value_text(shownValue, approximating, declaredIn); + Unit roundedIn = shown_unit_of(declaredIn, declaredIn.dimension); + roundedIn.decimals = *formatSpec.places; + return checked_number_text(shownValue, approximating, roundedIn); } case NumberFormatBody::ExactOrFraction: break; } - return checked_number_text(shownValue, NumberStyle::exact_decimal(), shownIn); + return checked_shown_value_text(shownValue, NumberStyle::exact_decimal(), declaredIn); }; std::expected const spelled = spelling(); if (!spelled) @@ -469,8 +473,7 @@ template shown_number(*shownMeasured.stored(), declaredIn, declaredIn.dimension); if (!shownValue) number_format_failed(shownValue.error()); - NumberText const spelled = - spell_formatted_number(*shownValue, shown_unit_of(declaredIn, declaredIn.dimension), formatSpec); + NumberText const spelled = spell_formatted_number(*shownValue, declaredIn, formatSpec); std::string unitText; auto appendTo = [&unitText](std::string_view written) { unitText += written; }; write_shown_unit(appendTo, declaredIn); @@ -695,9 +698,12 @@ struct formatter /// **A dimensioned unit with no symbol** cannot say what scale its number is /// on, so the number is moved exactly into the coherent unit and followed by /// that unit's spelling, as `number_text` writes it: 3 of a unit of 1/1000 kg -/// is `0.003 kg`, `{:/}` `3/1000 kg`. Every body, the decimals `~Mode` reads -/// included, then applies to the number in the coherent unit. A value that -/// cannot be moved throws, never writing the number on the other scale. +/// is `0.003 kg`, `{:/}` `3/1000 kg`. `{}` and `~Mode` then spell it as a +/// trace line does: the coherent unit's 3 places are a default nobody chose, +/// so they are never padded, and never round a value other than zero to `≈0` +/// -- `{:~HalfEven}` of 1/3 of that unit is `≈0.0003 kg`. `.N Mode` and +/// `~.N Mode` round at the N places of the coherent unit they name. A value +/// that cannot be moved throws, never writing the number on the other scale. /// /// **The modes** are `RoundingMode`'s enumerators, spelled exactly as they /// are there. **There is no default mode**: the same number rounds diff --git a/include/formula-cpp/number_text.hpp b/include/formula-cpp/number_text.hpp index 24607f0..143b491 100644 --- a/include/formula-cpp/number_text.hpp +++ b/include/formula-cpp/number_text.hpp @@ -659,6 +659,99 @@ namespace detail return checked_convert(declaredNumber, declared, coherent(dimension)); } + /// Whether @p shownIn is a unit nobody declared: exactly the coherent unit + /// `coherent()` builds for its dimension -- no symbol, no scale, and + /// `Unit`'s default of 3 decimals, which nobody chose. A trace shows a + /// computed value in one, a product in joules or a ratio. A quantity + /// declared in `unit::One` is the same `Unit` value, so it counts as + /// unlabelled too. + [[nodiscard]] constexpr bool is_unlabelled(Unit const& shownIn) noexcept + { + return shownIn == coherent(shownIn.dimension); + } + + /// @p numberStyle with `DecimalPadding::Trimmed`: the same notation and + /// the same rounding mode, never padded. + [[nodiscard]] constexpr NumberStyle trimmed(NumberStyle numberStyle) noexcept + { + switch (numberStyle.notation()) + { + case NumberNotation::ExactDecimal: + return NumberStyle::exact_decimal(DecimalPadding::Trimmed); + case NumberNotation::ApproximateDecimal: + return NumberStyle::approximate_decimal(numberStyle.approximation(), DecimalPadding::Trimmed); + case NumberNotation::Fraction: + break; + } + return numberStyle; + } + + /// Whether @p spelled is a rounding that came out as zero: `≈0`. + [[nodiscard]] constexpr bool rounded_to_zero(NumberText const& spelled) noexcept + { + std::string_view const spelledText = spelled.view(); + return spelledText.size() == ApproximationMarker.size() + 1 && spelledText.starts_with(ApproximationMarker) + && spelledText.back() == '0'; + } + + /// `checked_number_text` for a number shown in @p shownIn, except that a + /// number in a unit nobody declared (`is_unlabelled`) is never padded: + /// the 3 decimals it would be padded to are a default, not anyone's + /// statement of precision. An approximating style still rounds it at + /// those 3 places -- unless they round a value other than zero to `≈0`, + /// which says nothing of it. The places are then extended to its first + /// significant digit, up to 18, and the value, rounded there in the + /// style's mode, stays marked: a tariff in euros per joule, + /// 3401/33480000000, reads `≈0.0000001`, and 1/11250000 `≈0.00000009`. A + /// value with no digit within 18 places reads `≈0`. A unit someone + /// declared keeps its declared places, whatever they round to. + /// + /// The one spelling of a value a trace line shows, and of a `Measured` + /// value moved into the coherent unit (`checked_shown_value_text`), so + /// that `number_text`, `std::format` and a trace write such a value alike. + [[nodiscard]] constexpr std::expected checked_shown_text(Rational shownNumber, + NumberStyle numberStyle, + Unit const& shownIn) noexcept + { + if (!is_unlabelled(shownIn)) + return checked_number_text(shownNumber, numberStyle, shownIn); + NumberStyle const unpadded = trimmed(numberStyle); + std::expected const spelled = checked_number_text(shownNumber, unpadded, shownIn); + if (!spelled.has_value() || shownNumber == Rational { 0 } || !rounded_to_zero(*spelled)) + return spelled; + // The first significant digit is at the fewest places a truncation + // leaves something at; rounded there in the style's own mode, the + // value cannot come out as zero. + NumberStyle const truncating = NumberStyle::approximate_decimal(RoundingMode::TowardZero); + for (std::int32_t places = declared_decimals(shownIn).value + 1; places <= ExactDecimalPlaces; ++places) + { + Unit finer = shownIn; + finer.decimals = places; + std::expected const truncated = checked_number_text(shownNumber, truncating, finer); + if (!truncated.has_value()) + return spelled; + if (!rounded_to_zero(*truncated)) + return checked_number_text(shownNumber, unpadded, finer); + } + return spelled; + } + + /// @p shownValue, a value of a quantity declared in @p declaredIn that + /// `shown_number` has already moved into the unit it is shown in, as + /// @p numberStyle writes it there -- the number alone. A value moved into + /// the coherent unit is spelled as a trace line spells it + /// (`checked_shown_text`): its places are a default nobody declared, so + /// they are never padded, and never round a value other than zero to + /// `≈0`. A value in any other unit is spelled at the decimals that unit + /// declares (`checked_number_text`), as before. + [[nodiscard]] constexpr std::expected checked_shown_value_text( + Rational shownValue, NumberStyle numberStyle, Unit const& declaredIn) noexcept + { + if (spells_coherent_unit(declaredIn, declaredIn.dimension)) + return checked_shown_text(shownValue, numberStyle, coherent(declaredIn.dimension)); + return checked_number_text(shownValue, numberStyle, declaredIn); + } + /// Writes the power a base unit's factor is raised to, as plain text and a /// trace write it -- `^-1`, `^(1/2)`, `^(-1/2)`, and nothing for a power of /// 1 -- through @p writer, which takes each piece as a `std::string_view`. @@ -831,8 +924,11 @@ namespace detail /// that unit has no symbol and a dimension (`detail::spells_coherent_unit`): /// the number is then moved exactly into the coherent unit and followed by /// that unit's spelling, `3/1000 kg` for 3 of a unit of 1/1000 kg, so it is -/// never on a scale nothing after it names. A dimensionless unit with no -/// symbol writes the number alone. +/// never on a scale nothing after it names. Such a value is spelled as a +/// trace line spells it (`detail::checked_shown_value_text`): never padded to +/// the coherent unit's default 3 places, and never rounded to `≈0` when it is +/// not zero -- 1/3 of a unit of 1/1000 kg reads `≈0.0003 kg`. A dimensionless +/// unit with no symbol writes the number alone. /// /// @return any error of `checked_number_text(Rational, NumberStyle, Unit const&)`; /// any error of the move into the coherent unit (`checked_convert`), @@ -856,7 +952,7 @@ template if (!shownValue) return std::unexpected { shownValue.error() }; std::expected spelled = - checked_number_text(*shownValue, shownStyle, detail::shown_unit_of(declaredIn, declaredIn.dimension)); + detail::checked_shown_value_text(*shownValue, shownStyle, declaredIn); if (!spelled || !detail::writes_a_unit(declaredIn)) return spelled; detail::NumberTextWriter appendTo { *spelled }; diff --git a/include/formula-cpp/render.hpp b/include/formula-cpp/render.hpp index 793cb89..e7c1037 100644 --- a/include/formula-cpp/render.hpp +++ b/include/formula-cpp/render.hpp @@ -342,79 +342,6 @@ namespace detail return quantitySymbol + "(" + std::string { attemptIndex } + ")"; } - /// Whether @p shownIn is a unit nobody declared: exactly the coherent unit - /// `coherent()` builds for its dimension -- no symbol, no scale, and - /// `Unit`'s default of 3 decimals, which nobody chose. A trace shows a - /// computed value in one, a product in joules or a ratio. A quantity - /// declared in `unit::One` is the same `Unit` value, so it counts as - /// unlabelled too. - [[nodiscard]] constexpr bool is_unlabelled(Unit const& shownIn) noexcept - { - return shownIn == coherent(shownIn.dimension); - } - - /// @p numberStyle with `DecimalPadding::Trimmed`: the same notation and - /// the same rounding mode, never padded. - [[nodiscard]] constexpr NumberStyle trimmed(NumberStyle numberStyle) noexcept - { - switch (numberStyle.notation()) - { - case NumberNotation::ExactDecimal: - return NumberStyle::exact_decimal(DecimalPadding::Trimmed); - case NumberNotation::ApproximateDecimal: - return NumberStyle::approximate_decimal(numberStyle.approximation(), DecimalPadding::Trimmed); - case NumberNotation::Fraction: - break; - } - return numberStyle; - } - - /// Whether @p spelled is a rounding that came out as zero: `≈0`. - [[nodiscard]] constexpr bool rounded_to_zero(NumberText const& spelled) noexcept - { - std::string_view const spelledText = spelled.view(); - return spelledText.size() == ApproximationMarker.size() + 1 && spelledText.starts_with(ApproximationMarker) - && spelledText.back() == '0'; - } - - /// `checked_number_text` for a number shown in @p shownIn, except that a - /// number in a unit nobody declared (`is_unlabelled`) is never padded: - /// the 3 decimals it would be padded to are a default, not anyone's - /// statement of precision. An approximating style still rounds it at - /// those 3 places -- unless they round a value other than zero to `≈0`, - /// which says nothing of it. The places are then extended to its first - /// significant digit, up to 18, and the value, rounded there in the - /// style's mode, stays marked: a tariff in euros per joule, - /// 3401/33480000000, reads `≈0.0000001`, and 1/11250000 `≈0.00000009`. A - /// value with no digit within 18 places reads `≈0`. A unit someone - /// declared keeps its declared places, whatever they round to. - [[nodiscard]] constexpr std::expected checked_shown_text(Rational shownNumber, - NumberStyle numberStyle, - Unit const& shownIn) noexcept - { - if (!is_unlabelled(shownIn)) - return checked_number_text(shownNumber, numberStyle, shownIn); - NumberStyle const unpadded = trimmed(numberStyle); - std::expected const spelled = checked_number_text(shownNumber, unpadded, shownIn); - if (!spelled.has_value() || shownNumber == Rational { 0 } || !rounded_to_zero(*spelled)) - return spelled; - // The first significant digit is at the fewest places a truncation - // leaves something at; rounded there in the style's own mode, the - // value cannot come out as zero. - NumberStyle const truncating = NumberStyle::approximate_decimal(RoundingMode::TowardZero); - for (std::int32_t places = declared_decimals(shownIn).value + 1; places <= ExactDecimalPlaces; ++places) - { - Unit finer = shownIn; - finer.decimals = places; - std::expected const truncated = checked_number_text(shownNumber, truncating, finer); - if (!truncated.has_value()) - return spelled; - if (!rounded_to_zero(*truncated)) - return checked_number_text(shownNumber, unpadded, finer); - } - return spelled; - } - /// @p shownNumber, a number stated in @p shownIn, as @p numberStyle writes /// it (`checked_shown_text`), or its exact fraction where that style /// cannot write it in that unit. diff --git a/test/format_tests.cpp b/test/format_tests.cpp index 186f247..d8a9d5f 100644 --- a/test/format_tests.cpp +++ b/test/format_tests.cpp @@ -173,6 +173,12 @@ TEST_CASE("a Measured in a dimensioned unit with no symbol formats in the cohere CHECK(std::format("{:/}", threeUnnamed) == "3/1000 kg"); CHECK(std::format("{:.4HalfEven}", threeUnnamed) == "0.0030 kg"); CHECK(std::format("{:>10}", threeUnnamed) == " 0.003 kg"); + // `~Mode` reads the coherent unit's places as a trace does: 1/3 of the + // unit is 1/3000 kg, `≈0.0003 kg`, never `≈0 kg`. `~.N` rounds at the N + // places it names. + Measured const thirdUnnamed { Rational { 1, 3 } }; + CHECK(std::format("{:~HalfEven}", thirdUnnamed) == "\xe2\x89\x88" "0.0003 kg"); + CHECK(std::format("{:~.5HalfEven}", thirdUnnamed) == "\xe2\x89\x88" "0.00033 kg"); // The same text number_text writes. formula::NumberText const fraction = formula::number_text(threeUnnamed, NumberStyle::fraction()); CHECK(std::format("{:/}", threeUnnamed) == fraction.view()); diff --git a/test/number_text_tests.cpp b/test/number_text_tests.cpp index b41c001..c55396f 100644 --- a/test/number_text_tests.cpp +++ b/test/number_text_tests.cpp @@ -558,6 +558,14 @@ TEST_CASE("a measured value in a dimensioned unit with no symbol is shown in the STATIC_REQUIRE(formula::number_text(Measured { Rational { 3 } }, NumberStyle::approximate_decimal(RoundingMode::HalfEven)) == "0.003 kg"); + // The coherent unit's 3 places are a default nobody chose, as in a trace: + // never padded to, and never rounding a value that is not zero to `≈0`. + STATIC_REQUIRE(formula::number_text(Measured { Rational { 30 } }, + NumberStyle::exact_decimal(DecimalPadding::Padded)) + == "0.03 kg"); + STATIC_REQUIRE(formula::number_text(Measured { Rational { 1, 3 } }, + NumberStyle::approximate_decimal(RoundingMode::HalfEven)) + == "\xe2\x89\x88" "0.0003 kg"); // A unit with only a negative exponent is written with it, and no slash. STATIC_REQUIRE(formula::number_text(Measured { Rational { 3 } }, NumberStyle::fraction()) == "3000 kg^-1"); diff --git a/test/trace_shown_unit_tests.cpp b/test/trace_shown_unit_tests.cpp index 51eef4b..ef2400b 100644 --- a/test/trace_shown_unit_tests.cpp +++ b/test/trace_shown_unit_tests.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -24,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -887,3 +889,32 @@ TEST_CASE("every value of a rejection, a bill, the statistics, a precision limit check_each_value_is_in_the_unit_written_after_it( recorded_trace(formula::opaque_output<"span">(lowestAndSpan) + var, determinations)); } + +TEST_CASE("a Measured value in a unit with no symbol reads in number_text and std::format as its trace line does", + "[trace-render][shown-unit]") +{ + // The coherent unit's 3 places are a default nobody chose: a trace never + // pads a value to them, and never rounds one that is not zero to `≈0`. + // `number_text` and `std::format` spell a value they move into it alike. + formula::NumberStyle const halfEven = formula::NumberStyle::approximate_decimal(formula::RoundingMode::HalfEven); + for (Rational const unnamedGrams : { Rational { 3 }, Rational { 30 }, Rational { 1, 3 } }) + { + formula::Measured const measured { unnamedGrams }; + for (formula::NumberStyle const numberStyle : + { formula::NumberStyle::fraction(), formula::NumberStyle::exact_decimal(), + formula::NumberStyle::exact_decimal(formula::DecimalPadding::Padded), halfEven }) + { + std::string const traced = formula::render_trace(recorded_trace(var, formula::environment(measured)), + { .maxSteps = 20, .numbers = numberStyle }); + formula::NumberText const spelled = formula::number_text(measured, numberStyle); + CHECK(traced == "1. m_u = " + std::string { spelled.view() } + "\n"); + } + formula::NumberText const exact = formula::number_text(measured, formula::NumberStyle::exact_decimal()); + formula::NumberText const approximated = formula::number_text(measured, halfEven); + CHECK(std::format("{}", measured) == exact.view()); + CHECK(std::format("{:~HalfEven}", measured) == approximated.view()); + } + // 1/3 of the unnamed gram is 1/3000 kg: `≈0.0003 kg`, never `≈0 kg`. + CHECK(std::format("{:~HalfEven}", formula::Measured { Rational { 1, 3 } }) + == "\xe2\x89\x88" "0.0003 kg"); +} diff --git a/tools/gallery/main.cpp b/tools/gallery/main.cpp index 0fa0011..23a6d68 100644 --- a/tools/gallery/main.cpp +++ b/tools/gallery/main.cpp @@ -476,6 +476,10 @@ constexpr auto settledEstimate = formula::retry Date: Mon, 5 Oct 2026 03:35:54 +0200 Subject: [PATCH 35/35] docs: say which units number_text reads declared decimals of A `Measured` value in a dimensioned unit with no symbol is shown in the coherent unit, at that unit's places, so the refusal for declared decimals outside -18 to 18 applies only to a `Rational` in the unit passed and to a `Measured` whose unit has a symbol or is dimensionless. Also wraps a test line that was over 125 columns. Signed-off-by: Christian Parpart --- docs/display.md | 11 +++++++---- test/trace_shown_unit_tests.cpp | 4 ++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/display.md b/docs/display.md index 71a7bae..57156b8 100644 --- a/docs/display.md +++ b/docs/display.md @@ -488,10 +488,13 @@ std::println("two places: {}\n", twoPlaces.view()); **When a number cannot be spelled.** `decimal_text` throws `ArithmeticException` for more than 18 places, and where rounding to whole tens or thousands overflows. `number_text` throws it where its rounding -overflows so, for a padded or approximating style in a unit whose -declared decimals lie outside -18 to 18, where a value in a unit with no -symbol cannot move into the coherent unit, and where that unit's spelling -does not fit the buffer. Each has a `checked_` form, +overflows so, and for a padded or approximating style in a unit whose +declared decimals lie outside -18 to 18: a `Rational` in any unit you pass, +or a `Measured` whose unit has a symbol or is dimensionless. A `Measured` in +a dimensioned unit with no symbol is shown in the coherent unit, at that +unit's places, so its unit's declared decimals are never read; it throws +instead where its value cannot move into the coherent unit, and where that +unit's spelling does not fit the buffer. Each has a `checked_` form, `checked_decimal_text` and `checked_number_text`, that returns the `ArithmeticError` in a `std::expected` instead of throwing. A trace never throws for a number: a line whose value its style cannot spell reads diff --git a/test/trace_shown_unit_tests.cpp b/test/trace_shown_unit_tests.cpp index ef2400b..80004a1 100644 --- a/test/trace_shown_unit_tests.cpp +++ b/test/trace_shown_unit_tests.cpp @@ -904,8 +904,8 @@ TEST_CASE("a Measured value in a unit with no symbol reads in number_text and st { formula::NumberStyle::fraction(), formula::NumberStyle::exact_decimal(), formula::NumberStyle::exact_decimal(formula::DecimalPadding::Padded), halfEven }) { - std::string const traced = formula::render_trace(recorded_trace(var, formula::environment(measured)), - { .maxSteps = 20, .numbers = numberStyle }); + formula::Trace<> const recorded = recorded_trace(var, formula::environment(measured)); + std::string const traced = formula::render_trace(recorded, { .maxSteps = 20, .numbers = numberStyle }); formula::NumberText const spelled = formula::number_text(measured, numberStyle); CHECK(traced == "1. m_u = " + std::string { spelled.view() } + "\n"); }